Le code était sain. L’éditeur ne l’était pas
Deux fiches de registre, une version d’écart. Axios 1.14.0 a été publié par GitHub Actions via un éditeur de confiance et porte une provenance. Axios 1.14.1, apparu à 00 h 21 UTC le 31 mars 2026, a été publié à la main, sans rien de tout cela. Cette différence résume toute l’histoire de la compromission d’axios sur npm, et elle figurait dans les métadonnées du registre. [2]
Les faits, en bref. Axios 1.14.1 et 0.30.4, publiés à 39 minutes d’intervalle, ont ajouté une nouvelle dépendance, plain-crypto-js@4.2.1, dont le script postinstall déposait un cheval de Troie d’accès distant sur toute machine ayant lancé l’installation. npm a retiré les deux versions avant 03 h 15 UTC. [1][2] Google attribue l’opération avec un haut degré de confiance à UNC1069, un groupe lié à la Corée du Nord, tandis que Microsoft suit l’acteur sous le nom Sapphire Sleet. [3][4]
J’appelle cela une attaque de la chaîne de confiance. Une attaque de la chaîne d’approvisionnement logicielle modifie le code que vous exécutez sans changer une seule ligne que vous avez relue, en compromettant le canal qui livre une dépendance. Personne n’a exploité une faille dans le code source d’axios. Chaque maillon était une décision de confiance prise par quelqu’un, et c’est la grille de lecture utile de cet incident. Je n’ai moi-même rien reproduit de tout cela ; l’analyse repose sur le post-mortem des mainteneurs, les rapports des éditeurs et les fiches du registre telles qu’elles se présentaient le 7 octobre 2026.
axios@1.14.0 published by CI
_npmUser GitHub Actions
trustedPublisher github
gitHead 46bee3dea75ef53a8eae49f3b7487e6341de6074
dist.attestations SLSA provenance v1
axios@1.14.1 2026-03-31T00:21:58Z, published by hand
_npmUser the lead maintainer's account
trustedPublisher absent
gitHead absent
GitHub commit/tag none; the release exists only on npmLa chaîne a commencé sur un portable et s’est terminée sur un portable
Le premier maillon n’était ni npm ni GitHub. Selon le post-mortem des mainteneurs, l’attaquant a atteint le poste du mainteneur principal via une campagne d’ingénierie sociale ciblée et un RAT, ce qui lui a donné les identifiants npm utilisés pour publier. [1] Un RAT sur la machine d’un développeur a produit le jeton. Le jeton a produit un RAT sur chaque machine de développeur et chaque runner CI ayant installé la version empoisonnée. La même famille de malware aux deux bouts.
Les mainteneurs ont été francs à ce sujet. Publier directement depuis un compte personnel était un risque évitable, et le flux OIDC ainsi que les releases immuables qu’ils adoptent désormais auraient dû être en place avant que cela n’arrive. [1] Cette phrase me parle. Elle nomme la vraie leçon, et cette leçon ne concerne pas axios : elle concerne l’endroit où réside l’autorité de publication.
Je lis cet incident comme six maillons, présentés ci-dessous. À chacun, un contrôle aurait pu briser la chaîne, et chacun a un responsable différent. Certains appartiennent à l’éditeur. La plupart vous appartiennent. Je les parcours dans l’ordre.
1 maintainer laptop social engineering + RAT -> npm credentials
2 registry gate direct token publish accepted -> axios 1.14.1, 0.30.4
3 decoy dependency plain-crypto-js 4.2.0 -> 4.2.1 -> new line in package.json
4 your resolver fresh install, no cooldown -> poisoned tarball fetched
5 install script postinstall: node setup.js -> dropper runs
6 runner or laptop reachable secrets, open egress -> RAT, then whatever it can reachPourquoi le trusted publishing OIDC n’a-t-il pas arrêté un jeton volé ?
Parce que ce n’était pas la seule porte. Le trusted publishing permet au registre de n’accepter une publication que depuis un workflow CI nommé, via un identifiant OIDC à courte durée de vie plutôt qu’un secret stocké. Les releases axios 1.x passaient déjà par là, ce qui explique que 1.14.0 affiche un éditeur de confiance et une provenance. [2][6] La version malveillante 1.14.1 a été publiée directement depuis un compte, sans éditeur de confiance, sans gitHead et sans commit ni tag correspondant dans le dépôt. [2]
Un bon chemin de publication ne supprime pas le mauvais. Tant que le paquet accepte encore des jetons, quiconque en détient un peut contourner entièrement le workflow. Le contrôle qui ferme cette voie est un réglage du paquet, Require two-factor authentication and disallow tokens, que la documentation npm recommande une fois les éditeurs de confiance configurés, et qui laisse ces éditeurs de confiance fonctionner normalement. [6] StepSecurity interprète l’absence de métadonnées comme le vol d’un ancien jeton classique à longue durée de vie ; les mainteneurs disent seulement que l’attaquant a obtenu les identifiants du compte. [1][2] Dans les deux cas, la publication n’est jamais passée par le workflow.
Mon avis : activer OIDC sans désactiver les jetons, c’est la moitié d’un contrôle, et les équipes cochent la case après la première moitié. Si vous publiez des paquets, vérifiez les deux moitiés dès aujourd’hui. Si vous les consommez, les métadonnées du registre sont un signal lisible : une version qui provenait auparavant d’un éditeur de confiance et qui soudain n’en provient plus mérite une pause. npm resserre aussi l’autre côté : à partir de janvier 2027, les jetons granulaires qui contournent l’authentification à deux facteurs perdront la possibilité de publier directement. [7]
Une dépendance que rien n’importe est en soi une vulnérabilité
L’attaquant a préparé le terrain un jour à l’avance. plain-crypto-js@4.2.0 est sorti à 05 h 57 UTC le 30 mars comme leurre propre : une copie du vrai code crypto-js, sans hook d’installation. Son seul rôle était de donner au paquet un historique de publication pour qu’il ne paraisse pas flambant neuf. La version malveillante 4.2.1 a suivi à 23 h 59 UTC, 22 minutes avant axios 1.14.1. [2]
Voici l’indice. StepSecurity a passé en revue les 86 fichiers d’axios@1.14.1 et a constaté que plain-crypto-js n’est jamais importé ni requis nulle part. [2] Le code qu’axios livrait n’en avait aucun usage. La release a aussi perdu le script prepare de husky, ce qui cadre avec une publication manuelle ayant contourné l’outillage habituel de release. [2] Dit simplement : une release de correctif a ajouté une dépendance d’exécution que le paquet n’utilise jamais. C’est une ligne sur laquelle un relecteur humain s’arrête.
C’est le contrôle que j’automatiserais en premier. À chaque mise à niveau, comparez le manifeste entre l’ancienne et la nouvelle version. Une nouvelle dépendance dans un correctif mineur revient à un humain. Posez ensuite trois questions : quel âge a le paquet, qui le maintient, et le paquet parent l’utilise-t-il vraiment ? Les commandes ci-dessous répondent aux trois en moins d’une minute. Votre bot de mise à jour ne les pose pas à votre place.
# what changed in the manifest between two versions
npm diff --diff=<pkg>@<old> --diff=<pkg>@<new> package.json
# does the package actually use the new dependency?
npm pack <pkg>@<new> && tar -xzf <pkg>-<new>.tgz && grep -R "<new-dep>" package/
# how old is the new dependency, and who maintains it?
npm view <new-dep> time maintainers --jsonPourquoi un simple npm install a-t-il suffi à livrer la machine ?
Parce que npm exécute du code à l’installation. Un script de cycle de vie est une commande qu’un paquet déclare dans son package.json et que le gestionnaire de paquets exécute automatiquement ; plain-crypto-js@4.2.1 déclarait postinstall: node setup.js. [3] Cela s’exécute avant que votre application n’importe quoi que ce soit, ce qui explique pourquoi un projet n’ayant jamais touché la bibliothèque à l’exécution était tout de même exposé. StepSecurity a observé le premier appel vers le serveur de l’attaquant dans les deux secondes suivant un npm install. [2]
L’obfuscation de setup.js est plus légère qu’il n’y paraît. Les chaînes sont inversées, décodées en Base64 puis combinées par XOR avec la clé OrDeR_7077 et la constante 333. [5] La désobfuscation de StepSecurity montre que la clé passe par Number() : ses lettres deviennent NaN, le XOR avec zéro ne change rien, et la clé effective est 0,0,0,0,0,0,7,0,7,7. [2] C’est un artifice contre les scanners de chaînes de caractères, pas de la cryptographie. La leçon : l’analyse statique des scripts d’installation est un bon déclencheur d’alerte, mais une frontière faible.
Ensuite, le dropper se ramifie selon la plateforme et demande au serveur de l’attaquant une seconde étape. Le corps de la requête POST indique au serveur laquelle envoyer : product0 pour macOS, product1 pour Windows, product2 pour Linux. [2] Le tableau montre où chacune atterrit. Les trois variantes émettent un signal toutes les 60 secondes, un JSON encodé en Base64, avec le User-Agent d’Internet Explorer 8 sous Windows XP. [3] Comme le RAT est récupéré à la volée, rien dans le tarball n’est, à proprement parler, le malware : le paquet est un téléchargeur. Le trafic sortant devient donc le contrôle qui compte, et un User-Agent IE8 en provenance d’un runner de build est l’alerte la moins coûteuse que vous écrirez jamais. [3]
platform stage two lands in started by
win32 %TEMP%\6202033.ps1 VBScript; powershell.exe copied to %PROGRAMDATA%\wt.exe
darwin /Library/Caches/com.apple.act.mond Mach-O binary, chmod +x, zsh in the background
linux /tmp/ld.py nohup python3
all POST http://sfrclak[.]com:8000/6202033
body: packages.npm.org/product0 (macOS), product1 (Windows), product2 (Linux)
beacon: Base64 JSON every 60 seconds
User-Agent: mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)
win32 persistence: HKCU Run key "MicrosoftUpdate" and %PROGRAMDATA%\system.batPourquoi le dossier paraissait-il propre ensuite ?
Parce que le dropper faisait le ménage. Après son exécution, setup.js se supprimait lui-même ainsi que le package.json malveillant, puis renommait un package.md préparé à l’avance en package.json. [2] Ce manifeste de remplacement déclare la version 4.2.0, sans hook d’installation. Un ingénieur qui ouvre ensuite node_modules/plain-crypto-js voit une inoffensive 4.2.0, alors que c’est la 4.2.1 qui a tourné. [2] Le leurre semé la veille rapporte une seconde fois.
C’est pourquoi je dis aux équipes de vérifier le lockfile, pas node_modules. Une entrée pour plain-crypto-js dans le lockfile, en 4.2.0 ou 4.2.1, signifie que votre graphe d’installation incluait l’attaque, et la recommandation de Google est de considérer l’hôte comme compromis à partir de ce moment-là. [3] Ce qu’il faut conserver et comment prouver la reprise sont traités dans mon précédent article sur Axios et les preuves de build. Ici, je reste sur le mécanisme.
Que faisait le RAT ? L’analyse de Google nomme la charge utile WAVESHAPER.V2 ; elle recense la reconnaissance système, le listage de répertoires, l’exécution arbitraire de commandes et l’injection de binaires. [3] C’est un accès, pas un vol. Ce qu’un opérateur fait avec un accès à un portable de développeur ou à un runner CI, c’est lire les jetons, les clés et les fichiers .env qui s’y trouvent ; le cas OpenAI montre l’enjeu : un workflow GitHub Actions dans son processus de signature d’application macOS a exécuté axios 1.14.1 avec un certificat de signature et du matériel de notarisation à portée. OpenAI n’a trouvé aucune preuve d’accès aux données utilisateur ni de logiciel altéré, et a fait tourner le certificat par précaution. [12]
Qui doit vraiment s’inquiéter de cette affaire ?
Une part de votre parc plus réduite et plus précise que ne le laissaient entendre les gros titres. L’application livrée n’est pas la victime. Rien n’importe plain-crypto-js, donc un bundle frontend construit à partir d’axios 1.14.1 n’avait aucune raison de contenir la charge utile ; la victime, c’est la machine qui a exécuté l’installation. [2] La fenêtre était également courte : de 00 h 21 UTC jusqu’au retrait à 03 h 15 UTC, moins de trois heures. [1]
Pour que vous soyez touché, trois conditions devaient être réunies. Une installation neuve a résolu axios 1.14.1 ou 0.30.4 dans cette fenêtre, c’est-à-dire une installation sans lockfile correspondant, ou un bot de mise à jour ayant fusionné dans les heures suivant la sortie. Les scripts d’installation étaient autorisés à s’exécuter, ce qui était le comportement par défaut. Et la machine détenait quelque chose qui valait la peine d’être atteint. Pendant l’incident, Microsoft recommandait de figer des versions exactes et de suspendre les bots de dépendances automatisés. [4] Si vous pouvez répondre à ces trois questions pour chaque runner et chaque portable, vous connaissez votre exposition. Si vous ne le pouvez pas, c’est là le véritable constat.
Quel contrôle brise quel maillon ?
Un par maillon, et le tableau ci-dessous est tout le modèle. Briser un seul maillon suffit à arrêter une attaque donnée. Vous ne choisissez pas quel maillon le prochain attaquant laissera intact, donc couvrez-en plus d’un.
Le maillon cinq a changé cette année. npm 12, disponible en version générale depuis le 8 juillet 2026, n’exécute plus les scripts preinstall, install et postinstall des dépendances à moins que le projet ne les autorise, via une liste allowScripts que vous validez dans le dépôt. [7][8] Sous npm 12, le hook de plain-crypto-js n’aurait pas tourné sans autorisation explicite. Mais vérifiez ce que votre CI utilise réellement : l’image node:24-alpine embarque npm 11.19.0 au 7 octobre 2026, et npm 11 exécute toujours les scripts des dépendances par défaut. pnpm les bloque par défaut depuis la version 10, et même ce réglage nécessitait une vérification : avant la 10.26.0, les dépendances hébergées sur Git pouvaient quand même exécuter du code à l’installation, une faille de gravité élevée. [9][10] Un réglage devient une frontière une fois que vous l’avez vu bloquer quelque chose.
Le maillon quatre dispose aussi d’un contrôle peu coûteux. npm 11.10.0 a introduit min-release-age, un délai en jours, en février 2026. Le minimumReleaseAge de pnpm se compte en minutes et vaut un jour par défaut dans pnpm 11. [10][11] Les versions empoisonnées d’axios ont vécu moins de trois heures, donc même un délai d’un jour les dépasse largement. Un délai de latence est un pari sur le fait que quelqu’un remarquera la menace dans cette fenêtre, et il ne sert à rien contre un attaquant patient. Associez-le à trustPolicy: no-downgrade de pnpm, qui refuse une version dont le niveau de confiance a baissé par rapport aux releases précédentes. [10] C’est conçu exactement pour un cas comme 1.14.1, mais je n’ai pas rejoué cette attaque contre ce réglage, car 1.14.1 a disparu du registre. Testez-le sur un paquet que vous contrôlez.
link control owner
1 maintainer laptop no publish credential on a workstation publisher
2 registry gate trusted publisher + "disallow tokens" publisher
3 decoy dependency diff the manifest; question new deps in patches you
4 fresh resolution npm ci + lockfile; min-release-age / pnpm cooldown you
5 install script npm 12 allowScripts; pnpm 10+; --ignore-scripts you
6 reachable secrets short-lived creds, split jobs, egress allow-list you
+ trust downgrade pnpm trustPolicy: no-downgrade youHuit vérifications avant votre prochain npm install
Voici la version actionnable de cet article. Commencez par vos propres dépôts, puis vos images CI, puis les paquets que vous publiez. La liste ci-dessous est ordonnée selon la rapidité du retour sur effort.
Lorsque je relis une base de code, le manifeste de dépendances, le lockfile et la configuration de release entrent dans le périmètre, parce que les frontières de confiance qui comptent se trouvent souvent là, et non dans le code que vous avez écrit. C’est la partie de cette chaîne qu’un pentest classique n’atteint généralement pas. Si vous voulez le regard d’un attaquant sur la manière dont votre produit fait confiance à ses dépendances et à son chemin de release, dites-moi ce que vous livrez. Si vous pensez avoir été touché, préservez les preuves avant de reconstruire et faites appel à un spécialiste dédié de la réponse à incident : c’est un métier différent du mien.
1 lockfile grep -nE "plain-crypto-js" package-lock.json # 4.2.0 or 4.2.1 means act
2 CI install npm ci, never npm install; no bot merges within hours of a release
3 scripts npm 12: review allowScripts | older npm: --ignore-scripts, allow by name | pnpm 10+: allowBuilds
4 cooldown npm config set min-release-age 3 | pnpm: minimumReleaseAge
5 trust pnpm: trustPolicy: no-downgrade
6 publishing your packages: trusted publisher on, "disallow tokens" on, no publish token on a laptop
7 egress build runners: default-deny outbound; alert on node spawning curl, powershell or python3
8 secrets the install job holds no deploy, signing or cloud credentialsSources
- [1] Mainteneurs d’Axios : post-mortem de l’incident
- [2] StepSecurity : axios compromis sur npm, analyse technique
- [3] Google Threat Intelligence : un acteur lié à la Corée du Nord cible axios
- [4] Microsoft : mesures d’atténuation de la compromission d’axios sur npm
- [5] JFrog Security Research : analyse du malware plain-crypto-js
- [6] Documentation npm : éditeurs de confiance
- [7] Changelog GitHub : sécurité npm à l’installation et fin des jetons contournant la 2FA
- [8] Documentation npm : scripts d’installation dans npm 12
- [9] Avis de sécurité pnpm GHSA-379q-355j-w6rj : contournement du blocage des scripts de cycle de vie
- [10] Documentation pnpm : réglages de sécurité de la chaîne d’approvisionnement
- [11] Release npm CLI v11.10.0 : min-release-age
- [12] OpenAI : compromission de l’outil de développement axios
Besoin de vérifier ce point dans votre propre produit ?
Revue de code sécuritéVous voulez apprendre à trouver vous-même ce type de vulnérabilité ?
Formation individuelle à la revue de code sécurisé