npm y la cadena de suministro: el gusano que se coló por el trusted publishing
Tabla de Contenidos
Durante un año, el consejo de seguridad de npm fue uno solo y todo el mundo lo repitió: deja de publicar con tokens de larga vida y pásate al trusted publishing con OIDC. Era buen consejo. Sigue siéndolo. Y el 11 de mayo de 2026, el gusano Shai-Hulud publicó decenas de paquetes oficiales de @tanstack/* entrando exactamente por ahí — no falsificando la procedencia, sino generándola de verdad desde el workflow legítimo del proyecto.
Ese matiz es el artículo entero. Un paquete malicioso con una firma Sigstore auténtica, emitida por Fulcio y anotada en Rekor, con el contexto del repositorio real detrás. Todos los indicadores que te habían enseñado a mirar decían «bien».
Esto no significa que el trusted publishing sea inútil ni que haya que volver a los tokens. Significa algo más incómodo: es una capa, no un perímetro, y llevamos un año vendiéndolo como si fuera lo segundo. Aquí va qué protege de verdad, qué deja fuera, y las cuatro medidas que sí habrían frenado los ataques de los últimos doce meses.
La cronología, con fechas
Conviene tenerla delante porque el ritmo importa más que cualquier incidente suelto:
| Fecha | Qué pasó |
|---|---|
| jul 2025 | Trusted publishing con OIDC pasa a disponibilidad general en npm |
| 26 ago 2025 | Ataque S1ngularity |
| 8 sep 2025 | Phishing contra debug, chalk y 16 utilidades más — vivas dos horas |
| 15 sep 2025 | Shai-Hulud oleada 1: más de 700 paquetes comprometidos |
| 23 nov 2025 | Shai-Hulud 2.0 |
| 9 dic 2025 | npm revoca permanentemente todos los tokens clásicos |
| feb 2026 | npm CLI 11.10.0 estrena min-release-age y configuración OIDC en bloque |
| 30 mar 2026 | [email protected] comprometido — 100 M de descargas semanales |
| 11 may 2026 | Shai-Hulud contra TanStack, entrando por el propio trusted publishing |
| 14 may 2026 | node-ipc: tres versiones maliciosas con un robacredenciales de 80 KB |
| 1 jun 2026 | 32 paquetes de @redhat-cloud-services, carga «Miasma» |
| ago 2026 | Nueva oleada: keyv y más de 400 paquetes |
Diez meses, ocho campañas. Esto ya no es una serie de incidentes: es el estado normal del registro.
Cómo entró por la puerta buena
Vale la pena entender el ataque de TanStack paso a paso, porque cada eslabón desmonta una suposición distinta. La reconstrucción de Upwind lo detalla así:
- Una dependencia «dropper» entra en el
package.jsony se ejecuta durante la instalación, vía los lifecycle hooks de npm. Nada exótico: es la funcionalidad documentada. - El dropper descarga y ejecuta el runtime de Bun. Con eso no depende del Node instalado y esquiva la monitorización que vigila ejecuciones de Node.
- Bajo Bun corre una carga de 2,3 MB muy ofuscada (
router_init.js) que se demoniza y sigue viva después de que la instalación termine. - Barrido de credenciales: sistema de archivos, APIs de nube, y —esta es la buena— memoria del runner de GitHub Actions leyendo
/proc/*/mem. Objetivo: claves de AWS, tokens de npm, cuentas de servicio de Kubernetes, configuraciones de asistentes de IA. - Abuso de OIDC: en vez de necesitar un token de npm guardado en algún sitio, canjea el token OIDC de GitHub Actions por acceso de publicación dinámico. El permiso
id-token: writedel workflow es la llave. - Propagación: con ese acceso descarga paquetes objetivo, se inyecta, sube la versión exactamente en +3 y republica.
- Y por el camino, acuña un bundle Sigstore a través de Fulcio y Rekor — con lo cual el tarball malicioso sale con procedencia generada desde el contexto del workflow de confianza.
El punto 7 es el que hay que interiorizar. No hubo falsificación. La cadena de confianza funcionó exactamente como está diseñada; lo que estaba comprometido era el eslabón anterior, el runner de CI. La procedencia responde a «¿esto se construyó donde dice?», y la respuesta era sí. No responde a «¿lo que se construyó es lo que el mantenedor escribió?», que es la pregunta que importaba.
Los indicadores publicados, por si tienes que barrer: workflows que hablen con api.masscan[.]cloud o 83.142.209.194, y artefactos de persistencia llamados router_init.js, pgmonitor.py o pgsql-monitor.service.
Entonces, ¿sirve el trusted publishing?
Sí, y mucho. Pero conviene tener claro el reparto, porque el consejo genérico no lo hace:
| Amenaza | ¿Lo para OIDC? |
|---|---|
| Token de npm robado de un portátil | ✅ Sí — no hay token que robar |
Token filtrado en un log de CI o en un .env | ✅ Sí |
Phishing al mantenedor (el caso debug/chalk) | ✅ En gran medida |
| Token válido meses después de que el mantenedor se vaya | ✅ Sí |
| Runner de CI comprometido durante el build | ⛔ No |
Dependencia maliciosa que se ejecuta en postinstall | ⛔ No |
| Envenenamiento de la caché de CI | ⛔ No |
| Cuenta de GitHub del mantenedor comprometida | ⛔ No |
La lectura correcta: OIDC elimina el secreto de larga vida, que era el vector dominante hasta 2025. No defiende el proceso que emite la publicación. Y a medida que el primer vector se cierra, los ataques se mueven al segundo. Eso es exactamente lo que muestra la cronología de arriba.
Un dato para calibrar cuánta gente sigue en la primera casilla: según el análisis de Aikido, de los 51.370 paquetes más descargados solo 11.001 usan trusted publishing — un 21,4 %, que cubre apenas el 25 % del volumen de descargas. Tres cuartas partes de lo que instalas cada día sigue publicándose con tokens. Y la adopción se mueve a golpe de incidente: de una base de ~35 paquetes nuevos por semana se pasó a 372 la semana de Shai-Hulud 2.0, y luego la urgencia se desinfla otra vez.
Así que el mensaje no es «el trusted publishing falló». Es «migra igualmente, y además haz estas cuatro cosas».
Las cuatro medidas que sí habrían parado esto
1. Enfriamiento de dependencias: no instales lo que se publicó esta mañana
Esta es, con diferencia, la de mejor relación impacto/esfuerzo, y sigue siendo la menos usada.
La idea es de una simplicidad ofensiva: decirle al gestor de paquetes que ignore las versiones que llevan menos de N días publicadas. Casi todas estas campañas se detectan y se retiran en horas — las versiones de debug y chalk estuvieron vivas dos horas —, así que un retardo modesto te saca por completo de la ventana de exposición. De diez ataques analizados, ocho tuvieron ventanas de menos de una semana.
Ya está soportado de forma nativa en los cuatro gestores, con la trampa de que las unidades no coinciden:
| Gestor | Clave | Unidad | Desde |
|---|---|---|---|
| npm | min-release-age | días | CLI 11.10.0 (feb 2026) |
| pnpm | minimumReleaseAge | minutos | 10.16 (sep 2025) |
| Yarn | npmMinimalAgeGate | minutos | Berry 4.10.0 |
| Bun | minimumReleaseAge | minutos | — |
En npm, un .npmrc con una línea:
min-release-age=7
En pnpm, dentro de pnpm-workspace.yaml, con lista de excepciones para lo que publiques tú:
minimumReleaseAge: 10080 # 7 días en minutos
minimumReleaseAgeExclude:
- '@mi-empresa/*'
Dos avisos prácticos. El primero: el enfriamiento se aplica en la instalación, no al sugerir actualizaciones, así que tu bot de dependencias puede abrir el PR y el npm ci fallar después; conviene configurar el mismo umbral en los dos sitios. El segundo: en Yarn hay un fallo conocido parseando duraciones tipo "7d" — pon el número de minutos y ya.
2. Separa el runner que instala del runner que publica
Esta es la que habría cortado el ataque de TanStack en seco.
El problema estructural es que en la mayoría de los workflows el mismo job hace npm ci y npm publish. Eso significa que el código de terceros que se ejecuta durante la instalación vive en el mismo proceso que tiene —o puede pedir— el token OIDC.
La forma correcta es partirlo, y que id-token: write exista solo en el job que publica, que no instala nada de terceros:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # sin id-token aquí
steps:
- uses: actions/checkout@v5
- run: npm ci --ignore-scripts
- run: npm run build
- run: npm pack
- uses: actions/upload-artifact@v4
with: { name: tarball, path: '*.tgz' }
publish:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # el permiso vive solo aquí
steps:
- uses: actions/download-artifact@v4
with: { name: tarball }
- run: npm publish *.tgz --provenance
Fíjate en el --ignore-scripts del paso de instalación. Los lifecycle hooks son el punto 1 y el punto 2 del ataque; si tu build no los necesita —y muchos no— desactivarlos en CI es gratis. Si alguna dependencia sí los necesita, la lista de excepciones es más corta de lo que crees y merece la pena escribirla a mano.
3. Trata la caché de CI como entrada no confiable
El vector inicial de la oleada de mayo fue envenenamiento de caché. Es un vector poco intuitivo porque la caché parece infraestructura tuya, y no lo es: en GitHub Actions, una rama con menos privilegios puede escribir en una caché que luego lee la rama de release.
Lo mínimo:
- Nunca uses
pull_request_targetsin un límite de confianza explícito. Ese disparador ejecuta con los secretos del repositorio base y con el código del PR delante; es el pie de la puerta. - Claves de caché segregadas por rama o por propósito. Que el workflow de release no comparta clave con el de PRs.
- En el job de publicación, no restaures caché en absoluto. Ese job tiene que ser lo más aburrido posible: descarga un artefacto, lo sube. Nada más.
4. Deja de leer la insignia de procedencia como si fuera un veredicto
La procedencia sigue siendo útil: te dice dónde se construyó el paquete. Después de mayo, lo que no puedes hacer es leerla como «esto es seguro».
En la práctica esto significa que la revisión de dependencias no se delega a un badge. Lo que sí funciona:
- Fija con
package-lock.jsony usa siemprenpm cien CI, nuncanpm install. Es la diferencia entre reproducir un árbol y resolver uno nuevo cada vez. - Vigila los saltos de versión raros. El gusano incrementaba la versión exactamente en +3. Un patch que salta tres números sin changelog es una señal barata y sorprendentemente buena.
- Rota credenciales cuando te toque una oleada, y rota todo: npm, GitHub, secretos de Actions, AWS, Vault, Kubernetes, SSH. Las cargas de estas campañas barren el runner entero, no solo el token de publicar.
Y lo que npm cambió por su cuenta
Parte del trabajo ya te lo hicieron, y conviene saberlo porque afecta a scripts que quizá tengas guardados:
- Los tokens clásicos están revocados desde el 9 de diciembre de 2025. No deprecados: revocados. Si un pipeline viejo tuyo dejó de publicar en esa fecha, esta es la razón.
npm loginya no te da un token de larga vida, sino una sesión de dos horas que caduca sola y exige 2FA para publicar.- Los tokens granulares de escritura caducan como máximo a los 90 días. Ya no existe el token eterno, ni pidiéndolo.
- Y desde npm CLI 11.10.0 puedes configurar OIDC en bloque para muchos paquetes a la vez, que era la fricción real de migrar una organización con cincuenta paquetes.
Si publicas paquetes, la migración a trusted publishing son literalmente cuatro líneas de YAML —el id-token: write y el --provenance del ejemplo de arriba— más marcar el publicador de confianza en la página del paquete. Hazlo. Pero hazlo sabiendo que acabas de cerrar el vector de 2025, no el de 2026.
Lo que yo haría este mes, en orden
Si tienes media hora, en este orden por rendimiento decreciente:
- Pon
min-release-age=7(o el equivalente de tu gestor) en cada repositorio. Cinco minutos, corta la mayoría de las campañas conocidas. - Cambia
npm installpornpm ci --ignore-scriptsen todos tus workflows de CI que no necesiten hooks. - Si publicas paquetes, parte el workflow en build y publish, con
id-token: writesolo en el segundo. - Migra a trusted publishing lo que aún publique con token granular.
- Audita los
pull_request_targetque tengas y las claves de caché compartidas entre ramas.
Ninguna de las cinco es cara. Y la incómoda de aceptar: la primera, que es la más eficaz de todas, no requiere confiar en nadie ni entender ningún ataque. Solo esperar una semana.
Para seguir:
- Vibe coding seguro: evitar vulnerabilidades en el código de IA — el otro frente por el que entra código que nadie revisó.
- Guía completa de Docker en 2026 — aislar el build es la mitad de la defensa de este artículo.
- Herramientas para auditar la seguridad de un dominio — el equivalente de esta revisión, pero hacia fuera.
Preguntas frecuentes
¿El trusted publishing con OIDC sigue mereciendo la pena después del ataque de mayo de 2026?
Sí, y hay que migrar igualmente. OIDC elimina por completo el token de larga vida, que fue el vector dominante hasta 2025: no hay nada que robar de un portátil, de un log de CI ni de un mantenedor mediante phishing. Lo que no cubre es el proceso que emite la publicación: si el runner de CI está comprometido durante el build, el atacante canjea el token OIDC igual que lo haría el workflow legítimo. Es una capa, no un perímetro.
¿Cómo pudo el gusano Shai-Hulud generar procedencia Sigstore válida?
Porque no la falsificó: la generó de verdad. La carga maliciosa se ejecutaba dentro del propio runner de GitHub Actions del proyecto, pidió un token OIDC con el permiso id-token: write del workflow, lo canjeó por acceso de publicación en npm, y acuñó un bundle Sigstore a través de Fulcio y Rekor desde ese contexto de confianza. La procedencia responde a «¿esto se construyó donde dice?», y la respuesta era sí. No responde a «¿lo construido es lo que escribió el mantenedor?».
¿Qué es el enfriamiento de dependencias y cómo se configura?
Es decirle al gestor de paquetes que ignore las versiones publicadas hace menos de N días, para no instalar nunca una versión maliciosa dentro de su ventana de vida (que suele ser de horas). En npm es min-release-age en el .npmrc y se mide en días (desde el CLI 11.10.0, febrero de 2026); en pnpm es minimumReleaseAge y se mide en minutos (desde 10.16); en Yarn es npmMinimalAgeGate en minutos y en Bun minimumReleaseAge. Un umbral de siete días habría bloqueado ocho de los diez últimos ataques analizados.
¿Por qué hay que separar el job que instala del que publica en CI?
Porque si el mismo job hace npm ci y npm publish, el código de terceros que se ejecuta durante la instalación vive en el mismo proceso que tiene acceso al token OIDC. Así entró el ataque contra TanStack: una dependencia maliciosa se ejecutó en un lifecycle hook, leyó la memoria del runner y canjeó el token. La solución es que id-token: write exista solo en un job de publicación que no instale nada de terceros y se limite a subir un artefacto ya construido.
¿Qué porcentaje de paquetes de npm usa ya trusted publishing?
Poco: de los 51.370 paquetes más descargados, 11.001 lo usan — un 21,4 %, que cubre alrededor del 25 % del volumen total de descargas. Es decir, tres cuartas partes de lo que se instala a diario sigue publicándose con tokens. La adopción sube a golpe de incidente (372 paquetes nuevos la semana de Shai-Hulud 2.0, frente a una base de unos 35 semanales) y vuelve a caer cuando pasa la urgencia.
¿Siguen funcionando los tokens clásicos de npm?
No. npm revocó permanentemente todos los tokens clásicos el 9 de diciembre de 2025. Desde entonces npm login entrega una sesión de dos horas en vez de un token de larga vida, y los tokens granulares de escritura caducan como máximo a los 90 días. Si un pipeline antiguo dejó de publicar en esas fechas, la causa es esa.