7 minuto(s) de lectura

El 11 de mayo, entre las 19:20 y las 19:26 UTC, alguien publicó 42 paquetes de TanStack en npm con 84 versiones nuevas. Seis minutos de ventana. Todas con malware embebido. Todas con SLSA provenance válida.

Si trabajaste alguna vez con la cadena de supply chain moderna, ese último detalle te tiene que pegar. SLSA provenance es la capa que se inventó después de los ataques anteriores para responder una pregunta concreta: ¿este paquete que tengo en mi node_modules fue construido por el pipeline oficial del proyecto, o lo metió alguien por la ventana? La respuesta del sistema, ese día, fue: “sí, todo en orden, lo publicó el pipeline oficial”. Y era cierto. El pipeline oficial estaba comprometido.

Es el primer caso público documentado donde la atestación criptográfica que se suponía iba a darnos paz mental, en lugar de pegar el grito, confirmó que el malware se había publicado correctamente.

SLSA provenance, en treinta segundos

Por si llegaste sin contexto. SLSA (se pronuncia “salsa”, “Supply-chain Levels for Software Artifacts”) es un framework de OpenSSF, originalmente de Google, para describir niveles de integridad en la cadena de supply chain. Lo que importa acá es la parte de provenance: cada vez que se publica un paquete, el builder oficial genera una atestación firmada criptográficamente que dice “este artefacto se construyó así, con esta fuente, en este momento, por este pipeline”. La gracia es que el que descarga pueda verificar de dónde vino el paquete, no solo creer en el nombre que dice el registry.

En la práctica, npm soporta provenance de paquetes publicados vía GitHub Actions con OIDC trusted publishing. El badge verde de “verified provenance” en la página del paquete en npmjs.com es eso: el sistema confirma que el paquete vino del pipeline declarado por el repo oficial. Bien implementado, encarece bastante un ataque de supply chain. Para casos como event-stream o ua-parser-js, donde el problema era “alguien publicó algo desde afuera”, provenance habría ayudado.

El caso TanStack es la versión 2.0 del problema: el ataque entró por adentro del pipeline. La provenance hizo exactamente lo que tenía que hacer. Confirmó la verdad. La verdad era que el pipeline estaba comprometido.

Qué pasó, en corto

El vector fue una combinación poco creativa pero efectiva. Un workflow de GitHub Actions con pull_request_target mal configurado, cache poisoning de 1.1 GB, y extracción de tokens OIDC leyendo /proc/<pid>/mem del runner. Con esos tokens, los atacantes publicaron las versiones maliciosas usando el flujo oficial de OIDC trusted publishing. Por eso la provenance dio válida. Era válida.

El payload era un router_init.js de 2.3 MB ofuscado que buscaba credenciales de AWS, GCP, Kubernetes, Vault, GitHub, claves SSH, y las exfiltraba por Session messenger. Lo detectó un investigador externo de StepSecurity unos 20 minutos después de publicado, no el monitoreo interno del proyecto. Cuando TanStack intentó hacer unpublish, npm le dijo que no se podía: hay dependents, contactá soporte. Hubo que esperar a que los del registry los bajaran server-side.

Si querés el detalle técnico fino, el postmortem de TanStack está acá y StepSecurity publicó análisis del vector.

¿Otra vez sopa?

Esto no es nuevo. Es la cuarta vez que pasa lo mismo en ocho años, y cada vez fue la misma sorpresa.

2018, event-stream. Un mantenedor cansado le pasó la posta de un paquete con 2 millones de descargas semanales a un fulano que se ofreció a ayudar. Tres meses después, ese mismo tipo agregó una dependencia llamada flatmap-stream con código ofuscado que apuntaba a wallets Copay con más de 100 BTC. La descubrieron dos meses más tarde, después de unos 8 millones de descargas. Conclusión del ecosistema: hay que tener cuidado con quién toma maintainership.

2021, ua-parser-js. Cuatro horas, tres versiones, 8 millones de descargas semanales, 1.000+ paquetes dependientes. El atacante secuestró la cuenta del mantenedor y publicó versiones con XMRig (minero de Monero) más un stealer de credenciales. La fix del ecosistema fue 2FA obligatorio para mantenedores de paquetes populares. Hecho. Resuelto. Caso cerrado.

Principios de 2022, colors.js y faker.js. Variante del modelo, otro ángulo. El propio autor, Marak Squires, saboteó sus paquetes a propósito como protesta. Imprimieron “LIBERTY LIBERTY LIBERTY” en consola y trabaron miles de builds. Ahora el problema era que un solo mantenedor tiene acceso total. Más capas: firmar commits, validar publicaciones, atestar pipelines.

2026, TanStack. SLSA provenance, OIDC trusted publishing, todo lo que se inventó después de los anteriores. Atestaciones criptográficas, builds reproducibles, supply chain levels of assurance. El sistema dijo: este paquete fue publicado por el pipeline oficial. Era verdad. El pipeline oficial era el problema.

Mientras esto pasaba, una campaña paralela llamada Mini Shai-Hulud afectaba 170+ paquetes cross-registry con más de 400 versiones maliciosas para el cierre del día. No fue un evento aislado: fue una jornada.

Habemus Patronum(?) o mejor dicho, acá hay un patrón

Cada vez que el ecosistema descubre un agujero, se le pone una capa encima. 2FA después de ua-parser-js. Signed commits después de colors. OIDC trusted publishing y SLSA provenance después de varios ataques de los años intermedios. Cada capa nueva resuelve el ataque anterior. La siguiente vez, el ataque ya no viene por ahí.

Lo que se sostiene a lo largo de los cuatro casos no es la técnica. Es la estructura del sistema de confianza: delegamos la decisión sobre qué corre en nuestra máquina a una cadena de actores y mecanismos automatizados que no controlamos. La cadena crece, suma intermediarios, suma atestaciones. A cada vuelta, lo que se sigue creyendo es que la próxima capa cierra el problema.

No lo cierra. Lo desplaza.

Cambiar de registry tampoco lo cierra. jsr, deno, bun publish, los registries de paquetes para otros lenguajes: todos usan el mismo modelo de fondo. Un autor o pipeline publica, un registry recibe, los consumidores descargan basados en confianza transitiva. Algunos tienen mejores defaults que npm. Ninguno cambia que la confianza está delegada y automatizada de punta a punta.

Acá es donde quiero parar, porque ya hablé de algo parecido desde otro ángulo en el episodio 21 de Cebando Ideas, “víctimas de nosotros mismos”, sobre vendor lock-in y la caída de AWS. El síntoma es el mismo: armamos sistemas donde la complejidad operacional se externaliza, asumiendo que del otro lado hay alguien que lo está mirando. Hasta que un día descubrimos que del otro lado había una capa más de delegación. Y otra. Y otra.

El criterio del oficio

Acá es donde el laburo de 20 años se pone interesante.

El developer senior que aprendió en una época donde había que entender qué hacía cada cosa que metías en el classpath, todavía sabe instintivamente qué corre en su build. No porque audite cada package.json. Porque tiene una intuición construida sobre años de leer código, de meter dependencias y arrepentirse, de ver caer cosas y entender por qué. Sabe que is-odd es ridículo, que cierto tipo de paquetes tienen olor, que cuando una librería de utilidades trae 200 transitivos algo no cierra. Tiene un radar.

El modelo automatizado asume que ese radar no hace falta. Que el sistema lo tiene. Que si la verificación pasa, está bien. Ahí está la trampa que se viene repitiendo desde 2018: el sistema no sabe por uno. El sistema sabe lo que le enseñaron a verificar después del último ataque.

La SLSA provenance suma valor. Hace más caros muchos ataques. Pero la próxima capa técnica no va a cerrar esto. Nunca la cerró. Y dejar de cultivar el criterio de fondo, porque ya hay una herramienta que “se ocupa”, es lo que nos deja con cara de sorpresa cada vez.

¿Qué hacemos los que construimos con este ecosistema encima, todos los días, sin poder darnos el lujo de reescribir todo desde cero?

Cosas chicas. Saber qué dependencia metiste y por qué. No agregar paquetes por reflejo porque dan una función de tres líneas. Mirar el árbol de transitivos al menos cada tanto, aunque sea con npm ls o equivalente. Pinear versiones, especialmente las críticas. Tener un proceso para reaccionar cuando salta algo, porque va a saltar otra vez. No usar la última versión publicada hace 20 minutos en producción si no hay urgencia real. Mantener el oficio despierto.

Sobre todo, no confundir verificación automatizada con criterio. Son cosas distintas. Una se puede comprar, instalar, configurar. La otra se construye con los años y con la disposición a seguir prestando atención cuando es más fácil delegar.

Cada vez que aparece un ataque como el de TanStack, los hilos se llenan de “hay que pasarse a X registry” o “hay que usar Y herramienta”. Es la conversación equivocada. La conversación de fondo es otra: cuánta de la confianza que ponemos en el stack está realmente verificada por nosotros, y cuánta es solo confianza en que alguien más la verificó. Si la respuesta sincera es “casi toda es del segundo tipo”, el problema no se resuelve cambiando el “alguien más”.

Se resuelve, en parte, aceptando que el oficio sigue importando. Ningún sistema de provenance, por bueno que sea, va a saber por vos qué tendría que estar corriendo en tu build.

Por eso digo siempre que el que abandona no tiene premio. En este caso, abandonar es dejar de mirar. Dejar de preguntarse. Confiar tanto en la cadena que un día el sistema te dice que todo está bien, y resulta que el sistema firmó el malware con tu nombre.


Fuentes consultadas: postmortem oficial de TanStack, análisis técnicos de StepSecurity y Wiz sobre la campaña Mini Shai-Hulud, reportes históricos de Snyk y CISA sobre los incidentes anteriores (event-stream, ua-parser-js).

Deja un comentario