N'importe qui pouvait publier ce paquet
Le 28 août, quelqu'un a ouvert une pull request sur un générateur de code OpenAPI très utilisé, y a laissé un commentaire de deux mots, et le workflow de publication du projet a mis en ligne sur npm dix versions empoisonnées, munies d'attestations de provenance valides. L'attaquant n'a jamais détenu le moindre identifiant. La première vague ne comportait aucun script d'installation : elle cachait sa charge là où node-gyp évalue du Python. La fenêtre entière a duré trois heures et onze minutes, et le délai d'ancienneté de Bromure Agentic Coding, actif par défaut à deux jours, bat ce chrono sans rien savoir de l'attaque.
Quelqu'un a ouvert une pull request depuis un fork et y a laissé un commentaire de deux mots. Le pipeline de publication du projet a lu le commentaire, récupéré leur code, l'a exécuté, et a signé ce qui en est sorti. Ils n'ont rien volé pour en arriver là.
@7nohe/openapi-react-query-codegen transforme un schéma OpenAPI en hooks
TanStack Query. Vous le pointez vers une spécification et il écrit le code
client pour vous. Aikido compte plus de 150 000 téléchargements par semaine. On
ajoute un paquet comme celui-là parce qu'il vous fait gagner un après-midi, et
votre agent de code l'installe sans se poser de question.
Le 28 août, il s'est mis à livrer un voleur d'identifiants.
Aikido Security a publié l'analyse le jour même, et SafeDep a publié la sienne avec le mécanisme du workflow et une chronologie minute par minute. Le malware se nomme lui-même dans ses propres chaînes de caractères : Trinitite. Les deux équipes rattachent son savoir-faire à la famille Mini Shai-Hulud, ce ver npm auto-réplicant qui traverse les comptes de mainteneurs depuis le début de l'année.
C'est sa route jusqu'à npm qui mérite qu'on s'y arrête.
Le pipeline de publication a pris une instruction d'un inconnu
Le release.yml du projet avait un déclencheur issue_comment. C'est un
événement GitHub Actions qui se déclenche lorsque quelqu'un commente une issue
ou une pull request, et c'est une commodité répandue : les mainteneurs le
câblent pour pouvoir publier une version en tapant un mot plutôt qu'en
cliquant dans une interface.
Celui-ci n'avait aucun contrôle d'association d'auteur. GitHub indique à un workflow si la personne qui a écrit le commentaire est le propriétaire du dépôt, un membre, un ancien contributeur ou un parfait inconnu, et c'est au workflow d'aller regarder. Celui-ci ne l'a pas fait.
SafeDep a retracé la séquence. Ouvrir une pull request depuis un fork.
Commenter npm publish dessus. Le pipeline se réveille, récupère le code
présent dans le fork de l'attaquant, et lance pnpm install, ce qui exécute
le hook preinstall de l'attaquant sur le runner du projet lui-même. Ce job
portait id-token: write, de quoi frapper un jeton OIDC de publication de
confiance npm.
Dix versions malveillantes sont sorties, chacune portant une attestation de provenance valide, puisque le pipeline du projet a bel et bien construit et publié chacune d'elles. La formule d'Aikido est celle qu'il faut garder : « lorsque le workflow lui-même est compromis, ce certificat devient un signal de confiance peu fiable. »
Vérifiez la provenance d'un paquet et vous apprenez une seule chose : s'il est sorti du pipeline de ce dépôt. Ici, c'était bien le cas. Personne n'a rien falsifié, et l'on peut détenir une signature correcte apposée sur un malware sans qu'aucun des deux faits n'ait l'air anormal. En mai, nous avions vu le même résultat arriver via un jeton OIDC volé. Cette fois, l'attaquant s'est passé du vol.
La première vague n'avait aucun script d'installation à trouver
Scannez ces huit premières versions à la recherche de hooks d'installation
dangereux et vous revenez bredouille. Leurs fichiers package.json ne
déclarent rien qui s'exécute.
La charge était dans binding.gyp, le fichier de description de build que
node-gyp utilise pour compiler les modules natifs. Aikido explique
l'astuce : « Lorsque npm install traite un paquet contenant un
binding.gyp, il invoque node-gyp pour compiler le module natif. node-gyp
évalue le champ conditions du fichier avec Python, ce qui signifie que des
expressions Python arbitraires peuvent y être placées et qu'elles seront
exécutées pendant l'installation, même sans aucun script preinstall déclaré
dans package.json. »
L'attaquant a donc écrit une expression Python dans un fichier de build. Elle
parcourt l'arbre interne des sous-classes de Python jusqu'à atteindre
catch_warnings, se sert de cette classe pour accéder à __builtins__,
importe os depuis là, et appelle os.system() sur un fichier JavaScript
livré dans l'archive. SafeDep note que le parcours est écrit en séquences
d'échappement Unicode, si bien qu'un grep sur os.system revient lui aussi
bredouille.
Le JavaScript en dessous déballe trois couches — XOR, puis AES-128-GCM, puis un
obfuscateur commercial — avant d'aller chercher Bun 1.4.0 sur GitHub et de le
déposer dans un répertoire temporaire préfixé trinnyyyy- pour exécuter la
vraie charge.
Dix-neuf minutes plus tard, l'attaquant a livré une deuxième vague avec
preinstall réintroduit dans package.json en repli. SafeDep date cette vague
à moins d'une minute après le premier rapport public de sécurité. Celui qui
menait cette opération lisait les publications et livrait contre elles.
Trois heures et onze minutes
La chronologie de SafeDep est en UTC. La dernière version saine, 3.0.2, est sortie le 11 août. La première vague est arrivée le 28 août à 20:00 et s'est achevée à 20:02. La deuxième s'est déroulée de 20:19 à 20:21. npm a retiré les dix versions vers 23:11.
Cela laisse une fenêtre d'exposition de trois heures et onze minutes. Un projet
sur ^3.0.0, la banale plage caret que npm écrit pour vous, tombait droit
dedans. Si vous avez lancé une installation fraîche pendant cette fenêtre, ou
si votre CI l'a fait, ou votre agent de code, vous avez récupéré le ver.
Une fois lancé, il a balayé le répertoire personnel avec plus de 150 motifs
glob : clés privées SSH, fichiers .env, ~/.docker/config.json, identifiants
AWS, Azure et GCP, jetons npm, PyPI et RubyGems, jetons de comptes de service
Kubernetes, portefeuilles de cryptomonnaie. Sur la même liste figuraient
~/.claude.json, ~/.claude/ et ~/.claude/mcp.json, la configuration de
l'agent de code lui-même et les adresses de tous les serveurs MCP avec
lesquels il parle.
Dans les dépôts qu'il pouvait atteindre, il écrivait un .claude/settings.json
portant un hook SessionStart qui exécute setup.mjs chaque fois qu'un
développeur ouvre le projet dans Claude Code. Avec les jetons de registre
qu'il venait de collecter, il se republiait lui-même dans chacun des paquets
que sa victime maintenait sur npm, PyPI et RubyGems. C'est cela, la partie
ver : chaque développeur atteint devenait un candidat pour la publication
suivante.
Deux jours battent trois heures
Mettez maintenant un profil Bromure Agentic Coding sur le chemin.
Bromure exécute chaque profil comme sa propre machine virtuelle sur Apple Silicon, et chaque paquet que l'agent récupère (npm, PyPI, Cargo, RubyGems, Maven, NuGet, modules Go, Packagist) passe par un proxy du côté Mac de l'hyperviseur avant qu'un seul octet n'atteigne l'invité.
La première chose que ce proxy vérifie, c'est l'âge de la version. Supply
Chain → le délai d'ancienneté est actif par défaut, avec un minimum de deux
jours. Les références flottantes (latest, plages caret, plages tilde) se
résolvent sans un mot vers la version la plus récente antérieure au seuil.
Épinglez quelque chose de trop frais et vous obtenez un 451 avec un message
d'erreur clair.
Confrontez cela à une fenêtre hostile de trois heures et onze minutes. Lancez
npm install à 20:30 UTC le 28 août, en pleine attaque, avec ^3.0.0 dans
votre manifeste, et votre agent se résout vers 3.0.2, du 11 août. Il
n'apprend jamais que 3.0.3 existe.
Le délai y est arrivé sans signature, sans score de réputation et sans bulletin d'alerte. Il a lu une date de publication. Cela compte ici, parce que la deuxième vague a été livrée moins d'une minute après le premier avertissement public, et que toute défense qui attend un rapport entre dans la course en retard.
Ce que trouve le balayage lorsqu'il a quand même lieu
Supposons que vous ayez désactivé le délai pour un paquet, ou que la version ait dépassé le seuil avant que quiconque ne s'en aperçoive. La charge doit encore faire quatre choses, et dans un profil Bromure chacune d'elles rencontre un contrôle qui vit sur le Mac, là où l'invité ne peut pas l'atteindre.
Elle doit s'exécuter. Supply Chain → le retrait des scripts
d'installation enlève preinstall, install, postinstall et prepare des
archives npm à la volée, en réécrivant l'archive et en mettant à jour
l'empreinte des métadonnées du registre pour que la vérification de npm passe
toujours. Voilà la deuxième vague éliminée. Vous pouvez empiler d'autres
contrôles au-dessus : les recherches de vulnérabilités OSV, et le filtrage
de paquets via socket.dev, dont la vérification de blocage des paquets
compromis couvre exactement cette catégorie (scripts d'installation
malveillants, malware), ou Delpi comme registre de remplacement. Aucun
d'eux ne demande si une attestation existe, ce qui compte quand l'attestation
est réelle.
Elle doit trouver quelque chose. Le balayage aux 150 motifs s'exécute
contre /home/ubuntu dans une machine virtuelle, pas contre le répertoire
personnel de votre Mac. Les partages de dossiers sont explicites et plafonnés à
huit. Les identifiants qu'elle trouve sont des leurres : les jetons dans
l'invité sont des espaces réservés brm_… que le proxy de l'hôte remplace par
les vraies valeurs sur le fil, si bien que le matériel réel n'entre jamais dans
l'espace d'adressage de la VM. Les clés privées SSH
ne sont pas du tout dans l'invité :
un ssh-agent par profil sur l'hôte fait la signature, et
~/.docker/config.json contient du faux base64. Le kubeconfig est synthétique,
avec des certificats client jetables. Les requêtes AWS sont re-signées côté
hôte, si bien que tout ce qui contourne le proxy revient en
InvalidSignatureException plutôt qu'authentifié. Activez Exiger une
approbation à l'usage pour un identifiant et chaque substitution fait
apparaître une boîte de dialogue sur votre Mac, avec une autorisation limitée
dans le temps.
Elle doit téléphoner dehors. Garde-fous → Connexions sortantes est un
jeu de règles de style pf : une action, un protocole (tcp, udp, web ou
any), un nom d'hôte ou un CIDR IPv4, des ports, et pour web les méthodes
HTTP que vous autorisez. Les règles s'évaluent de haut en bas, la première qui
correspond gagne, et régler le trafic non apparié sur Refuser transforme la
liste en liste d'autorisation. La même politique est évaluée deux fois par des
couches indépendantes : le commutateur virtuel, par IP de destination et par
nom d'hôte espionné dans le DNS, tous protocoles confondus, et le proxy de
l'hôte, par SNI TLS et par méthode. Aller chercher un binaire Bun et pousser
des secrets volés dans un dépôt de dépôt-relais réclament tous deux une
destination, et les destinations doivent figurer sur la liste.
Elle doit publier. Garde-fous → GitHub en Lecture seule traite
git-receive-pack comme une écriture et bloque les écritures REST, en
renvoyant un 403 net que l'agent lit comme une panne d'API ordinaire. Pas de
commit empoisonné, pas de dépôt-relais, pas de publication en aval. Les mêmes
modes couvrent GitLab, Bitbucket, Kubernetes, AWS, DigitalOcean et les
registres de conteneurs.
Le hook SessionStart qu'elle comptait laisser derrière elle pour la fois
suivante se trouve dans un répertoire personnel que Effacer le répertoire
personnel… nettoie, sous Ressources → Stockage. Réinitialiser à la base…
fait de même pour tout ce qui a été écrit en dehors. Chaque décision prise en
chemin, chaque destination autorisée ou refusée, chaque verdict sur un paquet,
chaque substitution d'identifiant, atterrit dans la fenêtre du Journal de
sécurité sur l'hôte, où le code de l'invité ne peut pas l'éditer parce qu'il
ne peut pas l'atteindre.
Pourquoi cela continue d'arriver
La publication de confiance et la provenance valent la peine d'exister. Elles ont fermé la porte au vol du jeton npm d'un mainteneur, qui était l'attaque dominante il y a un an. Le compromis de cette semaine les a contournées sans toucher au moindre jeton : un inconnu pouvait atteindre le processus de publication, alors le pipeline a signé une attestation véridique apposée sur du code hostile.
On ne peut pas s'auditer pour sortir de là, parce que le prochain workflow qui échouera appartiendra à quelqu'un d'autre. Ce que vous pouvez faire, c'est cesser d'être la première machine à exécuter une nouvelle version de quoi que ce soit.
Ouvrez un profil, regardez Supply Chain, et vérifiez que le délai d'ancienneté est actif. Il devrait l'être, à deux jours, sans que vous y ayez touché. Passez ensuite deux minutes de plus : vérification de vulnérabilités OSV activée, socket.dev ou Delpi sélectionné si vous avez une clé, retrait des scripts d'installation activé. Dans les Garde-fous, réglez Trafic non apparié sur Refuser avec une courte liste d'autorisation, et mettez GitHub en Lecture seule pour tout profil qui ne livre rien. Dans les Identifiants, activez Exiger une approbation à l'usage pour tout ce qui peut dépenser de l'argent.
Puis laissez l'agent installer ce dont il a besoin. Il obtiendra 3.0.2. Installez Bromure Agentic Coding et laissez le délai d'ancienneté tranquille.