Aller au contenu
mcpd
aucun daemon
Source

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.

Ingest pipeline
  1. Decode

    OTLP or native, gzip or plain

  2. Sample

    Decided once, at the root

  3. Redact

    Before anything is written

  4. Price

    From the configured price book

  5. 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.

ModeNoms 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

ModeCe 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 : alice@example.com 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.

ce qui est ressorti
$ 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, attributes et metadata.
  • Logs : les fields structurés.
  • Métriques : les labels.
  • Scores : les metadata.
  • Retours : les metadata, et le comment en 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_id et aucun trace_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 dans runs_in_flight et nommée dans note, 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.

une demande, une personne
$ 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."}
ChampCe 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à :

après une rotation, la même demande ne correspond à rien
$ 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 :

un effacement partiel, le cas qu’il faut attraper
$ 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