# Politiques de protection des données CloudWatch : masquer les PII avant qu'elles n'atterrissent dans vos logs

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.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Pipeline d'un événement de log avec une politique de protection des données. L'application écrit une ligne contenant un email et un numéro de téléphone. CloudWatch Logs l'ingère, la politique fait correspondre les identifiants EmailAddress et PhoneNumber, l'action d'audit émet un constat vers un groupe de logs de constats et incrémente la métrique LogEventsWithFindings, et l'action de de-identify stocke l'événement avec les correspondances masquées. Un ingénieur avec logs:Unmask peut encore voir l'original ; tous les autres voient des astérisques. Une alarme sur la métrique appelle l'équipe.">
<defs><marker id="arrDp" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="60" width="160" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="95" y="86" text-anchor="middle" fill="#f1f3ff" font-weight="700">application</text><text x="95" y="106" text-anchor="middle" fill="#ff6b8a" font-size="11">{"email":"a@b.com","phone":…}</text>
<line x1="177" y1="95" x2="205" y2="95" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDp)"/>
<rect x="208" y="40" width="250" height="110" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="333" y="64" text-anchor="middle" fill="#ffd166" font-weight="700">politique de protection · à l'ingestion</text><text x="333" y="88" text-anchor="middle" fill="#f1f3ff">identifiants : EmailAddress,</text><text x="333" y="106" text-anchor="middle" fill="#f1f3ff">PhoneNumber, CreditCardNumber, …</text><text x="333" y="132" text-anchor="middle" fill="#9aa3c7">0,12 $ par Go analysé</text>
<line x1="460" y1="75" x2="518" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDp)"/>
<line x1="460" y1="115" x2="518" y2="130" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDp)"/>
<rect x="520" y="30" width="180" height="56" rx="10" fill="#0d1120" stroke="#ff6b8a" stroke-width="1.5"/><text x="610" y="52" text-anchor="middle" fill="#ff6b8a" font-weight="700">audit</text><text x="610" y="72" text-anchor="middle" fill="#9aa3c7" font-size="11">constat → groupe de logs · métrique +1</text>
<rect x="520" y="104" width="180" height="56" rx="10" fill="#0d1120" stroke="#4fffb0" stroke-width="1.5"/><text x="610" y="126" text-anchor="middle" fill="#4fffb0" font-weight="700">de-identify</text><text x="610" y="146" text-anchor="middle" fill="#9aa3c7" font-size="11">stocké comme {"email":"*******"}</text>
<line x1="702" y1="58" x2="740" y2="58" stroke="#ff6b8a" stroke-width="1.5" marker-end="url(#arrDp)"/>
<rect x="742" y="30" width="145" height="56" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="814" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">alarme</text><text x="814" y="72" text-anchor="middle" fill="#9aa3c7" font-size="11">LogEventsWithFindings &gt; 0</text>
<line x1="702" y1="132" x2="740" y2="132" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDp)"/>
<rect x="742" y="104" width="145" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="814" y="126" text-anchor="middle" fill="#f1f3ff" font-weight="700">Logs Insights</text><text x="814" y="146" text-anchor="middle" fill="#9aa3c7" font-size="11">démasquage seulement avec logs:Unmask</text>
<text x="450" y="200" text-anchor="middle" fill="#9aa3c7">La valeur brute n'atteint jamais le stockage. Le constat vous dit quel groupe de logs et quel identifiant, pas la valeur.</text>
</g>
</svg>
</div>

## 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 :

```ts
// 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](/fr/blog/your-logs-should-not-know-which-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 :

```yaml
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.

<div class="article-figure">
<svg viewBox="0 0 900 200" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Trois endroits où caviarder, de gauche à droite, par ordre de préférence. Dans l'application : un logger à liste blanche, rien de la requête par défaut, la seule vraie solution. Dans l'agent : le processeur de redaction d'OpenTelemetry Collector, avant que les données ne quittent l'hôte, fonctionne avec n'importe quel backend. Dans CloudWatch : politique de protection des données, à l'ingestion, identifiants gérés et constats, le filet de sécurité qui attrape ce que les deux premiers ont manqué.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="30" width="270" height="140" rx="12" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="155" y="56" text-anchor="middle" fill="#4fffb0" font-size="14" font-weight="700">1 · dans l'application</text>
<text x="155" y="82" text-anchor="middle" fill="#f1f3ff">logger à liste blanche</text>
<text x="155" y="102" text-anchor="middle" fill="#9aa3c7">rien de req.body par défaut</text>
<text x="155" y="122" text-anchor="middle" fill="#9aa3c7">règle de revue : pas de charges brutes</text>
<text x="155" y="152" text-anchor="middle" fill="#4fffb0" font-weight="700">la solution</text>
<rect x="315" y="30" width="270" height="140" rx="12" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/>
<text x="450" y="56" text-anchor="middle" fill="#ffd166" font-size="14" font-weight="700">2 · dans l'agent</text>
<text x="450" y="82" text-anchor="middle" fill="#f1f3ff">processeur redaction d'OTel Collector</text>
<text x="450" y="102" text-anchor="middle" fill="#9aa3c7">avant que les données ne quittent l'hôte</text>
<text x="450" y="122" text-anchor="middle" fill="#9aa3c7">tout backend, vos regex</text>
<text x="450" y="152" text-anchor="middle" fill="#ffd166" font-weight="700">la ceinture</text>
<rect x="610" y="30" width="270" height="140" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="745" y="56" text-anchor="middle" fill="#7b8cff" font-size="14" font-weight="700">3 · dans CloudWatch</text>
<text x="745" y="82" text-anchor="middle" fill="#f1f3ff">politique de protection des données</text>
<text x="745" y="102" text-anchor="middle" fill="#9aa3c7">à l'ingestion · identifiants gérés</text>
<text x="745" y="122" text-anchor="middle" fill="#9aa3c7">les constats disent où regarder</text>
<text x="745" y="152" text-anchor="middle" fill="#7b8cff" font-weight="700">le détecteur de fumée</text>
<text x="450" y="192" text-anchor="middle" fill="#9aa3c7">Faites les trois. Le premier est le seul qui réduit ce que vous stockez ; le troisième est le seul qui vous dit que les deux premiers ont échoué.</text>
</g>
</svg>
</div>

## 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-echo` sur 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](/contact).
