El mayor ataque a npm no me ha rozado, y no fue por previsión
ContenidoContents
Ayer alguien robó las credenciales del mantenedor de keyv, una librería de caché con unos 127 millones de descargas semanales, y con ese acceso publicó versiones envenenadas de todo lo que ese mantenedor tocaba.
El resultado, según el análisis de Microsoft: más de 400 paquetes comprometidos, y contando las versiones, más de mil trescientas. Entre ellos flat-cache y file-entry-cache, que suman por sí solos más de mil millones de instalaciones al mes porque cuelgan de ESLint, que cuelga de casi todo. En total, del orden de dos mil millones de instalaciones mensuales afectadas.
Lo elegante y lo desagradable del ataque es lo mismo: las versiones maliciosas se publicaron con firma válida, generada por GitHub Actions. El atacante empujó los ficheros a la rama principal y lanzó una versión inmediatamente. Todo lo que un sistema automático mira para decidir si un paquete es legítimo, decía que sí.
Cada paquete recibía dos ficheros nuevos y una línea en su package.json:
1"scripts": {
2 "preinstall": "node setup.mjs"
3}
preinstall significa que eso se ejecuta antes de que termine tu instalación. No hace falta que uses el paquete, ni que importes nada. Basta con instalar. Ese script se descargaba un runtime de Bun, ejecutaba un segundo binario ofuscado, recolectaba credenciales de npm, GitHub, AWS, Kubernetes y Vault, y con los tokens robados republicaba más paquetes. Un gusano.
Fui a mirar mi servidor esperando lo peor
Tengo un Mac mini en casa sirviendo esta web, un Moodle, un Jitsi y unas cuantas cosas más. Y tengo Node instalado. Así que lo primero fue buscar los indicadores concretos:
1# ¿están los paquetes comprometidos?
2for p in keyv cacheable flat-cache file-entry-cache cache-manager; do
3 [ -d "node_modules/$p" ] && echo "$p PRESENTE"
4done
5
6# ¿los ficheros del malware?
7find node_modules -name "setup.mjs" -o -name "Math_Symbol.js"
8
9# ¿aparecen aunque sea de forma transitiva?
10grep -c '"node_modules/flat-cache"' package-lock.json
Cero. En todo. Ni instalados, ni en el árbol de dependencias, ni rastro de los ficheros.
Y luego miré las fechas, que es lo que de verdad cierra el caso: mi package-lock.json es del 8 de enero y la carpeta node_modules del 19 de junio. El ataque fue el 4 de agosto. Aunque hubiera tenido esos paquetes, no he ejecutado un npm install en la ventana en la que las versiones estaban envenenadas.
Por qué no estaban
Aquí viene la parte que me interesa contar, porque no tiene mérito.
Esta web no tiene dependencias. No es una forma de hablar: en todo el directorio que sirve Apache no hay una sola carpeta node_modules. La portada es HTML escrito a mano. Las quince herramientas son HTML y JavaScript que corre en tu navegador, sin librerías. El blog lo genera Hugo, que es un binario de Go. No hay empaquetador, no hay proceso de build, no hay nada que instalar para publicar: escribo, hago commit y un hook lo sube en segundos.
El único npm que tengo es para los 101 tests que corren antes de cada commit. Son 360 paquetes en el lockfile y 57 MB de node_modules que jamás salen de mi máquina y jamás llegan al servidor web.
Y ahora la casualidad incómoda: hace dos días borré Umami, la analítica autoalojada que tenía montada. Eran 1,5 GB de dependencias de Node corriendo permanentemente como servicio. No lo quité por seguridad, lo quité porque no la usaba y quería espacio en disco. Cuarenta y ocho horas después, el ecosistema del que colgaba se convertía en un campo de minas.
No fue previsión. Fue suerte, y antes de la suerte, pereza.
Lo aburrido que sí protege
Si algo me ha salvado de verdad, no ha sido ninguna decisión inteligente sobre seguridad. Han sido tres cosas sin ningún glamour:
No reinstalar. Un lockfile congelado desde enero no es una buena práctica que yo defienda; es que no he tenido motivo para tocarlo. Pero el efecto es real: las versiones envenenadas estuvieron disponibles unas horas, y quien no instaló nada esas horas no se enteró de nada.
No tener build en producción. Cada herramienta que añades a la cadena entre tu texto y el HTML servido es una puerta más. Yo tengo una: Hugo. Y no viene de npm.
Que lo que corre en el servidor no venga de un registro público. Apache, PHP y PostgreSQL vienen de Homebrew y de proyectos con décadas encima y procesos de publicación lentos. Lentos es aquí un cumplido.
Dónde sí soy vulnerable, para no engañarme
Sería muy cómodo terminar aquí con la moraleja de que lo simple es más seguro. Pero mi exposición no es cero:
- Tengo npm en la máquina. Si mañana instalo cualquier cosa y toca la lotería, el
preinstallse ejecuta con mi usuario, con mis tokens y mis llaves delante. - Moodle tiene sus propias dependencias, con su cadena de build para los estilos:
grunt,less,shifter. Ahí no decido yo. - Mi npm es antiguo. La recomendación de Microsoft es actualizar a npm 12, que trae
min-release-agepara no instalar versiones publicadas hace minutos —exactamente el vector de este ataque—. Fui a hacerlo y no pude: npm 12 exige Node 22, 24 o 26, y yo tengo el 23. Actualizar Node me obligaría a tocar las herramientas de build de Moodle, así que de momento me quedo con el npm viejo y con no instalar nada a ciegas. Deuda anotada.
Lo que sí hice fue vaciar la caché de npm, donde quedaban catorce entradas con metadatos de los paquetes afectados. No era código infectado, pero limpiar no cuesta nada.
Lo que me llevo
La reflexión no es “usa menos dependencias”. Es más incómoda: casi todo el mundo que se ha visto afectado hoy tampoco eligió instalar flat-cache. Le vino con ESLint, que le vino con la plantilla del proyecto. La cadena de decisiones que te lleva a ejecutar código de un desconocido en tu portátil empieza casi siempre con alguien haciendo create-algo hace tres años.
Yo no soy más listo. Simplemente monté esta web como se montaban antes, porque para lo que hace no necesitaba más, y resulta que la superficie de ataque es una consecuencia del tamaño de lo que construyes, no de lo cuidadoso que eres.
Eso sí: mañana, cuando tenga que instalar algo, tendré exactamente los mismos problemas que todos los demás.