← Volver a todas las publicaciones
Publicado el · por Renaud Deraison

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:

  1. Crear un repositorio en el slug liberado. GitHub le asigna un ID numérico nuevo.
  2. Añadir un workflow con el nombre de archivo del vínculo y el permiso id-token: write, y ejecutarlo.
  3. GitHub emite un token OIDC cuyo claim de repositorio es el slug antiguo.
  4. 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.
  5. 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.

Un nombre libre, un workflow que encaja, una credencialVínculo npm trustrepo: acme/sdkworkflow: release.ymlrepo_id: (no guardado)el mantenedor renombraacme/sdk → acme/sdk-v2acme/sdk queda librepara registrarlo otra vezRepo nuevo del atacante: acme/sdkrepository_id: new.github/workflows/release.ymlpermissions: id-token: writeToken OIDC: repository = acme/sdkIntercambio npm: slug ✓ workflow ✓IDs: nada con qué compararlos→ credencial de publicación emitidaRegistro de npm@acme/[email protected]etiquetada latestcon procedenciaUn agente de código:npm install @acme/sdkresuelve latest,la ejecuta, nadie miraNombres ilustrativos. Bloom encontró más de 1.500 paquetes en este estado y no ha publicado la lista.
La cadena de SlugFest. El vínculo guarda un nombre, un cambio de nombre lo libera, y un repositorio nuevo con el nombre antiguo pasa la comprobación de npm y publica bajo el paquete original.

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.

En Bromure: para el agente, la versión nueva aún no existeVM de la máquina (Ubuntu)$ npm install @acme/sdk+ @acme/[email protected]$ npm i @acme/[email protected]451 published 3 hours agoGH_TOKEN=ghp_… (sustituto)ANTHROPIC_API_KEY=sk-ant-api03-brm-…ningún secreto real que llevarseProxy, en el MacFiltro de antigüedad (2 días): fueralas versiones más jóvenes, latest → 4.1.3Opcional: socket.dev, OSV,quitar scripts de instalaciónSustituto a otro host → 451, VM en pausaPATCH /repos/acme/sdk (renombrar)→ diálogo Prompt before writela política vive en el host, fuera del alcance del agenteRegistro de npm4.2.0 hace 3 horasdesde el slug recuperado4.1.3 hace 5 mesesla del mantenedordos días para quemantenedor, npm yescáneres lo notenSecurity Timeline, en el Macnpm · @acme/[email protected] · descargado · máquina, hora · filtra por paquete, exporta CSV cuando salga la lista
La misma instalación desde una máquina Bromure. La versión recién publicada falta en el listado que ve el agente, una petición fijada recibe un 451, una carga que sí se ejecuta solo encuentra sustitutos, y cada descarga queda registrada.

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.