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

L'indice était l'horodatage

npm a livré son plus grand changement de sécurité en seize ans le 8 juillet 2026 — les scripts d'installation désactivés par défaut. En moins d'une semaine, deux compromissions npm distinctes ont déplacé leur charge utile pour qu'elle se déclenche à l'importation du paquet plutôt qu'à son installation, contournant directement le nouveau défaut et --ignore-scripts. Le mécanisme que l'écosystème venait de passer un an à apprendre à surveiller avait déjà bougé. La seule chose qui n'a pas bougé, c'est l'âge de la version malveillante — et c'est le signal que vérifie la barrière d'âge de Bromure.

L'écosystème entier a passé un an à apprendre que les scripts d'installation npm étaient le danger. Le 8 juillet, npm les a désactivés par défaut. Trois jours plus tard, une compromission a sorti sa charge utile du script d'installation ; trois jours après, une seconde est arrivée sans aucun script d'installation. Les deux se déclenchent quand vous importez le paquet. Le correctif et le contournement se sont croisés dans la même semaine.

Un développeur ajoute @asyncapi/generator à un projet le 14 juillet et lance npm install. Le paquet n'a pas de preinstall, pas d'install, pas de hook postinstall — rien qu'un scanner de scripts puisse signaler, rien que le tout nouveau défaut de npm puisse ignorer. L'installation se termine proprement. La première fois que le code fait require('@asyncapi/generator'), un chargeur câblé dans le point d'entrée du paquet lui-même lance un processus Node détaché, récupère une seconde charge de 8,2 Mo via IPFS, la déchiffre et se met à chasser des identifiants. Rien dans l'installation n'a paru anormal, parce que la partie anormale n'a pas eu lieu à l'installation.

Le correctif livré pour l'attaque de l'an dernier

Pendant près d'un an, l'histoire des attaques de la chaîne d'approvisionnement npm a été la même : un paquet compromis porte un hook postinstall, et npm install l'exécute automatiquement sur votre machine. Alors le 8 juillet, npm a livré la version 12 — la plus grande refonte de sécurité en seize ans d'existence de l'outil. Les scripts de cycle de vie des dépendances sont désormais désactivés par défaut : « les scripts preinstall, install et postinstall des dépendances ne s'exécutent pas sauf autorisation ». Les dépendances Git et les sources par URL distante sont aussi devenues opt-in. C'est une vraie amélioration, et elle ferme une porte par laquelle une année de vers est passée.

Elle ferme une porte. Les attaquants avaient déjà trouvé l'autre.

La charge utile a migré vers l'importation

Le 11 juillet, le paquet npm jscrambler a été compromis — cinq versions malveillantes publiées sur environ trois heures. Les trois premières, de 8.14.0 à 8.17.0, « exécutaient le dropper depuis un hook preinstall », le schéma classique. Puis, en pleine campagne, l'attaquant a changé de porte. « À partir de 8.18.0, le hook d'installation disparaît entièrement — le dropper identique est à la place injecté comme une fonction auto-exécutable en tête de dist/index.js », de sorte qu'il « se déclenche quand le paquet est importé ou que son CLI est lancé plutôt qu'à l'installation ». Socket nomme la raison sans détour : « C'est une évasion délibérée : elle déjoue les scanners qui n'inspectent que les scripts preinstall/postinstall et survit à npm install --ignore-scripts ».

Trois jours plus tard, l'organisation npm @asyncapi a été compromise de la même façon, mais conçue pour ça dès le départ. Cinq versions sur quatre paquets — @asyncapi/specs 6.11.2-alpha.1 et 6.11.2, @asyncapi/generator 3.3.1, @asyncapi/generator-components 0.7.1, @asyncapi/generator-helpers 1.1.1 — ont été republiées après qu'un attaquant a utilisé un workflow GitHub Actions mal configuré pour dérober le jeton du bot du projet. Le constat de Microsoft est celui qui compte ici : « Tous les paquets affectés ne déclaraient aucun hook preinstall, install ou postinstall dans package.json ». Le chargeur se logeait à la place dans le point d'entrée normal de chaque paquet — index.js pour les specs, un validator.js enfoui dans les templates du générateur. Et le conseil de mitigation est explicite : « Ne comptez pas sur npm install --ignore-scripts comme mitigation ; cette campagne s'exécute quand le module est importé, pas via un hook de cycle de vie ».

jscrambler · 11 juil.8.18.0 : hook retiréAsyncAPI · 14 juil.aucun hookPORTE D'INSTALLATIONpreinstall / postinstallfermée par défaut (npm v12)fermée par --ignore-scriptsferméePORTE À L'IMPORTATIONrequire() / importchargeur au point d'entréenpm v12 n'y touche pasouverteMÊME CHARGE UTILEprocessus node détachéétage 8,2 Mo via IPFScollecteur d'identifiantsC2 · 85.137.53.71
Même charge utile, une autre porte. Pendant un an, la charge est passée par la porte des scripts d'installation : npm exécute preinstall/postinstall automatiquement. npm v12 (8 juillet) ferme cette porte par défaut, et --ignore-scripts la ferme manuellement. Mais une seconde porte mène à la même pièce — le code qui s'exécute quand vous importez le paquet. jscrambler (11 juillet) a déplacé son dropper d'un hook preinstall vers dist/index.js ; AsyncAPI (14 juillet) est arrivé sans aucun hook d'installation, son chargeur câblé dans le point d'entrée du paquet lui-même. Fermer la porte d'installation ne touche pas la porte d'importation.

Deux acteurs sans lien, à trois jours d'écart, tombant tous deux sur le même geste la semaine même où le correctif phare de l'écosystème est entré en service. Ce n'est pas une coïncidence. C'est ce qui arrive quand une défense nomme un mécanisme : le mécanisme devient un point d'appui, et se tenir ailleurs coûte à l'attaquant quelques lignes de code. Un scanner qui demande « ce paquet a-t-il un hook d'installation ? » reçoit un « non » véridique d'AsyncAPI, et le laisse passer.

La seule chose qui n'a pas bougé

Retirez le mécanisme de livraison et regardez ce qui est resté constant dans les deux compromissions. Les versions jscrambler ont été en ligne quelques heures. La version stable d'AsyncAPI a été publiée à 08h30 UTC et la détection en aval a commencé à 08h49 — dix-neuf minutes. Chaque version malveillante des deux incidents a été retirée bien en deçà de deux heures après sa mise en ligne. La charge utile est passée du hook d'installation au point d'entrée ; la livraison est passée d'un script de cycle de vie à un chargeur d'exécution. La seule propriété qui n'a pas bougé, c'est que chaque version empoisonnée était toute neuve. Une version fraîchement publiée est l'empreinte partagée d'une compromission, car c'est la forme de l'attaque : voler un jeton, publier, se faire prendre, se faire retirer. La fenêtre est courte par construction.

C'est la propriété que vérifie la barrière d'âge de Bromure, et elle est active par défaut. Chaque récupération de paquet — npm, PyPI, Cargo et les autres — passe par le proxy man-in-the-middle de l'hôte avant que l'installation de l'agent ne voie le moindre octet, et le proxy refuse toute version plus jeune que le seuil, deux jours par défaut. Une référence flottante comme latest ou une plage semver se résout silencieusement vers la version la plus récente qui soit assez ancienne ; une référence figée vers une version trop fraîche revient sous forme de 451 avec une erreur Bromure expliquant pourquoi. Elle ne demande jamais où se trouve la charge utile. Peu lui importe que le code se déclenche à preinstall ou à require(), que le hook soit présent ou absent, que --ignore-scripts soit défini. Une version publiée il y a dix-neuf minutes est refusée parce qu'elle a dix-neuf minutes. Ces deux campagnes étaient mortes à l'arrivée face à une barrière de deux jours — non parce que Bromure a reconnu la charge utile, mais parce qu'il n'a jamais regardé la charge utile du tout.

CONTRÔLE MÉCANISME · VISE LA CHARGE UTILE« a-t-il un hook d'installation ? »preinstall / install / postinstallAsyncAPI répond, honnêtement :nonpasse le contrôlechargeur exécuté au require()BARRIÈRE D'ÂGE BROMURE · VISE LA VERSION« quel âge a cette version ? »seuil : 2 jours, par défautle registre indique publiée :il y a 19 minutesrefusée · 451charge jamais résolue
Deux façons de décider de laisser passer un paquet. Un contrôle de mécanisme pose une question sur la charge utile — a-t-il un hook d'installation ? — et AsyncAPI répond « non » avec véracité, donc le contrôle le laisse passer. La barrière d'âge de Bromure pose une question sur la version — quel âge a-t-elle ? — et une version publiée il y a dix-neuf minutes est refusée par un 451 quel que soit l'endroit où son code se déclenche. Le mécanisme est passé de l'installation à l'importation entre deux attaques en une semaine ; l'âge n'a pas bougé, parce qu'une publication fraîche est la forme de la compromission.

La barrière d'âge n'affirme pas que les nouveaux paquets sont malveillants — la plupart vont bien, et une version de deux jours reste très récente. Elle affirme quelque chose sur l'endroit où le risque se concentre. La fenêtre dangereuse d'une compromission, ce sont les premières heures après qu'un jeton volé a poussé une version, avant qu'un mainteneur ne s'en aperçoive et ne la retire. Attendre deux jours ne rend pas un paquet sûr, mais cela sort votre installation de l'intervalle exact où vivent ces attaques, et cela sans inspecter une seule ligne du code que vous êtes sur le point d'exécuter. Pour un paquet dont vous avez besoin le jour de sa sortie, la barrière accepte une exemption par paquet ; tout le reste attend ses deux jours.

Quand une version est quand même assez ancienne

Une barrière d'âge est un filtre, pas un mur. Réglez un seuil plus court, exemptez un paquet dont vous avez besoin dès le premier jour, ou retirez une version malveillante restée non détectée assez longtemps pour dépasser deux jours d'âge, et le code empoisonné atteint l'installation. La réponse de Bromure pour ce cas est celle que décrivent les précédents articles sur la chaîne d'approvisionnement, et c'est la raison pour laquelle la barrière d'âge peut se permettre d'être un filtre plutôt que toute la défense : l'agent qui lance l'installation n'est pas sur votre Mac.

Il est à l'intérieur d'une VM Linux jetable, à une frontière d'hyperviseur. Alors quand le chargeur d'AsyncAPI se déclenche et que son collecteur d'identifiants part en quête — et il cherche dur, balayant plus d'une centaine de variables d'environnement dont NPM_TOKEN, AWS_ACCESS_KEY et ANTHROPIC_API_KEY, plus .npmrc, ~/.aws/credentials, id_ed25519 et le kubeconfig sur le disque — il trouve les copies de la VM, qui sont des chaînes brm_… factices. Les vraies valeurs vivent sur l'hôte. Quand l'agent fait une requête légitime qui en a besoin, le proxy de l'hôte échange le faux contre la vraie valeur sur le fil, pour ce seul saut, puis l'échange en retour ; le secret n'atterrit jamais dans la mémoire de la VM pour que le collecteur le récupère. La clé ~/.ssh que la charge copie est une paire jetable créée pour ce profil. Et l'envoi C2 que la charge tente — vers 85.137.53.71, dans ce cas — sort par le même proxy de l'hôte qui journalise chaque destination, si bien qu'un processus Node que vous n'avez jamais lancé ouvrant une socket vers une IP inconnue est une ligne dans le Journal de sécurité, pas un transfert silencieux. Quand la fenêtre se ferme, la VM et tout ce que la charge y a fait disparaissent.

C'est la même famille de runtime Miasma qui s'est invitée dans le propre périmètre npm de Red Hat plus tôt cette année. Le collecteur est mature et réutilisé d'une campagne à l'autre ; le mécanisme de livraison est la partie qui n'arrête pas de changer.

La leçon inconfortable de cette semaine précise, c'est qu'une défense arrimée à un mécanisme a une demi-vie qui se compte en jours. L'écosystème a passé un an à faire des scripts d'installation la chose à surveiller, a livré le correctif, et a vu deux acteurs distincts déplacer la charge utile avant même que les notes de version ne refroidissent. On ne gagne pas cette course en nommant le prochain mécanisme, parce qu'il y a toujours un autre endroit où le code peut se déclencher. On la gagne en vérifiant une chose que l'attaquant ne peut pas changer à bon marché — l'âge de la version — et en exécutant ce qui passe quelque part où il ne peut rien emporter. Essayez-le.