Masquage et conservation
Ce qui n’est jamais stocké, et ce qui cesse de l’être. Le masquage tourne avant l’écriture, parce qu’un secret supprimé après coup a quand même été écrit.
Le masquage tourne avant le stockage
Les prompts et les complétions sont ce que mcpd détient de plus précieux et de plus dangereux. Le masquage est la troisième étape du pipeline d’ingestion, avant l’écriture, si bien qu’une valeur reconnue par une règle n’atteint jamais la base de données.
-
Decode
OTLP or native, gzip or plain
-
Sample
Decided once, at the root
-
Redact
Before anything is written
-
Price
From the configured price book
-
Store
One transaction per batch
Redaction is stage three, not a cleanup job. A value that a rule matches never reaches storage at all.
L’ordre est tout l’enjeu. Une valeur nettoyée après coup a déjà été sur le disque, dans le journal d’écriture anticipée, et sans doute dans la sauvegarde de la nuit dernière. Jamais écrit est une garantie différente de supprimé plus tard, et c’est la seule que vous pouvez énoncer sans détour à quelqu’un qui pose la question.
Le masquage est actif par défaut. Un outil qui ne vous protège qu’une fois
configuré ne protège personne. --no-redact, ou
MCPD_NO_REDACT, le désactive, ce qui n’a de sens que si quelque
chose en amont masque déjà.
Le masquage est le bout du fil côté daemon. Les SDK tiennent l’autre bout :
MCPD_CAPTURE_PAYLOADS=false, ou capture_payloads
dans le code, garde les prompts et les complétions dans le processus. Un
span arrive quand même avec ses temps, son statut, sa consommation, son
modèle, sa corrélation et ses erreurs, et input et
output sont retirés avant la mise en file : ils ne traversent donc jamais le
réseau. Une équipe qui ne doit pas envoyer les octets du tout règle cela, et
un span qui ne porte rien de sensible peut se réactiver pour lui-même.
Les règles par défaut
Une règle est un ensemble de sous-chaînes en minuscules et un mode. Une clé
correspond quand son nom, mis en minuscules, contient l’une d’elles. C’est
parce que la correspondance porte sur une sous-chaîne que
X-API-Key, authorization et
customer_email sont tous attrapés sans avoir à lister chaque
casse et chaque préfixe.
| Mode | Noms de clés qui correspondent |
|---|---|
mask | api_key, apikey, api-key,
secret, password, passwd,
access_token, refresh_token,
id_token, bearer,
authorization, auth_token,
private_key, client_secret,
session_token, credential,
signature, x-api-key |
partial | email, phone, ssn,
credit_card, card_number, iban |
hash | user_id, userid |
Quand une clé reconnue porte un objet ou un tableau, tout ce qui se trouve
dessous est masqué aussi.
{"credential": {"value": "hunter2", "note": "prod"}}
perd les deux champs, pas seulement celui dont le nom semblait dangereux.
La limite mérite d’être dite sans détour : les règles portent sur les
noms de clés, pas sur les valeurs. Une clé d’API qu’un
utilisateur a collée au milieu d’un prompt, stockée sous une clé nommée
content, n’est reconnue par rien ici. Le masquage arrête la
capture structurée des secrets et des identifiants. Ce n’est pas un
analyseur de contenu, et la prendre pour tel vous décevra.
Les quatre modes
| Mode | Ce qui arrive à la valeur |
|---|---|
drop | La clé est retirée entièrement, comme si elle n’avait jamais été envoyée. |
mask | La valeur est remplacée par [redacted]. |
partial |
Les deux premiers et les deux derniers caractères survivent, réunis
par des points de suspension : [email protected] devient
al…om. De quoi reconnaître une valeur que vous connaissez
déjà, pas de quoi l’utiliser. Une valeur de six caractères ou moins
est remplacée entièrement, parce que garder quatre caractères sur cinq
en divulgue l’essentiel.
|
hash |
La valeur est remplacée par sha256: suivi des douze
premiers octets d’une empreinte SHA-256 à clé, en vingt-quatre
caractères hexadécimaux.
|
Les règles intégrées en utilisent trois sur quatre. Rien n’est supprimé par
défaut : drop est là pour une règle que vous ajoutez, quand
même la présence d’un champ est plus que ce que vous voulez garder.
$ curl -s "$MCPD/v1/api/traces/$TRACE" | jq .spans[0].input{ "question": "Where is my order A-1042?", "api_key": "[redacted]", "email": "al…om", "user_id": "sha256:9f2c1b7d4a08e5c3f6b1d0a4"} Pourquoi le hachage est à clé
Le hachage garde un champ utile. Sous une même clé, des entrées égales donnent des empreintes égales : vous pouvez donc encore regrouper par identifiant d’utilisateur masqué, compter combien de personnes distinctes ont rencontré une panne, et suivre l’une d’elles au fil d’une session, sans détenir qui elles sont.
Sans clé, cette propriété est un trou. Les identifiants viennent d’espaces petits et devinables, et quiconque a l’empreinte et ce code source peut hacher des candidats jusqu’à ce que l’un corresponde. C’est la clé qui rend l’empreinte irrécupérable plutôt que simplement pénible à retrouver.
La clé intégrée est une constante dans le code source, donc elle est publique. Définissez la vôtre dans tout déploiement qui hache quoi que ce soit.
un déploiement qui hache et qui expire
mcpd --redact-key "$MCPD_REDACT_KEY" --retention-days 30 Changer la clé rompt le lien, volontairement. Toutes les empreintes changent, donc les lignes écrites avant la rotation ne peuvent plus être jointes à celles écrites après. C’est à cela que sert une rotation, et c’est aussi ce qu’elle coûte. Tournez la clé quand vous voulez le lien rompu, pas par habitude sur un calendrier.
Lisez la seconde moitié de ce coût avant de tourner la clé. L’effacement par sujet compare l’empreinte stockée : dès que la nouvelle clé est en vigueur, toute ligne écrite sous l’ancienne est hors de portée d’une demande de suppression. mcpd ne peut pas vous dire que c’est ce qui s’est passé, parce que rien n’enregistre quelle clé a écrit quelle ligne. Tout ce qu’il peut faire, c’est refuser de laisser le zéro qui en résulte passer pour une réponse, et la section sur le zéro plus bas est ce refus.
Tous les signaux, pas seulement les spans
Un secret fuit aussi facilement par une ligne de log que par un prompt : le même masquage passe donc sur les six signaux à l’entrée.
- Spans :
input,output,attributesetmetadata. - Logs : les
fieldsstructurés. - Métriques : les
labels. - Scores : les
metadata. - Retours : les
metadata, et lecommenten texte libre. - Notes d’aspect : la phrase, dans
text.
Le nom d’un span et le message d’un enregistrement de log ne passent pas par le masquage. Ce sont des identifiants, pas des payloads, et les réécrire casserait le regroupement. Ne construisez pas un nom de span à partir de l’adresse e-mail d’un client.
Le stockage des exécutions n’est pas masqué
Les exécutions durables sont l’exception, et c’est délibéré. Le stockage
des exécutions garde l’entrée, l’état, la sortie et la corrélation d’une
exécution, et l’entrée, la sortie et l’erreur de chaque étape, exactement
tels qu’ils ont été envoyés, avec user_id non haché. Ce sont
les données de travail du workflow : une étape ultérieure les relit, et une
valeur que le masquage aurait réécrite changerait ce que fait l’exécution.
Les spans qu’une exécution écrit dans le stockage des traces prennent le
chemin d’ingestion ordinaire : le stockage des traces les détient donc
masqués et hachés comme tout autre span. La conservation et l’effacement
atteignent tous deux le stockage des exécutions, comme le disent les
sections ci-dessous, et chaque lecture d’une exécution par l’API est
enregistrée dans le journal d’audit.
Chaînes longues et documents profonds
Deux bornes s’appliquent à tout, qu’une règle ait reconnu quelque chose ou non.
Toute chaîne de plus de 128 KiB est tronquée, avec
… [N bytes truncated] ajouté à la fin pour que le lecteur sache
que la valeur est incomplète et non courte. La coupe tombe sur une frontière
de caractère, jamais à l’intérieur d’un point de code multioctet. Un seul
prompt emballé ne doit pas décider de la taille de votre base.
Le parcours s’arrête à une profondeur de 32, et tout ce qui est en dessous
est remplacé par [too deeply nested]. Un document pathologique
ou autoréférent qui arrive sur un port d’ingestion ouvert ne peut pas
saturer un CPU.
Conservation
--retention-days, ou MCPD_RETENTION_DAYS, supprime
les signaux plus vieux que ce nombre de jours. Omettez l’option et rien
n’est jamais supprimé. Garder les données par défaut est dans le bon sens :
un outil qui jetterait sans bruit les traces du mois dernier se ferait
découvrir au pire moment.
La passe tourne sur un minuteur horaire dans le daemon, pas au moment d’une
requête. Une passe peut supprimer un très grand nombre de lignes, et ce
travail ne doit jamais tomber dans la requête de quelqu’un.
Chaque passe retire, en une seule transaction, les spans, les logs, les
métriques et les lignes de trace dont le received_at est
antérieur à la coupure, ainsi que les scores, les retours et les notes
d’aspect dont l’horodatage propre l’est, puis journalise combien de lignes
sont parties. Les entrées de l’index plein texte sont effacées d’abord,
tant que les lignes qui les nomment existent encore, pour que rien ne reste
orphelin.
La même passe, sur la même fenêtre, élague le stockage des exécutions. Elle supprime chaque exécution durable dont la racine s’est terminée avant la coupure, avec ses exécutions filles et tout ce qui est stocké pour elles, par lots, pour que les workers ne soient pas retenus pendant ce travail. Une exécution qui n’est pas terminée n’est jamais supprimée, quelle que soit la durée de son attente. Les définitions ne sont pas élaguées.
La passe couvre le projet nommé par --project et chaque projet
du bloc projects du fichier de configuration, pas seulement le
projet par défaut. Un projet qui y définit son propre
retention_days est balayé sur cette valeur, et les autres sur
celle du daemon. Un projet absent des deux endroits, celui qu’un émetteur a
nommé de lui-même, n’a pas de fenêtre et n’est jamais élagué. Cela compte
quand un seul daemon sert plusieurs équipes : une équipe qui a convenu de
garder trente jours ne les garde pas en silence parce que le réglage d’une
autre équipe était le seul que la passe ait lu, et une équipe que personne
n’a inscrite garde tout.
Une demande d’effacement concernant une personne
DELETE /v1/api/subjects/{user_id} répond en un seul appel à
une demande concernant une personne. Envoyez l’identifiant tel que votre
instrumentation l’a posé. Le daemon applique le même hachage qu’à
l’ingestion : un déploiement qui hache user_id n’exige donc pas
que vous le hachiez d’abord.
L’unité d’effacement est l’exécution, pas la ligne. Seul un span racine
porte en général un user_id, et ce sont les spans enfants qui
détiennent le prompt et la complétion : supprimer les lignes qui nomment le
sujet laisserait donc le payload debout. Une exécution entre dans le
périmètre quand la projection de sa trace nomme le sujet ou quand l’un de
ses spans le nomme, et alors l’exécution part en entier :
- Chaque span de cette exécution, avec les entrées et les sorties qu’il portait.
- Les logs, les métriques, les scores, les retours et les notes d’aspect qui en dépendent.
-
Les signaux qui nomment un
span_idet aucuntrace_id. Un score ou une ligne de log épinglée à une étape de l’exécution du sujet est atteinte par les identifiants de span des exécutions du périmètre, pas seulement par leurs identifiants de trace. -
Les lignes de log enregistrées au nom de la personne sans exécution à
laquelle se rattacher, reconnues sur leur propre
user_id. -
Le retour que la personne a écrit, reconnu sur
author_id, où qu’il soit rattaché. Cette ligne contient sa propre prose, et elle peut être rattachée à l’exécution de quelqu’un d’autre, et c’est justement là qu’elle survivait avant. - Les cas de dataset capturés depuis une exécution du périmètre. Ceux-là sont vidés plutôt que supprimés. La section qui suit la prochaine dit pourquoi.
-
Sur un daemon qui sert des exécutions durables, chaque exécution durable
terminée dont la corrélation porte le
user_id, reconnu tel que vous l’avez donné au démarrage de l’exécution, puisque le stockage des exécutions le garde non haché, avec tout son arbre d’exécutions filles et tout ce que le stockage des exécutions détient pour elles. Une exécution de cette personne qui n’est pas terminée n’est pas touchée : la supprimer corromprait le workflow dont elle fait partie. Elle est comptée dansruns_in_flightet nommée dansnote, pour que vous puissiez l’annuler, ou la laisser se terminer, puis renvoyer la demande.
Le stockage des exécutions est effacé en premier. Les spans d’une exécution attendent dans son outbox jusqu’à leur écriture dans le stockage des traces : effacer d’abord les exécutions supprime donc tout span encore en attente, et l’effacement du stockage des traces retire ensuite ceux qui y sont déjà écrits.
Ce que porte la réponse
Un compte par signal, deux comptes pour le stockage des exécutions, et six
champs à côté qui portent ce qu’un compte ne peut pas dire : ce qui a été
laissé là volontairement, et si un petit nombre peut se lire comme « rien
sur cette personne n’est stocké ici ». deleted est le total des
six comptes par signal. Les comptes d’exécutions et
aspect_notes_erased sont rapportés à côté, sans y être
ajoutés.
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq{ "project": "checkout", "traces": 1, "spans": 2, "logs": 1, "metrics": 0, "scores": 1, "feedback": 1, "deleted": 6, "runs_erased": 1, "runs_in_flight": 0, "dataset_items_erased": 1, "aspect_notes_erased": 0, "datasets": [ "checkout-regressions" ], "deletes_pending": false, "unmatchable_identifiers": 0, "note": "1 captured dataset cases were emptied in place rather than deleted, so a regression test is not destroyed without anyone being told. They hold no payload any more."} | Champ | Ce qu’il vous dit |
|---|---|
runs_erased |
Les exécutions durables supprimées du stockage des exécutions,
exécutions filles comprises. Elles ne sont pas comptées dans
deleted. Absent quand le daemon ne sert aucune
exécution
|
runs_in_flight |
Les exécutions durables de la personne laissées entières parce
qu’elles ne sont pas terminées. note nomme les
exécutions à annuler. Absent quand le daemon ne sert aucune
exécution
|
dataset_items_erased |
Les cas capturés vidés sur place. Ils ne sont pas comptés dans
deleted, parce que les lignes sont toujours là
|
aspect_notes_erased |
Les notes d’aspect supprimées. Elles ne sont pas comptées dans
deleted, qui totalise les six comptes par signal
affichés au-dessus
|
datasets | Dans quels datasets se trouvent ces cas, pour que vous puissiez aller les regarder |
deletes_pending | Vrai quand le backend applique les suppressions après avoir répondu : les comptes disent alors ce qui a correspondu, pas ce qui a déjà quitté le disque |
unmatchable_identifiers | Les identifiants stockés dans ce projet qui sont sous une forme que cette demande ne pouvait pas reconnaître |
note | Pourquoi les nombres ci-dessus peuvent ne pas être toute la réponse. Absent quand ils le sont |
Les cas capturés sont vidés, pas supprimés
Un cas de dataset capturé en production détient une copie mot pour mot de l’entrée : effacer l’exécution en laissant la copie laisserait donc les mots de la personne dans le dataset. Supprimer le cas purement et simplement est un tort d’une autre nature : quelqu’un a capturé cette exécution pour empêcher un bug de revenir, et détruire son test de régression sans un mot n’est pas une décision que mcpd doit prendre tout seul.
Le payload est donc écrasé et la ligne survit. Le cas affiche
{"erased": true} là où étaient son entrée et ses
métadonnées, son lien vers l’exécution source est effacé, et la réponse
nomme combien de cas et quels datasets. Le cas est visiblement vide, ce sur
quoi un opérateur peut agir.
Un cas reste entièrement hors d’atteinte, et le cloisonnement en est la raison. Un cas capturé dans un dataset qui appartient à un autre projet n’est ni vidé ni compté. L’appel est autorisé pour un projet et touche un projet, et un effacement qui franchirait cette frontière serait un moyen d’apprendre ce que contient un dataset que vous ne pouvez pas lire. Effacez aussi la personne de ce projet, avec une clé qui l’atteint.
Ce que cela ne fait pas
Cela touche un seul projet. ?project= le nomme, et le projet
par défaut du daemon est utilisé quand vous l’omettez. Une clé qui n’atteint
pas ce projet est refusée par un 403 et rien n’est supprimé. Les lignes de
la même personne dans un projet que la clé ne peut pas ouvrir ne sont ni
touchées ni comptées, si bien qu’un compte ne peut jamais révéler que le
sujet existe ailleurs. Effacer une personne de plusieurs projets, c’est un
appel par projet, avec une clé qui atteint chacun.
Un sujet dont rien n’est stocké donne un 200 portant des zéros, pas un 404. L’état que vous avez demandé est bien celui qui tient, et un 404 répondrait à une question que personne n’a le droit de poser : si cette personne existe quelque part que cette clé peut voir. C’est aussi pourquoi il est sûr de répéter la demande après une expiration de délai. Le second appel rapporte les comptes plus petits d’une seconde passe plutôt qu’une erreur.
Ce n’est pas un moyen d’effacer une personne de vos propres systèmes. mcpd supprime ce que mcpd a stocké. Tout ce que vous avez transféré vers un autre endpoint OTLP doit être effacé là-bas aussi, et mcpd ne sait pas ce qu’il en est advenu.
Les clés, et la trace qui en est gardée
L’effacement exige une clé d’administration quand des clés sont configurées,
et se replie sur les clés de lecture quand aucune clé d’administration n’est
définie, pour qu’un déploiement existant garde la portée qu’il avait. Il est
écrit dans le journal d’audit avec l’action, le projet et une empreinte de
la clé qui a demandé, et une tentative refusée est enregistrée de la même
façon. Là où user_id est haché, c’est l’empreinte que la ligne
nomme : le journal d’audit survit aux données qu’il décrit, donc y écrire
l’identifiant en clair laisserait le nom que la personne vous a demandé de
retirer dans une table que la conservation ne balaie pas.
Une exécution à la fois reste possible.
DELETE /v1/api/traces/{trace_id} retire une seule exécution
et tout ce qui y est attaché, en une seule transaction, en renvoyant le
nombre de lignes supprimées.
Le zéro d’un effacement est la valeur dont il faut se méfier
Un zéro, c’est ce qu’un opérateur répète à une personne sous la forme « nous ne détenons rien sur vous », et c’est le seul nombre que cet endpoint ne peut pas prouver. Les identifiants stockés sont hachés avec la clé de masquage, et rien n’enregistre quelle clé a haché quelle ligne : un zéro peut donc tout aussi bien vouloir dire que la clé a changé après l’écriture de ces lignes. mcpd ne peut pas distinguer les deux. Ce qu’il peut faire, c’est ne jamais renvoyer un zéro nu qui se lirait comme le premier.
Voici le même sujet, effacé sous une clé de masquage qui a tourné depuis l’enregistrement de l’exécution. Les comptes sont tous à zéro et la ligne est toujours là :
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq "{deleted, unmatchable_identifiers, note}"{ "deleted": 0, "unmatchable_identifiers": 0, "note": "Nothing matched. Stored identifiers are hashed with the redaction key, so this means no stored identifier hashes to this subject under the key in force now. It does not mean nothing about this person was ever stored: rows written under a different redaction key cannot be matched by this endpoint."} Ce que la note peut prouver dépend du projet. Quand rien n’y est attribué à qui que ce soit, il n’y a aucune ligne qu’un changement de clé pourrait cacher, et la note le dit exactement. Sinon, un zéro n’est pas prouvé : rétablissez la clé de masquage sous laquelle ces lignes ont été écrites et répétez la demande.
Il signale plutôt qu’il refuse, et c’est un compromis délibéré. Refuser casserait la répétition après une expiration de délai, ferait différer la réponse pour « jamais vu » de celle pour « effacé il y a une heure », ce qui ferait de l’endpoint un moyen de demander si une personne est connue d’un projet, et bloquerait tout effacement dans un déploiement dont le réglage de masquage a changé un jour, y compris ceux qui, à juste titre, ne correspondent à rien.
Les identifiants que cette demande ne pouvait pas reconnaître
Une rotation est invisible. Un changement sur le fait même de
hacher les identifiants ne l’est pas, et unmatchable_identifiers
est ce que mcpd peut voir. Un identifiant haché se reconnaît à son préfixe
sha256:, donc le stockage compte combien des identifiants qu’il
détient sont sous la forme opposée à celle que portait cette demande : des
lignes en clair face à une demande hachée, ou l’inverse. Ces lignes ont été
écrites sous un autre réglage de masquage, elles peuvent appartenir au
sujet, et cette demande ne peut rien pour elles.
Le compte est fait à chaque effacement, pas seulement sur un zéro, et la
réponse ci-dessous dit pourquoi. Ce projet a été écrit avec le masquage
actif, puis de nouveau avec le masquage désactivé. La demande a reconnu les
lignes récentes et les a supprimées, et les deux identifiants plus anciens
sont hachés, donc cette demande ne pouvait pas les adresser. Un compte qui
se serait arrêté à deleted se serait lu comme une réponse
complète :
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq "{deleted, unmatchable_identifiers, note}"{ "deleted": 2, "unmatchable_identifiers": 2, "note": "2 stored identifiers in this project are in a form this request could not match, so rows written under a different redaction setting were not searched and rows about this person may remain. Restore the redaction setting those rows were written under and repeat the request."} Le compte porte sur tous les identifiants du projet, pas sur les seules lignes du sujet, donc ces deux-là peuvent très bien appartenir à quelqu’un d’autre. C’est le maximum de ce qu’il peut dire. Ce qu’il écarte, c’est de lire un effacement d’apparence réussie comme un effacement complet.
La garantie n’est pas la même sur les trois backends
SQLite et Postgres suppriment dans la transaction qui vous a répondu. Quand
la réponse arrive, les lignes sont parties, et deletes_pending
vaut false.
ClickHouse, non. La suppression y est une mutation asynchrone : le serveur
accepte l’instruction et l’applique en arrière-plan, donc la réponse dit ce
qui a correspondu et non ce qui a déjà quitté le disque. Ces effacements
reviennent avec deletes_pending à true, ce qui est
la façon dont mcpd vous dit que c’est la plus faible des deux garanties qui
s’applique. Les cas de dataset sont l’exception sur ce backend. Ils sont
réécrits sous leur propre clé plutôt que mutés, donc le cas vidé est ce que
toute lecture renvoie dès l’envoi de la réponse. Une mutation aurait laissé
l’ancien texte lisible jusqu’à son achèvement.
Si vous devez certifier à quelqu’un que les données sont parties à un moment donné, c’est la différence à noter. Sur ClickHouse, confirmez que la mutation s’est achevée sur le serveur avant de répondre.
Avant que le port soit joignable
Le masquage protège les valeurs à l’intérieur d’une trace. Il ne change
rien à qui peut lire la trace. mcpd avertit au démarrage quand il écoute sur
une adresse autre que la boucle locale avec une ingestion ouverte ou une
API de lecture ouverte, parce que les prompts et les complétions stockés
sont alors lisibles par quiconque atteint le port. Définissez d’abord
--ingest-key et --read-key, et lisez
Docker et Kubernetes.
La suite
- En garder moins dès le départ : Échantillonnage.
- Toutes les options et variables d’environnement : Configuration.
- Sondes, sauvegardes et mises à niveau : Exploitation.