PromptVault — Comment masquer 13 types de PII dans ChatGPT sans casser la conversation
Anonymiser un prompt avant qu'il quitte le navigateur, recevoir une réponse ChatGPT mentionnant [NAME_1] et [IBAN_1], puis rendre Marie Dupont et FR76 3000... visibles à l'utilisateur — en temps réel, dans le DOM React de ChatGPT. Le pipeline complet en 80 lignes de TypeScript.
Cet article décortique le projetPromptVaultLe problème : ChatGPT en entreprise
Vos équipes utilisent ChatGPT, Claude, Gemini. Tous les jours. Et tous les jours, dans les prompts, partent vers OpenAI :
- des emails clients,
- des IBAN,
- des numéros de carte bancaire,
- des dates de naissance,
- des noms complets.
Aucun DPO ne peut accepter ça. La réponse "interdisez ChatGPT" ne tient pas une semaine — les commerciaux trouveront un VPN, les développeurs colleront leur code dans un onglet privé.
La seule réponse défendable : masquer avant envoi, restaurer dans la réponse, sans que l'utilisateur change ses habitudes.
C'est ce que fait PromptVault. Voici comment.
Pipeline en 3 étapes
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. detect │ -> │ 2. anonymize │ -> │ 3. observe │
│ 13 PII types │ │ reversible │ │ DOM mutation │
└──────────────┘ └──────────────┘ └──────────────┘
↓ ↓ ↓
regex + NER [TYPE_n] tokens restitution
map kept local live (RAF)
1. Détecter — regex + NER
Le détecteur est volontairement boring : 9 regex pour le déterministe (IBAN, email, numéro de sécu, téléphone FR/INT, carte bancaire, date naissance, IP, montant), un modèle NER léger pour le reste (noms de personnes, entreprises, adresses).
const REGEX_PATTERNS = [
{ type: 'SECURITE_SOCIALE', pattern: /[12]\s?\d{2}\s?\d{2}\s?\d{2}\s?\d{3}\s?\d{3}\s?\d{2}/g },
{ type: 'IBAN', pattern: /[A-Z]{2}\d{2}\s?[\dA-Z]{4}\s?(?:[\dA-Z]{4}\s?){2,7}[\dA-Z]{1,4}/g },
{ type: 'CARTE_BANCAIRE', pattern: /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2})\s?\d{4}\s?\d{4}\s?\d{4}\b/g },
{ type: 'EMAIL', pattern: /[\w.-]+@[\w.-]+\.\w{2,}/g },
{ type: 'TELEPHONE', pattern: /(?:\+33|0)\s*[1-9](?:[\s.-]*\d{2}){4}/g },
{ type: 'DATE_NAISSANCE', pattern: /\b(?:0[1-9]|[12]\d|3[01])\/(?:0[1-9]|1[0-2])\/(?:19|20)\d{2}\b/g },
{ type: 'MONTANT', pattern: /\d[\d\s,.]*(?:€|\$|£|EUR|USD|CHF)/g },
// …
];
Le NER léger tourne dans le navigateur, jamais en serveur. C'est lent (50 ms sur un prompt de 1 000 tokens), mais c'est le prix de "0 PII quitte le browser".
2. Anonymiser — réversible et stable
Le piège classique : on remplace les occurrences une par une, et les indices de fin de chaîne décalent au fur et à mesure. Solution : itérer à l'envers.
export function anonymize(text: string, config: ShieldConfig): AnonymizationResult {
const entities = detectEntities(text, config);
const mapping = new Map<string, { original: string; type: string }>();
const counter = new Map<string, number>();
let result = text;
// Itération inverse — les indices avant l'entité courante restent valides
for (let i = entities.length - 1; i >= 0; i--) {
const e = entities[i];
const count = (counter.get(e.type) ?? 0) + 1;
counter.set(e.type, count);
const placeholder = `[${e.type}_${count}]`;
mapping.set(placeholder, { original: e.text, type: e.type });
result = result.substring(0, e.start) + placeholder + result.substring(e.end);
}
return { anonymizedText: result, mapping, entitiesCount: entities.length, entityTypes: [...new Set(entities.map(e => e.type))] };
}
Trois subtilités qu'on ne voit qu'en production :
a. Tokens lisibles
Pas de hash, pas d'UUID. [EMAIL_1], [IBAN_1], [NAME_1]. ChatGPT comprend ce qu'il
manipule et écrit "envoyez votre relance à [EMAIL_1]" plutôt que d'inventer un email aléatoire.
Le modèle reste utile.
b. Numérotation par type
Marie Dupont devient [NAME_1], son mari Pierre Dupont devient [NAME_2].
Le LLM ne fusionne pas les deux personnes. L'identité relative est préservée.
c. Map kept local
La table placeholder → original ne quitte jamais le navigateur. Elle vit dans la
mémoire de l'extension le temps de la requête. C'est ce qui rend l'audit RGPD trivial :
il n'y a rien à auditer côté serveur, le serveur n'a jamais vu la donnée.
3. Restituer — MutationObserver sur ChatGPT
C'est l'étape qui m'a pris le plus de temps. ChatGPT streame sa réponse mot à mot via
React. On ne peut pas simplement faire innerHTML.replace() : on casserait l'hydration React.
Solution : MutationObserver + TreeWalker.
export function startDesubstitutionObserver(
responseElement: HTMLElement,
mapping: Map<string, { original: string; type: string }>
) {
const observer = new MutationObserver(() => {
for (const [placeholder, { original }] of mapping) {
const walker = document.createTreeWalker(responseElement, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
if (node.textContent?.includes(placeholder)) {
node.textContent = node.textContent.replaceAll(placeholder, original);
}
}
}
});
observer.observe(responseElement, { childList: true, subtree: true, characterData: true });
// Auto-disconnect après 60s — pas de leak si l'utilisateur change de conversation
setTimeout(() => observer.disconnect(), 60000);
}
Pourquoi TreeWalker plutôt qu'un simple innerHTML.replace() ?
| innerHTML.replace | TreeWalker SHOW_TEXT | |
|---|---|---|
| Casse React | Oui — re-render forcé | Non — mutation feuilles |
| Liens / images | Endommagés | Préservés |
| Code blocks ChatGPT | Décolorés | Intacts |
| Streaming partiel | Crée des flickers | Continu |
Le setTimeout(disconnect, 60_000) est important. ChatGPT garde la conversation montée
en DOM des heures. Sans déconnexion, on aurait 30 observers actifs en parallèle.
Le compteur de tokens, en bonus
Effet de bord intéressant : comme on intercepte chaque prompt, on peut le compter. PromptVault montre en temps réel à l'utilisateur combien de tokens il a envoyé, combien ça coûte chez OpenAI, et combien on a économisé en routant via Claude quand l'extension est en mode multi-provider.
Le DPO découvre soudain que le service marketing a brûlé 240€ chez OpenAI ce mois-ci. Conversation utile.
Ce que ce projet m'a appris
-
Le DOM observer pattern est sous-utilisé. Tout le monde fait des extensions qui injectent du contenu en début de page. Très peu construisent une couche qui réagit au stream React d'un produit moderne sans le casser.
-
La conformité ne se vend pas sur des slides. Quand un DPO voit le compteur "5 PII bloquées sur ce prompt" en direct, il comprend mieux le ROI qu'avec dix pages de PowerPoint.
-
Reversible > anonymous. Les outils existants soit anonymisent définitivement (et le LLM rend une réponse abstraite), soit ne masquent rien. La voie du milieu — masquer, laisser le LLM raisonner sur des placeholders, restituer dans la réponse — préserve à la fois l'usage et la conformité.
Stack & code
- Extension Chrome MV3 + VS Code extension (même
shield/partagé) - Preact 10 pour le sidepanel — léger, pas de virtual DOM coûteux
- IndexedDB pour les prompts sauvegardés
- Backend Blazor .NET 10 + Fusion côté entreprise (audit, marketplace de prompts, chaînes de prompts, ROI tracking) — 34 modules métier
Le module shield/ complet fait 180 lignes de TypeScript. Le NER, 90 lignes.
La logique entière du produit tient dans un fichier qu'on peut lire en 10 minutes.
C'est précisément ce qui le rend défendable en audit sécurité.
Florian Sola
Lead Technique · Haute performance temps réel · 9 ans d'expérience