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