8 de septiembre de 2026

npm y la cadena de suministro: el gusano que se coló por el trusted publishing

Foto de Marco Orta Marco Orta | 14 min de lectura
Compartir
Portada tipográfica sobre fondo de terminal: la palabra npm y debajo un diff del .npmrc, «- min-release-age unset» en rojo y «+ min-release-age=7» en verde
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:

    FechaQué pasó
    jul 2025Trusted publishing con OIDC pasa a disponibilidad general en npm
    26 ago 2025Ataque S1ngularity
    8 sep 2025Phishing contra debug, chalk y 16 utilidades más — vivas dos horas
    15 sep 2025Shai-Hulud oleada 1: más de 700 paquetes comprometidos
    23 nov 2025Shai-Hulud 2.0
    9 dic 2025npm revoca permanentemente todos los tokens clásicos
    feb 2026npm 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 2026Shai-Hulud contra TanStack, entrando por el propio trusted publishing
    14 may 2026node-ipc: tres versiones maliciosas con un robacredenciales de 80 KB
    1 jun 202632 paquetes de @redhat-cloud-services, carga «Miasma»
    ago 2026Nueva 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í:

    1. Una dependencia «dropper» entra en el package.json y se ejecuta durante la instalación, vía los lifecycle hooks de npm. Nada exótico: es la funcionalidad documentada.
    2. 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.
    3. 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.
    4. 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.
    5. 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: write del workflow es la llave.
    6. Propagación: con ese acceso descarga paquetes objetivo, se inyecta, sube la versión exactamente en +3 y republica.
    7. 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 buildNo
    Dependencia maliciosa que se ejecuta en postinstallNo
    Envenenamiento de la caché de CINo
    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:

    GestorClaveUnidadDesde
    npmmin-release-agedíasCLI 11.10.0 (feb 2026)
    pnpmminimumReleaseAgeminutos10.16 (sep 2025)
    YarnnpmMinimalAgeGateminutosBerry 4.10.0
    BunminimumReleaseAgeminutos

    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_target sin 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.json y usa siempre npm ci en CI, nunca npm 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 login ya 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:

    1. 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.
    2. Cambia npm install por npm ci --ignore-scripts en todos tus workflows de CI que no necesiten hooks.
    3. Si publicas paquetes, parte el workflow en build y publish, con id-token: write solo en el segundo.
    4. Migra a trusted publishing lo que aún publique con token granular.
    5. Audita los pull_request_target que 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:

    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.

    Compartir

    Buscar

    Etiquetas

    IA PHP JavaScript Migración Laravel Tutorial Desarrollo Web Buenas Prácticas Seguridad Upgrade Laravel 13 OpenAI Backend SEO Claude