El nombre antiguo todavía podía publicar
El 13 de octubre, Bloom Security reveló SlugFest: la publicación de confianza de npm identificaba un repositorio de GitHub por su cadena propietario/nombre, así que cualquiera que se quedara con el nombre antiguo de un repositorio renombrado o borrado podía publicar versiones nuevas de paquetes que no eran suyos, sin robar ninguna credencial. Bloom encontró más de 1.500 paquetes expuestos con más de 10 millones de descargas, y señala a los agentes de IA como los instaladores que recogen una versión nueva sin que nadie mire. En Bromure Agentic Coding, una versión tan reciente no existe para el agente, la instalación corre en una VM que solo guarda sustitutos, y el cambio de nombre que abre el agujero espera un clic.
La publicación de confianza de npm recordaba el nombre del repositorio y olvidaba qué repositorio era. Cuando el mantenedor lo renombró, la siguiente persona que creó un repositorio con ese nombre pudo publicar.
El lunes 13 de octubre, Golan Myers, de Bloom Security, publicó SlugFest, un fallo en la publicación de confianza (trusted publishing) de npm. The Hacker News lo cubrió ese mismo día. Myers lo explica con una cerradura: «Es como una cerradura que comprueba la dirección de la puerta en lugar de quién es el dueño de la casa; cuando el dueño se muda, quien se instala recibe la llave».
Bloom contó más de 1.500 paquetes expuestos, descargados más de 10 millones de veces en total y más de un millón de veces al mes. El ataque no necesita «ni conocimiento interno, ni robo de credenciales, ni condición de carrera». Bloom no ha nombrado los paquetes: «No publicamos una lista de los paquetes afectados porque el problema sigue estando muy extendido».
Bloom también dice quién instala hoy estos paquetes. Según su recuento, un agente de programación instala de media 70 paquetes al mes, y una máquina de desarrollo unos 350. En palabras de Bloom, una versión maliciosa publicada a través de un nombre recuperado «se descarga y se ejecuta sin ningún humano en el circuito».
Cómo comprobaba el repositorio la publicación de confianza
npm abrió la publicación de confianza a todo el mundo en julio de 2025. En
lugar de guardar un token de npm en la CI, el mantenedor ejecuta npm trust github --allow-publish y le dice a npm qué repositorio de GitHub y qué
archivo de workflow pueden publicar el paquete. Al publicar, GitHub Actions
emite un token OIDC de corta duración que describe la ejecución del
workflow, npm lo compara con el vínculo y devuelve una credencial de
publicación. No existe ningún secreto de larga duración que robar.
El vínculo guardaba el repositorio como un slug, owner/repo, más el nombre
del archivo de workflow. No guardaba los repository_id y
repository_owner_id numéricos que GitHub asigna de por vida. npm solo los
registraba tras la primera publicación correcta. Hasta entonces, cualquier
repositorio que respondiera al nombre correcto y ejecutara el archivo de
workflow correcto coincidía.
En GitHub, los nombres quedan libres. Un mantenedor renombra un repositorio, lo transfiere a una organización, lo borra o cambia el nombre de su cuenta. El slug antiguo queda disponible y otra persona puede registrarlo. Los pasos de Bloom:
- Crear un repositorio en el slug liberado. GitHub le asigna un ID numérico nuevo.
- Añadir un workflow con el nombre de archivo del vínculo y el permiso
id-token: write, y ejecutarlo. - GitHub emite un token OIDC cuyo claim de repositorio es el slug antiguo.
- El endpoint de intercambio de npm hace coincidir slug y nombre de archivo y emite una credencial de publicación. Los IDs numéricos están en el token, pero npm no tenía nada con qué compararlos.
- Publicar una versión nueva de un paquete que no es tuyo, con su nombre, su número de descargas y todos los proyectos que dependen de él.
Bloom recorrió la cadena de principio a fin contra un scope de npm y unos repositorios que controla, publicando desde una segunda organización en la que solo coincidía el slug. La publicación de confianza de GitLab tenía el mismo hueco.
La versión resultante, además, va firmada. La publicación de confianza desde un repositorio público genera una atestación de procedencia, y esta nombraría la ejecución del workflow en el repositorio del atacante bajo el slug que configuró el mantenedor. En agosto, un comentario de dos palabras en una pull request consiguió que el propio pipeline de publicación de un proyecto sacara diez versiones envenenadas con procedencia válida. SlugFest ni siquiera necesita el pipeline de la víctima.
Cuatro meses hasta el arreglo
Bloom informó del fallo a través del programa de HackerOne de GitHub el 6 de junio. GitHub lo validó el 9 de junio. La última respuesta de fondo llegó el 8 de julio: el equipo estaba «trabajando en corregir el problema y en determinar su riesgo y gravedad». Seis seguimientos y una solicitud de mediación a HackerOne quedaron sin respuesta.
El changelog de GitHub del 2 de
octubre
trae el arreglo. Un vínculo de publicación de confianza que no ha publicado
en las 48 horas siguientes a su creación ahora caduca y ya no puede
autorizar una publicación. Tras la primera publicación correcta, el vínculo
queda atado a la identidad numérica del repositorio. Cambiar de repositorio
exige un vínculo nuevo con una ventana nueva de 48 horas. npm también
rechaza ahora los tokens emitidos por workflows issue_comment, el
disparador del ataque de agosto.
El changelog no dice qué pasa con los vínculos creados antes del 2 de
octubre. El consejo de Bloom a los mantenedores es ejecutar npm trust list
en cada paquete, comprobar que cada slug sigue apuntando a un repositorio
que controlan y recrear el vínculo tras cualquier cambio de nombre o
transferencia.
Si instalas paquetes en lugar de publicarlos, ese consejo no te sirve de
nada. El vínculo de confianza de un paquete no se ve desde npm install.
Los 1.500 nombres no se han publicado. Una versión maliciosa salida de un
slug recuperado se parece a cualquier otra versión, procedencia incluida.
La frase de Bloom sobre el caso de los agentes: «Solo tiene que ser el
paquete que elige un agente».
La misma instalación, desde una máquina Bromure
En Bromure Agentic Coding, el agente corre en una VM Linux en tu Mac, y cada
petición que hace la VM, registry.npmjs.org incluido, cruza un proxy en el
host. La política de la cadena de suministro se aplica en ese proxy, fuera
de la VM, así que nada de lo que hagan el agente o un paquete dentro de la
máquina puede cambiarla. Sigamos la versión de SlugFest hasta una máquina
Bromure.
Una versión tan reciente no aparece
El filtro de antigüedad viene activado por defecto en cada máquina nueva,
con un mínimo de dos días. Cuando npm pide @acme/sdk al registro, el proxy
quita del listado todas las versiones publicadas en los últimos dos días y
mueve latest a la versión más reciente que tenga antigüedad suficiente.
El agente ejecuta npm install @acme/sdk, el mismo comando que ejecutaría
en cualquier sitio, y recibe la última versión real del mantenedor. La
versión del atacante no está en el listado que ve el agente. Si el agente la
fija por número porque se lo dijo una página web o una issue, la petición
del tarball recibe un HTTP 451, y npm muestra el motivo en el terminal del
agente:
Bromure Supply-Chain Security blocked this request: npm package @acme/[email protected]
published 3 hours ago — policy requires 2 days minimum
El filtro funciona solo con la fecha de publicación, así que no necesitas la lista de Bloom ni una firma de malware. Un atacante que recupera un slug tiene que publicar una versión nueva, y una versión nueva es una versión joven. Mientras el agente instala la versión anterior, el mantenedor, npm y los escáneres tienen dos días para darse cuenta de una versión que nadie del equipo publicó. Un mantenedor cuyo vínculo nunca se ha usado tiene buenos motivos para mirar cuando publique por primera vez.
Más comprobaciones después de dos días
Activa más capas en el panel Supply Chain de la máquina y la versión se topará con ellas cuando supere el filtro. Con socket.dev seleccionado, cada tarball se revisa antes de servirse, y los paquetes marcados como malware reciben un 451. Con la comprobación de OSV activada, una versión con un aviso publicado se bloquea a partir de la gravedad que elijas. Con Strip preinstall / install / postinstall / prepare from npm tarballs on the fly, el proxy quita los hooks de instalación del tarball antes de que npm lo vea, lo que elimina la vía de entrega de la que depende la mayor parte del malware de npm.
La carga encuentra sustitutos
Supongamos que una versión entra de todos modos y se ejecuta. Se ejecuta
dentro de la VM, y las credenciales que hay allí son sustitutos.
GH_TOKEN, ~/.git-credentials, la clave de Anthropic, las claves de AWS:
cada una es una cadena con la forma de la real, derivada del valor real y
de una sal propia de cada instalación, e inútil salvo a través del proxy de
tu Mac, para peticiones al host al que pertenece. Una carga que las envía a
su propio servidor envía cadenas que fallan la autenticación en todas
partes.
Esa petición también cruza el proxy, que examina cada petición saliente en
busca de sustitutos dirigidos al host equivocado. Un sustituto ghp_ de
camino a un servidor de recolección recibe un 451, la VM se pausa y en el
Mac se abre This environment may have been compromised, con el nombre
de la credencial, el host al que pertenece y el host al que se envió.
Tu cambio de nombre espera un clic
SlugFest empieza con un cambio de nombre, una transferencia o un
repositorio borrado, y los agentes también hacen esas tareas de limpieza. En
una máquina Bromure, GitHub está por defecto bajo Prompt before write.
Renombrar un repositorio es un PATCH a la API de GitHub, y borrarlo es un
DELETE. Cualquiera de los dos se detiene en un diálogo de macOS, Allow
write on "github.com" from workspace "…"?, que muestra el método y la
ruta tal como pasaron por la red. Cuando un agente ordena un proyecto
renombrando acme/sdk a acme/sdk-v2, lo ves antes de que el nombre
antiguo quede libre, y sabes qué vínculo de npm tienes que recrear.
Cuando salga la lista
Bloom publicará en algún momento los paquetes afectados. Entonces alguien
preguntará cuál de tus máquinas instaló alguno de ellos, y cuándo. La
Security Timeline registra cada descarga de paquete de cada máquina,
sea cual sea la política: npm · @acme/[email protected], la máquina, la hora y si
se descargó, lo retuvo el filtro de antigüedad o se bloqueó. Filtra por el
nombre del paquete y exporta el resultado como CSV. El registro está en el
Mac, donde el agente no puede editarlo.
La versión nueva no aparece
El filtro de antigüedad, activado por defecto con dos días, quita las
versiones recientes del listado del registro y devuelve latest atrás.
El agente instala la última versión real del mantenedor sin saber que
ha pasado nada.
Las peticiones fijadas reciben un 451
Si el agente pide la versión nueva por número, la descarga se rechaza y npm muestra la antigüedad del paquete y la política en el terminal del agente.
La carga encuentra sustitutos
Todo lo que se ejecuta en la VM encuentra tokens sustitutos. Si uno se envía al host equivocado, recibe un 451, la VM se pausa y el Mac muestra una alerta.
Cambios de nombre e instalaciones quedan registrados
Prompt before write retiene el cambio de nombre que libera un slug. La Security Timeline registra cada descarga de paquete, por máquina y versión.
El próximo nombre
El arreglo de GitHub ata un vínculo a la identidad numérica del repositorio tras su primera publicación y hace caducar los vínculos que nunca publican, lo que cierra este hueco. La publicación de confianza protege la credencial de publicación y te dice qué pipeline construyó la versión. En agosto, el pipeline era el de la propia víctima, disparado por el comentario de un desconocido. Esta vez, el repositorio de un desconocido respondía al nombre correcto. Las dos versiones llevaban procedencia.
Tus agentes instalan lo que devuelva el resolutor, 70 paquetes al mes según el recuento de Bloom. Ejecútalos en una máquina Bromure, donde una versión de dos días todavía no existe y una carga que sí se ejecuta solo encuentra sustitutos. Para saber más del filtro de antigüedad, lee Cualquiera podía publicar ese paquete y Revocar el token era el detonante.