Un ingénieur support a demandé un log de debug sur une route d'API, « juste pour une semaine », pour traquer un bug dans la validation d'adresse. La ligne de log, c'était tout le corps de la requête. La route, c'était le checkout. Pendant neuf jours, l'email, le numéro de téléphone, l'adresse de livraison et les quatre derniers chiffres de la carte de chaque commande ont été écrits dans un groupe de logs CloudWatch avec 30 jours de rétention, lisible par quiconque avait logs:GetLogEvents sur le compte, c'est-à-dire tout le monde.
Personne n'a rien fait de mal avec les données. Nous l'avons découvert parce qu'une politique de protection des données activée un mois plus tôt l'a signalé, à raison d'environ 1 200 constats par jour, et que son alarme s'est déclenchée. Cet article parle de cette politique : ce qu'elle est, ce qu'elle coûte, comment elle se configure, et pourquoi nous la considérons toujours comme un filet de sécurité plutôt qu'une solution.
Ce que fait une politique de protection des données
CloudWatch Logs peut analyser chaque événement à l'ingestion contre un ensemble d'identifiants de données, gérés par AWS (adresses email, numéros de téléphone, numéros de carte, clés secrètes AWS, IBAN, identifiants nationaux pour une longue liste de pays) ou personnalisés (une regex), et prendre deux types d'actions. Audit compte les correspondances, émet une métrique, et écrit optionnellement un constat dans un autre groupe de logs, S3 ou Firehose. De-identify masque les caractères correspondants dans l'événement stocké, donc ce que vous voyez dans Logs Insights est ***********.
La politique peut être attachée à un seul groupe de logs ou à tout le compte. Le masquage est appliqué à l'ingestion et il est irréversible pour quiconque n'a pas la permission logs:Unmask ; ceux qui l'ont peuvent voir l'original avec un drapeau sur la requête. L'analyse coûte 0,12 $ par Go de logs analysés, en plus de l'ingestion.
La politique que nous exploitons
Au niveau du compte, pour qu'un nouveau groupe de logs ne puisse pas être créé en dehors. Gérée en CDK comme tout le reste :
// infra/lib/log-data-protection.ts
new logs.CfnAccountPolicy(this, 'LogDataProtection', {
policyName: 'pii-guard',
policyType: 'DATA_PROTECTION_POLICY',
scope: 'ALL',
policyDocument: JSON.stringify({
Name: 'pii-guard',
Version: '2021-06-01',
Statement: [
{
Sid: 'audit',
DataIdentifier: [
'arn:aws:dataprotection::aws:data-identifier/EmailAddress',
'arn:aws:dataprotection::aws:data-identifier/PhoneNumber-US',
'arn:aws:dataprotection::aws:data-identifier/CreditCardNumber',
'arn:aws:dataprotection::aws:data-identifier/AwsSecretKey',
'arn:aws:dataprotection::aws:data-identifier/IpAddress',
],
Operation: { Audit: { FindingsDestination: { CloudWatchLogs: { LogGroup: findingsGroup.logGroupName } } } },
},
{
Sid: 'mask',
DataIdentifier: [ /* même liste */ ],
Operation: { Deidentify: { MaskConfig: {} } },
},
],
}),
});
new cloudwatch.Alarm(this, 'PiiInLogs', {
metric: new cloudwatch.Metric({ namespace: 'AWS/Logs', metricName: 'LogEventsWithFindings', statistic: 'Sum', period: Duration.minutes(5) }),
threshold: 0,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
evaluationPeriods: 1,
});
Deux déclarations, parce qu'audit et de-identify sont des opérations séparées et que vous voulez les deux : le masquage protège les données, le constat d'audit vous dit où est la fuite pour que vous corrigiez le code. L'alarme sur LogEventsWithFindings est toute la raison pour laquelle nous avons attrapé le log du checkout en neuf jours plutôt que jamais.
IpAddress est sur la liste délibérément et c'est celui qui génère du bruit. Nos logs d'accès contiennent légitimement les IP des clients, et les masquer là casserait le flux d'investigation WAF. La politique de compte est la base ; pour les deux groupes de logs où les IP sont le but, une politique au niveau du groupe sans IpAddress la surcharge. Les politiques de groupe et de compte s'appliquent toutes deux, et un terme est masqué si l'une ou l'autre correspond, donc la surcharge doit être l'absence de l'identifiant, pas une déclaration permissive.
À quoi ressemblent les constats
Le groupe de logs de constats reçoit un événement JSON par événement de log correspondant, avec le groupe, le flux, les identifiants trouvés et les décalages de caractères. Pas la valeur. Une requête Logs Insights dessus donne l'inventaire des fuites :
fields @timestamp, resourceArn, dataIdentifiers.0.name as identifier
| stats count(*) as events by resourceArn, identifier
| sort events desc
Le jour où nous avons regardé, la première ligne était le groupe de logs de la route de checkout avec EmailAddress et PhoneNumber-US, suivie d'une Lambda qui loguait mot pour mot la charge utile d'un webhook tiers (EmailAddress), et d'un log CodeBuild qui avait affiché une variable d'environnement contenant une clé d'accès pendant un build qu'un collègue déboguait (AwsSecretKey). Trois fuites, les habitudes de trois équipes différentes, une requête.
Ce que ça coûte, et ce que ça a épargné
À notre volume, environ 5 Go de logs par jour, l'analyse coûte 18 $ par mois. Autant que le WAF et moins que Route 53. Pour une plateforme qui traite des paiements, c'est le contrôle le moins cher de la facture.
Ce qu'elle ne fait pas, c'est faire disparaître le problème de fond. Les événements masqués occupent toujours du stockage et coûtent toujours de l'ingestion. Le log de debug de l'ingénieur support restait une erreur de conception : loguer tout le corps d'une requête sur une route de checkout, à n'importe quel niveau de masquage, c'est faux. La protection des données est le détecteur de fumée, pas l'ignifugation. L'ignifugation est dans l'application : un logger avec une liste blanche de champs, pour que par défaut rien de la requête n'atteigne le log, et une règle de revue de code qui rejette à vue tout nouveau log.info(req.body).
Si vos logs ne vont pas dans CloudWatch
Nous avons soutenu ailleurs que les logs applicatifs ne devraient pas appartenir au cloud. Si les vôtres passent par un OpenTelemetry Collector vers Loki, le même contrôle a sa place dans le collecteur, et il y est sans doute mieux, parce qu'il s'exécute avant que les données ne quittent l'hôte :
processors:
redaction:
allow_all_keys: true
blocked_values:
- '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' # email
- '\b(?:\d[ -]*?){13,16}\b' # numéro de carte
- 'AKIA[0-9A-Z]{16}' # AWS access key id
summary: info
Le processeur redaction masque les valeurs correspondantes dans les attributs de logs et rapporte un résumé du nombre masqué, sur lequel vous pouvez alerter. Il n'a pas la bibliothèque AWS de formats d'identifiants nationaux, donc pour une charge réglementée vous maintiendriez la liste vous-même. Pour les trois classes de fuites que nous voyons réellement, emails, cartes et identifiants, trois regex suffisent.
Ce que nous avons changé après le constat
- Le log de debug du checkout a été retiré dans l'heure. Le groupe de logs est passé à 1 jour de rétention jusqu'à l'expiration des événements masqués, puis de nouveau à 30.
- Le logger de l'application a reçu une liste blanche :
orderId,userId,route,status,durationMs. Tout le reste doit être ajouté par son nom, en revue de code. - La Lambda de webhook logue maintenant la forme de la charge utile (clés et types) et un hash, jamais les valeurs.
- CodeBuild a reçu
--no-echosur l'étape d'environnement, et la clé d'accès affichée a été renouvelée, parce qu'une ligne de log est une copie. - L'alarme va maintenant sur le canal d'astreinte avec la requête Logs Insights en lien. Temps moyen entre fuite et correction lors des deux occasions depuis : moins d'une heure.
Activez la politique avant d'en avoir besoin. C'est une seule ressource CloudFormation, dix-huit dollars par mois, et elle trouvera quelque chose. La nôtre a trouvé trois choses la première semaine. Si vous voulez de l'aide pour tracer la liste blanche de votre propre logger, ou pour mettre en place la politique sur plusieurs comptes, parlons-en.