El 21 de septiembre alguien subió seis commits maliciosos a la rama principal de Zod, la biblioteca de validación de datos más usada de TypeScript, con una credencial de git robada. Su mantenedor los revirtió ese mismo día y ninguna versión publicada en npm los llegó a incluir. El incidente se hizo público el 11 de octubre en las notas de la versión 4.6.6 y desde entonces circula por redes y medios de desarrolladores como un posible compromiso de Zod.
Zod permite describir la forma que deben tener unos datos, por ejemplo el cuerpo de una petición o la respuesta de un modelo de IA, y comprobar en tiempo de ejecución que la cumplen, con los tipos de TypeScript deducidos a partir de esa descripción. Lo usan frameworks, SDK de IA y miles de proyectos, y en npm se descargó más de 346 millones de veces solo la semana pasada. Esa popularidad lo convierte en un objetivo perfecto para un ataque a la cadena de suministro: quien consiga meter código en una versión de Zod lo ejecuta en millones de máquinas de desarrolladores y de servidores de integración continua.
Eso es exactamente lo que se intentó. Los commits aparecieron entre las 11:56 y las 15:16 (hora UTC) a nombre de «colinhacks», la cuenta de Colin McDonnell, creador de Zod, aunque sin firma verificada. A las 21:33, McDonnell fusionó la PR #6620, «Cleanup bad commits», que deshacía los seis, y más tarde reescribió el historial de la rama para eliminarlos. Las notas de la 4.6.6 lo cuentan en un párrafo y explican que la procedencia de esa versión apunta a una copia del commit anterior a esa reescritura, con el mismo contenido.
Claves del incidente
- Un script que se instalaba solo. El primer commit, titulado «Fix bugs and improve performance», añadía un archivo
meta.test.mjsa los paqueteszody@zod/mini, lo incluía en lo que se sube a npm y lo declaraba comopreinstall, la orden que se ejecuta al instalar un paquete. Además, subía el número de versión a 4.6.6. - Un servidor escondido en la cadena de bloques. El script no llevaba ninguna dirección de servidor. Consultaba un contrato de Ethereum a través de tres nodos públicos, sacaba de él una lista de dominios, pedía a cada uno un archivo y ejecutaba el código que recibía, para después borrar el rastro. Así, el atacante podía cambiar de servidor sin tocar el paquete y sin dejar un dominio fácil de bloquear.
- El objetivo era publicar sin pedir permiso. Los otros commits iban a por el proceso de publicación: uno quitaba la aprobación manual que exigía el entorno de npm en GitHub Actions, otro añadía un flujo que publicaba con un token en cada push a la rama principal y dos más subían la versión a 4.6.7 y 4.6.8.
- Nada llegó a npm. En el registro, después de la 4.6.5 del 13 de septiembre, la siguiente versión es la 4.6.6 del 11 de octubre. Esa versión y la 4.6.7, publicada hoy, se generaron en GitHub Actions con certificado de procedencia SLSA y no tienen ningún script de instalación.
- Publicar ahora necesita a una persona. Tres días después del ataque, la PR #6622 hizo que la integración continua ya no pudiera publicar: solo deja cada versión en una cola de espera de npm, donde no se puede instalar, hasta que un mantenedor la aprueba con doble factor. En palabras de la propia PR, una credencial robada que llegue a ese flujo «puede llenar una cola y nada más». La PR #6638, del 30 de septiembre, deja la ejecución en pausa en un entorno de aprobación durante un máximo de 30 días.
- La alarma vino del historial. El 11 de octubre, un usuario abrió la incidencia #6658 porque una auditoría de seguridad le marcó el commit malicioso dentro del historial de la 4.6.6. Otros usuarios señalaron enseguida que el código no está en el paquete de npm. A la hora de escribir esta nota, la incidencia sigue abierta y sin respuesta de los mantenedores.
npm audit signatures verifica las firmas y la procedencia de los paquetes que tienes instalados. Un detalle curioso: el atacante puso el preinstall fuera del bloque scripts del package.json, que es donde npm busca lo que tiene que ejecutar al instalar, así que, tal como estaba escrito, probablemente no se habría ejecutado solo. No es algo con lo que convenga contar la próxima vez.Por qué importa
Los ataques a npm de los últimos años se han apoyado casi siempre en lo mismo: un token o una cuenta robados que permiten publicar una versión maliciosa en minutos, antes de que nadie lo note. En este caso la credencial robada solo daba acceso al repositorio, y la publicación todavía exigía una aprobación manual que el atacante intentó quitar en el propio repositorio. Esa capa de más bastó para que el ataque se quedara en el historial de git.
La respuesta de Zod va más allá: ya no hay ningún camino para que una máquina publique sola. La función de publicación en espera de npm convierte cada versión en una propuesta que un humano tiene que aprobar con su segundo factor, y eso cuesta poco cuando se publica unas pocas veces al mes. Es un modelo fácil de copiar para cualquier proyecto con muchos usuarios, que hasta ahora confiaba sobre todo en tokens y en la procedencia de la versión.
También deja una lección sobre cómo se comunican estos incidentes. Las notas de la 4.6.6 lo contaron en pocas líneas, tres semanas después, y sin aviso de seguridad. Las herramientas de auditoría, que miran el historial y no solo el paquete, lo encontraron por su cuenta, y lo que empezó como una buena noticia, un ataque detenido a tiempo, se difundió como un posible compromiso. Mientras los mantenedores no respondan a la incidencia #6658, la mejor forma de saber qué pasó es leer las notas de la versión y el propio registro de npm.
Datos contrastados con la PR #6620, las PR #6622 y #6638, la incidencia #6658 y el registro de npm. Portada: candado y cadena, foto de Dori, Wikimedia Commons, CC BY 2.5 (recortada y teñida).
