Blog / Actualité sécurité

Compromission d’axios sur npm : comment la chaîne de confiance a cédé

La compromission d’axios sur npm n’était pas un bogue de code. Suivez les six maillons de la chaîne de confiance, le contrôle qui brise chacun d’eux, et ce que change npm 12.

Études doctorales en sécurité de l’information achevéesSoutenance universitaire réussie · Soutenance finale de thèse à venir

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 npm

La 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 reach

Pourquoi 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 --json

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

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

Huit 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 credentials

Sources

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é
Tous les articles

08Contact

Vous livrez un produit qui traite des données d’utilisateurs ?

Envoyez-moi un bref descriptif. Je réponds sous trois jours avec des questions ou une première proposition, et le prix est fixé par écrit avant le démarrage.