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

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.

un inconnupull request surun fork, puis unnpm publishrelease.ymlon: issue_commentaucun contrôle d'auteurrécupère le forkpnpm installexécute son codeid-token: writenpm OIDCpublication de confiancejeton émisdélivré comme prévu10 versionspubliées sur npmprovenance valideet ce n'est pas fauxAucune étape de cette chaîne n'a exigé un secret volé. Le maillon faible était en amont de toute signature.
Comment dix versions signées ont été publiées par quelqu'un sans le moindre identifiant. Le workflow de publication s'est déclenché sur un commentaire sans vérifier qui l'avait écrit, a récupéré le code du fork de l'attaquant, l'a exécuté pendant l'installation des dépendances, et s'est servi de sa propre permission id-token pour frapper un jeton de publication npm légitime. L'attestation de provenance sur le résultat est exacte : le pipeline du projet a bien construit et publié ce paquet.

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.

11 août3.0.2 — dernière saine28 août, 20:00 & 20:19 UTCvague 1 (binding.gyp), vague 2 (preinstall)23:11 UTCnpm retire les dixexposition : 3 h 11 minDélai d'ancienneté à deux jours : une plage caret prend la dernière version antérieure au seuil — 3.0.2.
La fenêtre d'attaque face au seuil par défaut. Dix versions empoisonnées ont existé sur npm pendant trois heures et onze minutes. Le délai d'ancienneté de Bromure est actif dès l'installation, avec un minimum de deux jours : une plage caret se résout donc vers 3.0.2, du 11 août, et les versions empoisonnées ne sont jamais candidates. Le délai ne porte aucun jugement sur le paquet ; il refuse seulement d'être le premier.

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.

ce qu'il faut encore au ver1. s'exécuter à l'installationbinding.gyp, ou un hook preinstall2. trouver des identifiants150+ motifs sur le répertoire perso3. sortir sur le réseaurécupérer Bun, déposer les données4. republier plus loinpousser des commits, republieroù cela atterrit sur le Macproxy de paquets, côté hôteancienneté, scripts retirés, OSV,filtrage socket.dev ou Delpiun répertoire perso de VMespaces réservés brm_…, faux base64,kubeconfig synthétique, aucune clé SSHConnexions sortantesrègles côté hôte, la première gagne,vérifiées au switch et au proxyGarde-fous, Lecture seulegit-receive-pack et écritures RESTrenvoient 403 depuis l'hôte
Les quatre choses dont Trinitite a besoin une fois lancé, et là où chacune atterrit dans un profil Bromure Agentic Coding. L'exécution rencontre le retrait des scripts et le filtrage de paquets au proxy de l'hôte. Le balayage d'identifiants trouve des espaces réservés dans un répertoire personnel de VM. La sortie réseau et la publication rencontrent un jeu de règles côté hôte et les Garde-fous. Aucun de ces contrôles ne vit dans l'invité, si bien que le code qui s'y exécute ne peut pas les modifier.

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.