Retour aux textes

Masquez avant d'appeler le LLM

Remplacer un CPF par un marqueur, c'est le début. Le plus dur, c'est la table de correspondance, le détecteur, les quasi-identifiants et la réponse qui revient avec le mauvais marqueur.

Quand un système de santé ou financier appelle un modèle de langage externe, chaque prompt peut contenir un nom, un CPF (le numéro d'identification fiscale brésilien), un diagnostic ou des montants. Si ça sort de votre infrastructure sans traitement, le problème cesse d'être technique et devient un risque juridique et de confiance.

L'idée d'une couche de confidentialité est simple : avant l'appel, identifier les entités sensibles, remplacer chacune par un marqueur et garder la table de correspondance à l'intérieur de votre périmètre. Le modèle raisonne sur les marqueurs, et la réponse est réhydratée uniquement de votre côté.

entrée : Maria Souza, CPF 123.456.789-09, hospitalisée le 12/03
modèle : [PERSONNE_1], CPF [CPF_1], hospitalisée le [DATE_1]

Jusque-là, tout le monde est d'accord. Le problème, c'est que la version naïve de tout ça protège bien moins qu'elle n'en a l'air, et les trous se trouvent à quatre endroits.

Un token stable, c'est de la pseudonymisation, pas de l'anonymisation

La tentation, c'est de faire en sorte que le même CPF devienne toujours le même marqueur, dans tous les appels, pour que le modèle « se souvienne » de la personne. Ça crée de la chaînabilité : quiconque voit les requêtes peut rassembler tout ce qui est passé sous le même marqueur et construire un profil sans jamais voir le CPF. Et la table de correspondance devient l'actif le plus sensible du système, parce que c'est la seule chose qui sépare un tas de marqueurs d'un tas de patients.

Pour la LGPD, la loi brésilienne de protection des données, comme pour le RGPD, des données pseudonymisées restent des données personnelles. Elles ne cessent de l'être que si l'anonymisation est irréversible, et une table de correspondance existe justement pour revenir en arrière. Le marqueur ne vous fait donc pas sortir du champ de la loi ; il réduit seulement ce qui fuit si l'appel est intercepté.

Remplacer la table par un hash ne sert à rien non plus. Le CPF a environ 10⁹ valeurs possibles, et calculer le SHA-256 de toutes tient en un après-midi sur un portable. Un hash sans clé d'un identifiant dont l'espace est petit, c'est juste un CPF écrit autrement.

Le minimum que je considère raisonnable :

  • Une portée par conversation ou par tenant, jamais globale. Au sein d'une conversation, des marqueurs séquentiels (CPF_1, CPF_2) suffisent.
  • Quand vous avez besoin de cohérence entre les appels, comme pour un document traité en morceaux, utilisez un HMAC avec une clé par portée, et faites tourner la clé.
  • La table traitée comme un coffre-fort : chiffrée, avec un accès restreint, une rétention courte et sa propre piste d'audit des accès.
token = "CPF_" + base32(HMAC(cle[tenant][conversation], cpf))[:8]

Envoyer des données personnelles pour détecter des données personnelles

Un conseil courant, c'est de commencer par le déterministe, avec des regex et des validateurs pour les documents, et de laisser un LLM pour les cas que la règle n'atteint pas. Le conseil est bon, avec une condition qu'on oublie souvent : ce modèle doit tourner à l'intérieur du périmètre. Si le détecteur est une API externe, vous envoyez le texte brut à l'extérieur justement pour découvrir ce que vous n'aviez pas le droit d'envoyer à l'extérieur.

En pratique, la détection reste entièrement de votre côté : regex et validateurs (clé de contrôle du CPF, numéro de carte de santé CNS, téléphone, e-mail, dates), listes d'entités connues, un NER comme celui de Presidio avec un modèle en portugais et, si besoin, un petit modèle qui tourne en local. Le modèle externe ne voit que du texte qui est déjà passé par tout ça.

texte
  -> regex + validateurs (CPF, CNS, téléphone, e-mail, dates)
  -> NER / modèle local, dans le périmètre
  -> généralisation des quasi-identifiants
  -> marqueurs avec portée + table dans le coffre
  -> LLM externe
  -> validation des marqueurs dans la réponse
  -> réhydratation

En santé, la fuite se cache dans les quasi-identifiants

Le nom et le CPF, c'est la partie facile. Ce qui réidentifie vraiment un patient, c'est la combinaison d'attributs qui, pris isolément, semblent inoffensifs. Latanya Sweeney a montré en 2000 que le code postal, la date de naissance et le sexe suffisaient à identifier de manière unique environ 87 % de la population des États-Unis. Dans un dossier médical en texte libre, c'est pire, parce que ces attributs sont mélangés au reste.

Aucune regex n'attrape « patient de 34 ans séropositif suivi à Sorocaba ». Il n'y a pas de nom, pas de document, et dans la base d'une mutuelle régionale ça peut être une seule personne. C'est là que se trouve le vrai problème.

Remplacer ne résout rien, parce que le modèle a besoin du contenu clinique : dans un résumé de cas, le diagnostic est le sujet. Le bon outil, c'est la généralisation, en décidant tâche par tâche ce que le modèle a réellement besoin de savoir :

AttributOriginalGénéralisé
Âge34 ans30 à 39 ans
Datehospitalisé le 12/03[DATE_1], ou « J+3 » par rapport à l'admission
VilleSorocabaintérieur de l'État de São Paulo
DiagnosticVIHconservé, si la tâche en a besoin

Et ça se mesure. Si, après généralisation, un seul patient de la base correspond à « 30 à 39 ans, intérieur de l'État de São Paulo, VIH, hospitalisé en mars », la généralisation n'a pas suffi. C'est l'intuition du k-anonymat appliquée à ce qui reste dans le texte : chaque combinaison qui arrive au modèle devrait correspondre à plusieurs personnes, pas à une seule.

La réhydratation échoue aussi

Le retour, c'est là où personne ne regarde. Le modèle peut changer le format du marqueur ([CPF_1] devient CPF 1, [cpf_1] ou CPF_01), en inventer un qui n'existe pas ([PERSONNE_3] alors qu'il n'y avait que deux personnes) ou intervertir les marqueurs. Un rechercher-remplacer naïf laisse un marqueur cassé à l'écran ou, pire, met le nom du mauvais patient dans la réponse.

  • Des marqueurs au format rigide et difficile à paraphraser, et un détecteur qui retrouve les variantes que le modèle a tendance à produire.
  • Tout marqueur de la réponse doit exister dans la table de cette conversation. Un marqueur inconnu, c'est un refus ou une nouvelle génération, jamais une supposition.
  • Un marqueur d'une autre conversation ou d'un autre tenant ne se résout jamais, même s'il existe dans le coffre.
  • Après la réhydratation, aucun marqueur ne doit rester dans le texte.
for token in find_tokens(reponse):
    if token not in table_de_la_conversation:
        raise TokenInconnu(token)

Mesurez ce qui passe à travers

Un masquage que personne ne teste, ce n'est qu'un sentiment de sécurité. Gardez un jeu de cas synthétiques qui ressemblent aux vrais, y compris des quasi-identifiants en texte libre, mesurez le rappel du détecteur par type d'entité et comptez ce qui arrive au modèle externe. La métrique qui compte, c'est ce qui a fuité, pas ce qui a été attrapé.

Ça rejoint Le prompt n'est pas une frontière de sécurité : une couche s'occupe de ce que le modèle voit, l'autre de ce qu'il peut faire.