Un ver dans npm, et pourquoi go get n'exécute aucun script
Ou : Comment un fichier package.json a appris à se reproduire
Le 4 août 2026, installer une bibliothèque de cache aussi banale que keyv suffisait à exécuter un voleur d’identifiants, avant même qu’une seule ligne de ton code ne l’importe. Trois mois plus tôt, quarante-deux paquets de TanStack avaient subi le même sort. Les deux attaques ont un point commun qui devrait inquiéter tout le monde : les versions piégées portaient des signatures de provenance parfaitement valides. Elles en ont un second, plus banal et plus décisif : le code malveillant s’exécutait pendant l’installation, au moment où personne ne regarde. Go a fait sur ce point un pari différent, il y a des années. Ce pari ne le rend pas invulnérable. Il déplace le moment où le code d’une dépendance s’exécute, et ce déplacement change beaucoup de choses.
Attaque de la chaîne d’approvisionnement npm : ce que le modèle des modules Go change
Deux vagues, une même porte d’entrée
La première vague touche TanStack le 11 mai 2026. Entre 19 h 20 et 19 h 26 UTC, l’attaquant publie 84 versions malveillantes réparties sur 42 paquets @tanstack/*1. Il n’a volé aucun mot de passe : il a enchaîné un workflow GitHub Actions qui exécutait le code des forks, un empoisonnement du cache de ce workflow, puis l’extraction, dans la mémoire de la machine qui exécutait le workflow, du jeton qui sert à publier sur npm. La seconde vague, baptisée CHAINDROP par Elastic, commence le 4 août à 9 h 02 UTC, quand l’identité GitHub compromise du mainteneur de keyv pousse un commit directement sur la branche principale2.
| TanStack, 11 mai 2026 | keyv et cacheable, 4 août 2026 | |
|---|---|---|
| Entrée | Workflow GitHub Actions détourné | Compte GitHub du mainteneur |
| Déclencheur à l’installation | Script prepare d’une dépendance optionnelle | Script preinstall : node setup.mjs |
| Provenance des versions piégées | Valide | Valide |
| Ampleur | 84 versions, 42 paquets | Plus de 400 paquets, par propagation |
La suite tient du ver au sens propre. Une fois installé, le code fouille la machine à la recherche de jetons : npm, GitHub, fournisseurs cloud. Avec un jeton npm qui autorise la publication, il republie chaque paquet de sa victime, infecté à son tour. Elastic décrit la règle : dès qu’un jeton exploitable est trouvé, « the worm enumerates every package the victim has publish rights to ». Aikido comptait au moins 444 paquets compromis le lendemain, Elastic plus de 4002.
La provenance prouvait la bonne chose
La provenance npm atteste qu’un paquet a été construit par la chaîne de publication officielle du dépôt. C’est exactement ce qui s’est passé. L’attaquant avait poussé son code dans le dépôt, et la chaîne de publication a fidèlement construit, signé et publié ce qu’on lui donnait. Socket le résume pour TanStack : « Do not trust Sigstore provenance badges alone as a security signal »1. Pour keyv, l’alerte déposée le jour même sur le dépôt va plus loin : « Republishing packages with valid provenance is a capability of this payload »3. Republier avec une provenance valide n’était pas un accident, c’était une fonction.
DevOps Dave : Je n’installe que des paquets avec une provenance vérifiée.
Security Sarah : Vérifiée par qui ?
DevOps Dave : Par GitHub. Le paquet vient bien du dépôt officiel.
Security Sarah : Le ver aussi.
La provenance répond à la question « d’où vient ce paquet ? ». Elle ne répond pas à « que fait ce paquet ? ». Et sous npm, la réponse à la seconde question arrive au pire moment possible : pendant npm install, sur ton poste ou dans ton intégration continue, là où traînent justement les jetons que le ver cherche.
Ce que Go refuse de faire au téléchargement
Le billet de l’équipe Go sur la chaîne d’approvisionnement pose le principe en une phrase : « neither fetching nor building code will let that code execute, even if it is untrusted and malicious »4. Pas de script d’installation, pas de point d’accroche, rien. J’ai voulu le voir plutôt que le croire. J’ai fabriqué un module piégé, servi par un proxy local, avec Go 1.27.1.
| |
go get, go mod download, go build et go vet sur un programme qui l’importe : aucun fichier marqueur créé. Et si l’on tente de lancer la directive de génération de la dépendance, Go refuse tout net :
| |
Le marqueur n’apparaît qu’au lancement du programme compilé, quand init() s’exécute. C’est tout le changement : le code de la dépendance tourne quand tu exécutes ton programme, pas quand tu le télécharges.
La somme de contrôle qui ne change jamais d’avis
Le second pilier est le fichier go.sum, qui garde l’empreinte de chaque version utilisée, adossé à une base publique, sum.golang.org. La référence des modules la décrit comme un « Transparent Log (or “Merkle Tree”) », un journal que des auditeurs indépendants peuvent vérifier5. Pour l’éprouver, j’ai monté un proxy menteur qui sert une version altérée d’une ligne du vrai module github.com/google/uuid v1.6.0.
| |
Même refus quand une archive change après avoir été enregistrée dans go.sum. Ajoute la sélection de version minimale : Go prend la version que demande le go.mod, pas la dernière parue, et depuis Go 1.16 les commandes de construction refusent de modifier go.mod en silence5. Une version piégée publiée cette nuit n’entre pas dans ta compilation sans qu’un humain la demande.
Ce que Go ne bouche pas
Ce modèle a un angle mort, et il est cruel. La même référence précise que la base de sommes garantit que le contenu d’une version ne change pas d’un jour à l’autre, « even if the module’s author subsequently alters the tags in their repository ». C’est ce qui a démasqué mon proxy menteur. C’est aussi ce qui a protégé un module piégé pendant plus de trois ans. En novembre 2021, github.com/boltdb-go/bolt, un faux BoltDB au nom presque identique, publie une version 1.3.1 munie d’une porte dérobée. Le miroir de Go la met en cache, puis l’auteur réécrit l’étiquette sur un code propre. Le dépôt GitHub paraît sain, le miroir continue de servir la version piégée. Elle n’a été signalée qu’en février 20256.
Le deuxième angle mort tient au mot « exécution ». Go n’exécute rien au téléchargement, mais go test ./... lance les init() de toutes tes dépendances (le marqueur de mon module piégé apparaît aussi là), et go tool exécute les outils déclarés par la directive tool de Go 1.24. Dans une intégration continue qui lance les tests avec des jetons de publication dans l’environnement, une dépendance piégée trouve exactement ce que cherchaient les vers npm. Go déplace le moment du risque, il ne le supprime pas.
Et npm bouge aussi. Depuis pnpm 10, publié le 7 janvier 2025, les scripts d’installation des dépendances ne s’exécutent plus par défaut7.
Ce que je fais concrètement
Je garde go.sum dans le dépôt et je ne désactive jamais GOSUMDB pour des modules publics. Je lance govulncheck dans l’intégration continue : il signale en priorité les failles des fonctions que ton code appelle vraiment, et la base qu’il consulte recense aussi des modules malveillants, dont ce faux BoltDB8. Je relis lettre par lettre le chemin de toute nouvelle dépendance. Et les tests tournent sans jeton de publication : un init() ne peut pas voler ce qui n’est pas là. Pour un éditeur de logiciel, l’enjeu n’est plus seulement technique : une faille activement exploitée dans un produit peut désormais déclencher le signalement en 24 heures du Cyber Resilience Act. Et ces attaques par les dépendances ne visent pas que les bibliothèques : les extensions des agents d’IA ont suivi le même chemin.
Le ver CHAINDROP n’a rien inventé. Il a profité d’une seule ligne de package.json, exécutée par des millions de machines qui ne lui avaient rien demandé. Go n’a pas cette ligne. Il te reste à ne pas la réinventer dans ton intégration continue.
Le post-mortem de TanStack détaille la fenêtre de 19 h 20 à 19 h 26 UTC, les 84 versions sur 42 paquets et la chaîne d’attaque, et précise que les publications utilisaient une authentification de publication de confiance valide, liée au workflow de publication du dépôt. Socket confirme les 84 versions, des attestations Sigstore valides, et rattache l’attaque à la campagne Mini Shai-Hulud. ↩︎ ↩︎
Elastic Security Labs décrit le déclenchement par
preinstall, la propagation par jetons volés et plus de 400 paquets compromis. Aikido relève l’entrée"preinstall": "node setup.mjs", au moins 444 paquets sur 1 381 versions le 5 août, et des versions publiées « with valid provenance signed by GitHub Actions ». Endor Labs date le commit de 9 h 02 UTC et confirme la provenance SLSA valide. ↩︎ ↩︎L’alerte ouverte le 4 août 2026 sur le dépôt cacheable liste les versions compromises et ajoute : « Valid provenance on any subsequent release from these repos is not evidence of cleanliness ». Le mainteneur y indique ensuite que npm a retiré les neuf versions malveillantes, et qu’il a désactivé ses workflows et la publication de confiance. ↩︎
Le billet de l’équipe Go sur la sécurité de la chaîne d’approvisionnement détaille ce choix, en opposition explicite aux écosystèmes à hooks d’installation, et rappelle que la version de chaque dépendance est « fully determined by the go.mod file of the main module ». ↩︎
La référence des modules Go décrit la base de sommes de contrôle, servie par
sum.golang.orget adossée à Trillian, qui rend possibles les proxys non fiables puisqu’ils « can’t serve the wrong code without it going unnoticed ». Elle précise aussi que, depuis Go 1.16, les commandes se comportent comme si-mod=readonlyétait activé. ↩︎ ↩︎L’avis GO-2025-3451 de la base de vulnérabilités de Go, publié le 5 février 2025, qualifie le module de « malicious typosquat ». The Hacker News rapporte les travaux de Socket : version publiée en novembre 2021, mise en cache par le miroir, étiquette réécrite ensuite sur un code propre. ↩︎
La note de version de pnpm 10.0.0 l’annonce comme un changement majeur : « Lifecycle scripts of dependencies are not executed during installation by default! », avec une liste blanche,
onlyBuiltDependencies, pour les rares paquets qui en ont besoin. ↩︎Le billet de sortie de govulncheck 1.0, du 13 juillet 2023, explique que l’outil « reduces noise by prioritizing vulnerabilities in functions that your code is actually calling ». ↩︎