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

L'ancien nom pouvait encore publier

Le 13 octobre, Bloom Security a révélé SlugFest : la publication de confiance de npm identifiait un dépôt GitHub par sa chaîne propriétaire/nom, si bien que quiconque s'emparait de l'ancien nom d'un dépôt renommé ou supprimé pouvait publier de nouvelles versions de paquets qui ne lui appartenaient pas, sans voler le moindre identifiant. Bloom a trouvé plus de 1 500 paquets exposés, totalisant plus de 10 millions de téléchargements, et désigne les agents IA comme les installateurs qui récupèrent une nouvelle version sans que personne ne regarde. Dans Bromure Agentic Coding, une version aussi jeune n'existe pas pour l'agent, l'installation tourne dans une VM qui ne contient que des leurres, et le renommage qui ouvre la brèche attend un clic.

La publication de confiance de npm se souvenait du nom du dépôt et oubliait de quel dépôt il s'agissait. Quand le mainteneur l'a renommé, la première personne à créer un dépôt sous ce nom pouvait publier.

Le lundi 13 octobre, Golan Myers, de Bloom Security, a publié SlugFest, une faille dans la publication de confiance (trusted publishing) de npm. The Hacker News l'a relayée le jour même. Myers l'explique avec une serrure : « C'est comme une serrure qui vérifie l'adresse sur la porte plutôt que le propriétaire de la maison ; quand le propriétaire déménage, celui qui emménage récupère la clé. »

Bloom a compté plus de 1 500 paquets exposés, téléchargés plus de 10 millions de fois au total et plus d'un million de fois par mois. L'attaque ne demande « aucune connaissance interne, aucun vol d'identifiants, aucune situation de concurrence ». Bloom n'a pas nommé les paquets : « Nous ne publions pas de liste des paquets concernés, car le problème est encore répandu. »

Bloom désigne aussi qui installe ces paquets aujourd'hui. Selon son décompte, un agent de code installe en moyenne 70 paquets par mois, et un poste de développeur environ 350. Pour Bloom, une version malveillante publiée via un nom récupéré « est tirée et s'exécute sans humain dans la boucle ».

Comment la publication de confiance vérifiait le dépôt

npm a ouvert la publication de confiance à tous en juillet 2025. Au lieu de stocker un jeton npm dans la CI, le mainteneur lance npm trust github --allow-publish et indique à npm quel dépôt GitHub et quel fichier de workflow ont le droit de publier le paquet. Au moment de publier, GitHub Actions émet un jeton OIDC de courte durée qui décrit l'exécution du workflow, npm le compare à la liaison et rend un identifiant de publication. Il n'existe aucun secret de longue durée à voler.

La liaison stockait le dépôt sous forme de slug, owner/repo, plus le nom du fichier de workflow. Elle ne stockait pas les identifiants numériques repository_id et repository_owner_id que GitHub attribue à vie. npm ne les enregistrait qu'après la première publication réussie. Jusque-là, n'importe quel dépôt qui répondait au bon nom et exécutait le bon fichier de workflow correspondait.

Sur GitHub, les noms se libèrent. Un mainteneur renomme un dépôt, le transfère à une organisation, le supprime, ou renomme son compte. L'ancien slug devient disponible, et quelqu'un d'autre peut l'enregistrer. Les étapes décrites par Bloom :

  1. Créer un dépôt sous le slug libéré. GitHub lui attribue un nouvel identifiant numérique.
  2. Ajouter un workflow portant le nom de fichier de la liaison, avec la permission id-token: write, et l'exécuter.
  3. GitHub émet un jeton OIDC dont le claim de dépôt est l'ancien slug.
  4. Le point d'échange de npm fait correspondre slug et nom de fichier et délivre un identifiant de publication. Les identifiants numériques sont dans le jeton, mais npm n'avait rien à leur comparer.
  5. Publier une nouvelle version d'un paquet qui ne vous appartient pas, avec son nom, son nombre de téléchargements et tous les projets qui en dépendent.

Bloom a déroulé la chaîne de bout en bout contre un scope npm et des dépôts qu'il contrôle, en publiant depuis une seconde organisation où seul le slug correspondait. La publication de confiance de GitLab avait la même lacune.

La version obtenue est en plus signée. La publication de confiance depuis un dépôt public génère une attestation de provenance, et celle-ci désignerait l'exécution du workflow dans le dépôt de l'attaquant, sous le slug configuré par le mainteneur. En août, un commentaire de deux mots sur une pull request avait suffi à faire publier par le propre pipeline d'un projet dix versions empoisonnées munies d'une provenance valide. SlugFest n'a même pas besoin du pipeline de la victime.

Un nom libéré, un workflow qui colle, un droit de publierLiaison npm trustrepo: acme/sdkworkflow: release.ymlrepo_id: (non stocké)le mainteneur renommeacme/sdk → acme/sdk-v2« acme/sdk » est libreet peut être reprisNouveau dépôt de l'attaquantrepository_id: new.github/workflows/release.ymlpermissions: id-token: writeJeton OIDC : repository = acme/sdkÉchange npm : slug ✓ workflow ✓ID : rien à quoi les comparer→ droit de publier délivréRegistre npm@acme/[email protected]étiquetée latestprovenance jointeUn agent de code :npm install @acme/sdkrésout latest,l'exécute, sans témoinNoms fictifs. Bloom a trouvé plus de 1 500 paquets dans cet état et n'a pas publié la liste.
La chaîne SlugFest. La liaison stocke un nom, un renommage libère ce nom, et un nouveau dépôt sous l'ancien nom passe le contrôle de npm et publie sous le paquet d'origine.

Quatre mois pour un correctif

Bloom a signalé la faille via le programme HackerOne de GitHub le 6 juin. GitHub l'a validée le 9 juin. La dernière réponse sur le fond date du 8 juillet : l'équipe « travaillait à corriger le problème et à en déterminer le risque et la gravité ». Six relances et une demande de médiation auprès de HackerOne sont restées sans réponse.

Le changelog de GitHub du 2 octobre contient le correctif. Une liaison de publication de confiance qui n'a rien publié dans les 48 heures suivant sa création expire désormais et ne peut plus autoriser de publication. Après la première publication réussie, la liaison est attachée à l'identité numérique du dépôt. Changer de dépôt exige une nouvelle liaison, avec une nouvelle fenêtre de 48 heures. npm rejette aussi désormais les jetons émis par des workflows issue_comment, le déclencheur de l'attaque d'août.

Le changelog ne dit pas ce que deviennent les liaisons créées avant le 2 octobre. Bloom conseille aux mainteneurs de lancer npm trust list sur chaque paquet, de vérifier que chaque slug pointe toujours vers un dépôt qu'ils contrôlent, et de recréer la liaison après tout renommage ou transfert.

Si vous installez des paquets au lieu d'en publier, ce conseil ne vous sert à rien. La liaison de confiance d'un paquet est invisible depuis npm install. Les 1 500 noms ne sont pas publiés. Une version malveillante issue d'un slug récupéré ressemble à n'importe quelle autre version, provenance comprise. La phrase de Bloom sur le cas des agents : « Il suffit que ce soit le paquet qu'un agent choisit. »

La même installation, depuis une machine Bromure

Dans Bromure Agentic Coding, l'agent tourne dans une VM Linux sur votre Mac, et chaque requête de la VM, registry.npmjs.org compris, traverse un proxy sur l'hôte. La politique de chaîne d'approvisionnement s'exécute dans ce proxy, hors de la VM, si bien que rien de ce que font l'agent ou un paquet dans la machine ne peut la modifier. Suivons la version SlugFest jusque dans une machine Bromure.

Une version aussi jeune n'est pas listée

Le délai d'ancienneté est actif par défaut dans chaque nouvelle machine, avec un minimum de deux jours. Quand npm demande @acme/sdk au registre, le proxy retire de la liste toutes les versions publiées depuis moins de deux jours et déplace latest vers la version la plus récente qui soit assez ancienne.

L'agent lance npm install @acme/sdk, la même commande qu'il lancerait n'importe où, et obtient la dernière vraie version du mainteneur. La version de l'attaquant ne figure pas dans la liste que voit l'agent. S'il l'épingle par son numéro parce qu'une page web ou une issue le lui a dit, la requête du tarball reçoit un HTTP 451, et npm en affiche la raison dans le terminal de l'agent :

Bromure Supply-Chain Security blocked this request: npm package @acme/[email protected]
published 3 hours ago — policy requires 2 days minimum

Le délai ne s'appuie que sur la date de publication : pas besoin de la liste de Bloom ni d'une signature de malware. Un attaquant qui récupère un slug doit publier une nouvelle version, et une nouvelle version est une version jeune. Pendant que l'agent installe la version précédente, le mainteneur, npm et les scanners ont deux jours pour remarquer une version que personne dans l'équipe n'a publiée. Un mainteneur dont la liaison n'a jamais servi a de bonnes raisons de regarder quand elle publie pour la première fois.

D'autres contrôles après deux jours

Activez d'autres couches dans le panneau Supply Chain de la machine, et la version les rencontre une fois le délai passé. Avec socket.dev sélectionné, chaque tarball est vérifié avant d'être servi, et les paquets signalés comme malveillants reçoivent un 451. Avec le contrôle OSV activé, une version qui fait l'objet d'un avis publié est bloquée au niveau de gravité choisi. Avec Strip preinstall / install / postinstall / prepare from npm tarballs on the fly, le proxy retire les hooks d'installation du tarball avant que npm ne le voie, ce qui enlève le mode de livraison sur lequel compte la plupart des malwares npm.

La charge ne trouve que des leurres

Admettons qu'une version passe quand même et s'exécute. Elle s'exécute dans la VM, et les identifiants qui s'y trouvent sont des leurres. GH_TOKEN, ~/.git-credentials, la clé Anthropic, les clés AWS : chacun est une chaîne qui a la forme du vrai, dérivée de la vraie valeur et d'un sel propre à l'installation, et inutilisable ailleurs qu'à travers le proxy de votre Mac, pour des requêtes vers l'hôte auquel il appartient. Une charge qui les envoie à son propre serveur envoie des chaînes que tout service refuse.

Cette requête traverse elle aussi le proxy, qui inspecte chaque requête sortante à la recherche de leurres envoyés au mauvais hôte. Un leurre ghp_ en route vers un serveur de collecte reçoit un 451, la VM se met en pause, et This environment may have been compromised s'ouvre sur le Mac, avec le nom de l'identifiant, l'hôte auquel il appartient et l'hôte vers lequel il a été envoyé.

Votre renommage attend un clic

SlugFest commence par un renommage, un transfert ou un dépôt supprimé, et les agents font aussi ce genre de ménage. Dans une machine Bromure, GitHub est par défaut sous Prompt before write. Renommer un dépôt est un PATCH sur l'API GitHub, et en supprimer un est un DELETE. L'un comme l'autre s'arrête sur une boîte de dialogue macOS, Allow write on "github.com" from workspace "…"?, qui montre la méthode et le chemin tels qu'ils sont passés sur le réseau. Quand un agent range un projet en renommant acme/sdk en acme/sdk-v2, vous le voyez avant que l'ancien nom ne se libère, et vous savez quelle liaison npm recréer.

Quand la liste sortira

Bloom publiera la liste des paquets concernés un jour ou l'autre. Quelqu'un demandera alors laquelle de vos machines en a installé un, et quand. La Security Timeline enregistre chaque récupération de paquet depuis chaque machine, quelle que soit la politique : npm · @acme/[email protected], la machine, l'heure, et si le paquet a été téléchargé, retenu par le délai d'ancienneté ou bloqué. Filtrez sur le nom du paquet et exportez le résultat en CSV. Le journal est sur le Mac, hors de portée de l'agent.

Dans Bromure : pour l'agent, la nouvelle version n'existe pas encoreVM de la machine (Ubuntu)$ npm install @acme/sdk+ @acme/[email protected]$ npm i @acme/[email protected]451 published 3 hours agoGH_TOKEN=ghp_… (leurre)ANTHROPIC_API_KEY=sk-ant-api03-brm-…aucun vrai secret à prendreProxy, sur le MacDélai d'ancienneté (2 jours) : versionsplus jeunes retirées, latest → 4.1.3En option : socket.dev, OSV,retrait des scripts d'installationLeurre vers un autre hôte → 451, VM en pausePATCH /repos/acme/sdk (renommage)→ dialogue Prompt before writela politique vit sur l'hôte, hors de portée de l'agentRegistre npm4.2.0 il y a 3 hdepuis le slug récupéré4.1.3 il y a 5 moisversion du mainteneurdeux jours pour quemainteneur, npm etscanners le voientSecurity Timeline, sur le Macnpm · @acme/[email protected] · téléchargé · machine, heure · filtre par paquet, export CSV quand la liste sortira
La même installation depuis une machine Bromure. La version fraîche manque à la liste que voit l'agent, une requête épinglée reçoit un 451, une charge qui s'exécute malgré tout ne trouve que des leurres, et chaque récupération est consignée.

La nouvelle version n'est pas listée

Le délai d'ancienneté, actif par défaut à deux jours, retire les versions fraîches de la liste du registre et ramène latest en arrière. L'agent installe la dernière vraie version du mainteneur sans savoir qu'il s'est passé quoi que ce soit.

Les requêtes épinglées reçoivent un 451

Si l'agent demande la nouvelle version par son numéro, le téléchargement est refusé et npm affiche l'âge du paquet et la politique dans le terminal de l'agent.

La charge ne trouve que des leurres

Tout ce qui s'exécute dans la VM trouve des jetons leurres. Envoyé au mauvais hôte, l'un d'eux reçoit un 451, la VM se met en pause et le Mac affiche une alerte.

Renommages et installations sont consignés

Prompt before write retient le renommage de dépôt qui libère un slug. La Security Timeline consigne chaque récupération de paquet, par machine et par version.

Le prochain nom

Le correctif de GitHub attache une liaison à l'identité numérique du dépôt après sa première publication et fait expirer les liaisons qui ne publient jamais, ce qui referme cette brèche. La publication de confiance protège l'identifiant de publication et vous dit quel pipeline a construit la version. En août, le pipeline était celui de la victime, déclenché par le commentaire d'un inconnu. Cette fois, c'est le dépôt d'un inconnu qui répondait au bon nom. Les deux versions portaient une provenance.

Vos agents installent ce que le résolveur leur renvoie, 70 paquets par mois selon le décompte de Bloom. Faites-les tourner dans une machine Bromure, où une version de deux jours n'existe pas encore et où une charge qui s'exécute malgré tout ne trouve que des leurres. Pour en savoir plus sur le délai d'ancienneté, lisez N'importe qui pouvait publier ce paquet et Révoquer le jeton, c'était le déclencheur.