25 ans à éviter les malwares. Claude Code a quand même stocké 600 de mes secrets.
Je n'ai pas attrapé un seul malware en 25 ans de clavier. Pas un. Je repère un .scr déguisé en PDF à l'autre bout de la pièce. Je sens un script postinstall louche à dix mètres. À 14 ans, j'ai même écrit deux ou trois virus moi-même, juste pour comprendre la mécanique (la biologie de la chose me fascinait : réplication, mutation, persistance). L'attaquant, je le connais de l'intérieur.
Ce matin j'ai audité mon répertoire personnel sur les 12 derniers mois. 600 secrets en clair sur mon disque 😬. GitHub PATs, tokens OAuth, clés AWS, API Google, JWTs, tout le buffet. Pas dans un .env oublié sur un repo public. Pas dans un commit foireux. Dans des fichiers JSONL enterrés dans ~/.claude, un répertoire dont j'ai à peine remarqué l'existence il y a deux semaines.
Ce n'est pas un mea culpa sur une mauvaise hygiène. Il y a quinze ans j'avais 100 mots de passe dans Keychain et ça suffisait. Aujourd'hui on trimballe des dizaines de clés API, les outils les loggent sans nous prévenir, et ma discipline de 25 ans n'était jamais calibrée pour ça.
L'ancienne règle, "fais gaffe, ne clique pas n'importe où", suppose un attaquant qui m'en veut personnellement. Cette menace existe toujours. Mais une seconde catégorie a débarqué : l'attaquant qui ne m'en veut pas du tout, qui tourne juste avec mes privilèges, planté par une dépendance npm transitive que je n'ai jamais auditée. Il atterrit dans un répertoire personnel qui contient désormais un musée de tout ce que j'ai jamais montré à un assistant.
Beaucoup plus dangereux. Il est temps de réagir.

L'audit : 5 904 fichiers, 171 touchés, 600 secrets en clair
J'ai lancé le scan contre ~/.claude/{projects,tasks,sessions,todos,shell-snapshots,paste-cache,file-history,debug} plus ~/.zsh_history et ~/.bash_history. 5 904 fichiers au total. 1,1 Go de poids cumulé. 171 de ces fichiers contenaient au moins un identifiant.
La répartition ressemble à peu près à ça :
- 95 tokens GitHub OAuth (
gho_) - 94 GitHub fine-grained PATs (
github_pat_) - 103 clés API Google (
AIza) - 197 chaînes qui ressemblent à des JWT (
eyJ) - 45 clés d'accès AWS (
AKIA) - 18 OpenRouter
- 15 Resend
- 7 Anthropic OAuth
- 6 tokens de bot Telegram
- 3 clés de test Stripe
- 2 tokens Vercel
Environ 600 secrets répartis sur 171 fichiers. Presque tous révocables, beaucoup déjà révoqués au moment où j'écris ces lignes. Une nuance que je pose tout de suite au lieu de l'enterrer à la fin : le bucket JWT_LIKE est bruité, il inclut des clés publiques Supabase qui sont publiques par design. J'assume les faux positifs. Un faux positif me coûte une rédaction. Un faux négatif me coûte un identifiant.
Lire le JSONL, c'était comme ouvrir un fichier de sauvegarde automatique d'un roguelike dont j'ignorais que j'y jouais. Chaque commande, chaque collage, chaque lecture, persistée pour l'éternité dans l'ordre où c'est arrivé. Le donjon persistant de NetHack, sauf que le butin c'est mes clés AWS.
Un autre développeur a signalé publiquement une version plus petite du même problème dans GitHub issue #50014 le 17 avril : 5 secrets distincts sur 34 fichiers de session après environ 30 jours d'utilisation, 418 Mo au total.
30 jours, 5 secrets. 12 mois, 600. Une relation linéaire que je trouve bien trop crédible.
Donc la question n'est pas de savoir si ça arrive. C'est comment ça arrive, et ce qui défend contre.
Pourquoi ça arrive : cinq chemins vers une transcription en clair
Le mécanisme est mécanique, pas mystérieux. Chaque session Claude Code écrit un fichier JSONL dans ~/.claude/projects/<project-hash>/<session-id>.jsonl. Chaque ligne est un enregistrement : un message utilisateur, une réponse assistant, un appel d'outil, un résultat d'outil. Le fichier est en append-only. Rien ne l'élague. Rien ne le nettoie. Il reste là aussi longtemps que vous voulez, et sur la plupart des Mac ça veut dire pour toujours.
Cinq chemins mènent un secret dans ce fichier :
-
Sortie bash d'une commande légitime.
infisical secrets get MY_TOKEN --plain,gh auth token,vercel token,cat .env,security find-generic-password -w,printenv | grep TOKEN,echo $SECRET. Tout ce qui imprime un identifiant sur stdout, le JSONL l'enregistre. -
Collage manuel par vous, dans le chat. Vous lâchez un token dans le prompt pour demander à Claude de l'utiliser. Le token fait maintenant partie de l'enregistrement user-message pour toujours.
-
Lecture de fichier via l'outil
Read. Vous demandez à Claude de regarder.envpour le contexte. Le contenu du fichier atterrit dans l'enregistrement tool-result. -
Écriture de fichier avec un secret codé en dur. Vous demandez à Claude de scaffolder une config et le secret finit dans le nouveau contenu de fichier. Bonus : le même contenu est dupliqué sous
~/.claude/file-history/. -
Affichage explicite par Claude dans une réponse. Vous demandez "c'est quoi la valeur ?", Claude la recrache. La réponse est dans l'enregistrement assistant-message.
Cinq chemins, un fichier, append-only, texte clair. Pas de purge, pas de rotation, pas de scan.
Maintenant le pivot. L'ancienne défense, "ne colle pas tes secrets n'importe où, ne lance pas de commandes douteuses", était construite autour d'un attaquant qui t'attaque. Cette défense marche toujours contre cet attaquant. Le problème c'est qu'une catégorie différente a débarqué, et elle contourne l'ancienne défense sans la casser.
J'ai tracé le détournement LiteLLM jusqu'à mon propre cache pip il y a huit mois, et la leçon était déjà là : un package empoisonné atterrit avec les privilèges de l'utilisateur et commence à lire ce que l'utilisateur peut lire. En mars 2026, la campagne TeamPCP a empoisonné 75 tags GitHub Action et poussé des payloads malveillants vers 141+ packages npm via des secrets CI/CD volés. En avril 2026, les chercheurs de Check Point ont trouvé 33 packages npm qui livraient publiquement des fichiers .claude/settings.local.json avec des identifiants inline. GitHub PATs, tokens Telegram, tokens bearer de production, tout le bazar.
Trois campagnes différentes. Même mécanique. L'attaquant ne frappe pas à ma porte. C'est un script qui tourne avec mes privilèges, lâché par une dépendance que je n'ai jamais auditée directement. Et une fois que ce script est vivant dans mon répertoire personnel, mes 1,1 Go de transcriptions Claude Code c'est une mine d'or.
Le rapport 2026 de GitGuardian met la tendance plus large en chiffres : les expositions d'identifiants de service IA détectées sur GitHub public ont bondi de 81% d'une année sur l'autre. Les commits assistés par Claude Code fuient des secrets à environ 3,2%, contre 1,5% pour la baseline des commits publics. Les commits accélérés par IA fuient des secrets à deux fois le taux. Le changement est à l'échelle de l'industrie, pas juste mon disque.
Donc voici le pivot, net.
La discipline défend contre les attaquants qui t'attaquent. Les garde-fous mécaniques défendent contre les attaquants qui tournent comme toi.
Même ton coffre-fort fuit dès qu'il fait son boulot
J'avais Infisical. J'avais des règles de refus. J'avais l'injection runtime. J'avais quand même 600 secrets en clair.
L'histoire est énervante parce que l'hygiène était correcte. Le secret ne dort pas dans un .env. Il vit dans un coffre chiffré. Et pourtant.
La mécanique c'est l'étape de résolution elle-même. Le secret quitte le coffre pendant deux secondes pour faire son boulot, et ces deux secondes existent quelque part : en clair dans la sortie bash, en clair dans un env de processus, en clair dans un collage manuel. Pendant ces deux secondes, Claude Code écoute. Le JSONL prend des notes.
Regardez la différence entre deux commandes qui ont l'air identiques :
infisical secrets get MY_TOKEN --plain
curl -H "Authorization: Bearer $(cat last_token.txt)" https://api...
MY_TOKEN=$(infisical secrets get MY_TOKEN --plain)
curl -H "Authorization: Bearer $MY_TOKEN" https://api...
Un caractère de différence. Deux destins opposés. La première écrit le token dans la transcription. La seconde ne le laisse jamais toucher stdout.
Un coffre-fort c'est un verrou. Une transcription JSONL c'est un musée. Les deux gardent le secret. Un seul le garde en vitrine.
La défense à 4 couches que j'ai dû construire from scratch

L'ordre compte. La couche la plus précoce se déclenche avant que le secret touche le disque. La couche la plus tardive nettoie ce qui a glissé. Vous voulez les deux. Pensez-y comme organiser un raid : chaque phase réduit la surface d'attaque pour la suivante, et la dernière phase nettoie le butin que le boss a lâché par terre.
Couche 1 : Hook PreToolUse sur Bash. Il intercepte les patterns risqués avant que la commande tourne. infisical secrets get --plain non pipé, gh auth token non pipé, vercel token, security find-generic-password -w non pipé, cat .env|.envrc|.netrc|.npmrc, printenv|env greppant token|secret|key|password, echo $VAR_SECRET. Le hook retourne un JSON permissionDecision: ask avec un message qui explique le pattern sûr. Pas un blocage strict. Un faux positif ne doit pas casser le workflow, sinon je désactiverai le hook dans la semaine et on le sait tous les deux.
Couche 2 : Hook UserPromptSubmit. Il scanne le texte que je soumets avant qu'il entre dans la transcription. Match signifie decision: block. Le secret collé n'arrive jamais dans le JSONL. Même jeu de regex que les autres couches, plus un pattern supplémentaire pour les URLs complètes avec identifiants embarqués.
Couche 3 : Hook SessionEnd + scrubber. Quand une session se termine proprement, le hook glob-matche ~/.claude/projects/*/<session_id>.jsonl, nettoie le fichier en place, valide que chaque ligne est toujours du JSON valide, écrit atomiquement (fichier tmp plus os.replace). Ça ramène la fenêtre de fuite de 24 heures à quelques secondes.
Couche 4 : Cron quotidien à 04:00. Filet pour les sessions qui sont mortes brutalement. kill -9, crash, coupure de courant, tout ce où SessionEnd ne s'est jamais déclenché. Le cron parcourt les mêmes chemins et fait le même nettoyage. Ceinture et bretelles.
Une règle de comportement dans la mémoire de Claude ("n'imprime pas les secrets sur stdout") c'est la couche zéro, la plus faible. Je la garde comme indication polie. L'audit à 600 secrets est une preuve suffisante que la discipline du modèle finit par craquer.
Quelques choix de design qui comptent pour quiconque veut copier ça :
Le scrubber c'est Python 3, stdlib seulement. Zéro dépendance. /usr/bin/python3 ne casse jamais pendant une mise à jour Homebrew. Le pattern matching est ancré sur des préfixes connus : sk-ant-api, ghp_, github_pat_, AKIA, gho_. Pas de regex générique "20 caractères alphanumériques", ça générerait des faux positifs sur chaque UUID, hash, et chunk base64 dans le fichier.
Le remplacement est déterministe avec une empreinte sha256 : [REDACTED-GITHUB_PAT_CLASSIC-a1b2c3d4]. Même secret à deux endroits se rédacte vers la même empreinte. Je peux traquer les doublons sans jamais stocker la valeur elle-même. La validité JSON est préservée, la substitution arrive dans le champ string existant, la structure reste intacte.
Pas de sauvegardes pre-scrub. Un fichier de sauvegarde c'est juste une autre copie du secret, et un voleur le lit aussi facilement que l'original.
J'ai attrapé le tout premier signal de ce terrier de lapin il y a un mois quand Claude Code a refusé de copier mes propres secrets pendant un déplacement de dossier. settings.local.json s'est pointé avec des identifiants en clair, dans un endroit où je n'avais jamais pensé regarder. L'audit que je décris ici a commencé parce que j'ai refusé de croire que c'était le seul endroit.
Le code complet est sur GitHub.
Le scrubber n'a pas besoin d'être prudent. Il tourne. Le hook n'a pas besoin d'être malin. Il bloque.
Ce qui fuit encore, et la nouvelle règle par laquelle je vis
La défense c'est un périmètre, pas un mur. Liste honnête de ce qui glisse :
Secrets sans préfixe identifiable. URLs de la forme https://user:pass@host. Mots de passe arbitraires. UUID v4 utilisés comme tokens bearer. Patterns ancrés signifient bonne précision mais couverture partielle. J'accepte ce compromis parce que l'alternative c'est une regex qui signale chaque chaîne base64 et se fait désactiver dans la journée.
Obfuscation bash. eval $(echo "infisical secrets get X --plain"), alias personnalisés qui wrappent la commande dangereuse, tout ce qui est indirect. La couche 1 attrape les patterns directs. Elle n'attrape pas un obfuscateur déterminé (qui, pour être honnête, c'est surtout moi qui essaie de prouver que le hook a tort).
Scope strictement Claude. ~/.claude, ~/.zsh_history, ~/.bash_history. Hors scope : ~/.aws/credentials, ~/.ssh/id_*, Keychain via security, variables d'env dans les processus qui tournent, cookies de navigateur. Les autres surfaces sont d'autres projets.
Faux positifs sur JWT_LIKE. Les clés publiques Supabase se font rédacter inutilement. Je préfère perdre quelques clés publiques à la rédaction que rater une vraie.
Donc voici la nouvelle règle.
L'ancienne règle c'était "fais gaffe, ne clique pas n'importe où." Elle supposait un attaquant distant ou humain. Toujours valide pour cette catégorie, et je ne la jette pas.
La nouvelle règle est différente.
Rien de secret ne survit en clair sur ce disque.
Pas par paranoïa. Par pragmatisme. Demain un script va tourner avec mes privilèges, lâché par une dépendance trois couches plus loin que je n'ai jamais reviewée à la main, et la seule défense qui tient c'est mécanique.
Dans six mois
Un vendeur va annoncer "fuite massive d'identifiants via les transcriptions d'assistant IA" et tout le monde aura l'air surpris. Il y aura un post corporate avec du blâme vague sur une couche de stockage tierce. Il y aura des threads expliquant qu'il fallait évidemment toggler ce réglage-là. Tout le monde dira que c'était un cas isolé.
Pendant ce temps certains d'entre nous auditent leurs disques. Bricolent des hooks. Lisent la source quand ça fuit. Construisent des filets. Pas par paranoïa. Par pragmatisme.
L'ancienne règle supposait un humain de l'autre côté. Ça a arrêté d'être le défaut il y a un moment.
La prochaine couche de défense c'est d'apprendre à nos IA à bien se tenir. Nos nouveaux enfants numériques, non ?