Cualquiera podía publicar ese paquete
El 28 de agosto alguien abrió un pull request contra un generador de código OpenAPI muy usado, escribió en él un comentario de dos palabras, y el propio flujo de publicación del proyecto subió a npm diez versiones envenenadas con atestaciones de procedencia válidas. El atacante nunca tuvo ninguna credencial. La primera oleada no llevaba script de instalación alguno: escondía su carga donde node-gyp evalúa Python. Toda la ventana duró tres horas y once minutos, y el filtro de antigüedad de Bromure Agentic Coding, activo por defecto en dos días, le gana a ese tiempo sin saber nada del ataque.
Alguien abrió un pull request desde un fork y dejó en él un comentario de dos palabras. La tubería de publicación del proyecto leyó el comentario, descargó su código, lo ejecutó y firmó lo que salió de ahí. No robaron nada para llegar hasta ese punto.
@7nohe/openapi-react-query-codegen convierte un esquema OpenAPI en hooks de
TanStack Query. Lo apuntas a una especificación y te escribe el código cliente.
Aikido cuenta más de 150.000 descargas por semana. Añades un paquete así porque
te ahorra una tarde, y tu agente de código lo instala sin pensarlo dos veces.
El 28 de agosto empezó a repartir un ladrón de credenciales.
Aikido Security publicó el análisis ese mismo día, y SafeDep publicó el suyo con el mecanismo del flujo de trabajo y una cronología minuto a minuto. El malware se nombra a sí mismo en sus propias cadenas: Trinitite. Los dos equipos sitúan su modo de operar en la familia Mini Shai-Hulud, el gusano de npm autorreplicante que lleva todo el año recorriendo cuentas de mantenedores.
Lo que merece leerse es su ruta hasta npm.
La tubería de publicación aceptó una instrucción de un desconocido
El release.yml del proyecto tenía un disparador issue_comment. Ese es un
evento de GitHub Actions que se dispara cuando alguien comenta en una incidencia
o en un pull request, y es una comodidad habitual: los mantenedores lo conectan
para poder publicar una versión escribiendo una palabra en lugar de navegar por
una interfaz.
Este no tenía ninguna comprobación de asociación del autor. GitHub le dice al flujo de trabajo si quien escribió el comentario es la persona propietaria del repositorio, un miembro, alguien que contribuyó en el pasado o un completo desconocido, y le toca al flujo mirarlo. Este no miró.
SafeDep reconstruyó la secuencia. Abre un pull request desde un fork. Comenta
npm publish en él. La tubería despierta, descarga el código del fork del
atacante y ejecuta pnpm install, lo que ejecuta el hook preinstall del
atacante en el propio runner del proyecto. Ese trabajo llevaba
id-token: write, suficiente para acuñar un token OIDC de publicación de
confianza de npm.
Salieron diez versiones maliciosas, cada una con una atestación de procedencia válida, porque fue la propia tubería del proyecto la que construyó y publicó todas ellas. La frase de Aikido es la que hay que quedarse: “cuando el propio flujo de trabajo está comprometido, ese certificado se convierte en una señal de confianza poco fiable.”
Comprueba la procedencia de un paquete y aprendes una sola cosa: si salió de la tubería de ese repositorio. Aquí salió. Nadie falsificó nada, y se puede sostener una firma correcta sobre malware sin que ninguno de los dos hechos parezca raro. En mayo vimos el mismo desenlace llegar a través de un token OIDC robado. Esta vez el atacante se saltó el robo.
La primera oleada no tenía script de instalación que encontrar
Escanea esas ocho primeras versiones en busca de hooks de instalación
peligrosos y vuelves con las manos vacías. Sus archivos package.json no
declaran nada que se ejecute.
La carga estaba en binding.gyp, el archivo de descripción de compilación que
node-gyp usa para compilar módulos nativos. Aikido explica el truco:
“Cuando npm install procesa un paquete que contiene un binding.gyp, invoca
a node-gyp para compilar el módulo nativo. node-gyp evalúa el campo
conditions del archivo usando Python, lo que significa que ahí se pueden
colocar expresiones Python arbitrarias y que se ejecutarán durante la
instalación, incluso sin ningún script preinstall declarado en
package.json.”
Así que el atacante escribió una expresión de Python dentro de un archivo de
compilación. Recorre el árbol interno de subclases de Python hasta llegar a
catch_warnings, usa esa clase para alcanzar __builtins__, importa os
desde ahí y llama a os.system() sobre un archivo JavaScript incluido en el
tarball. SafeDep señala que el recorrido está escrito con escapes Unicode, de
modo que un grep de os.system también vuelve vacío.
El JavaScript de debajo desenvuelve tres capas — XOR, luego AES-128-GCM, luego
un ofuscador comercial — antes de bajar Bun 1.4.0 desde GitHub a un directorio
temporal con el prefijo trinnyyyy- para ejecutar la carga de verdad.
Diecinueve minutos después el atacante mandó una segunda oleada con
preinstall de vuelta en package.json como respaldo. SafeDep fecha esa
oleada en menos de un minuto después del primer informe público de
seguridad. Quien llevaba esto estaba leyendo las divulgaciones y publicando
contra ellas.
Tres horas y once minutos
La cronología de SafeDep va en UTC. La última versión limpia, 3.0.2, salió el 11 de agosto. La primera oleada aterrizó el 28 de agosto a las 20:00 y terminó a las 20:02. La segunda fue de 20:19 a 20:21. npm retiró las diez versiones sobre las 23:11.
Eso deja una ventana de exposición de tres horas y once minutos. Un proyecto en
^3.0.0, el rango con circunflejo corriente que npm escribe por ti, resolvía
justo dentro. Si lanzaste una instalación limpia dentro de esa ventana, o la
lanzó tu CI, o tu agente de código, te llevaste el gusano.
Una vez en marcha, barrió el directorio personal con más de 150 patrones glob:
claves privadas SSH, archivos .env, ~/.docker/config.json, credenciales de
AWS, Azure y GCP, tokens de npm, PyPI y RubyGems, tokens de cuenta de servicio
de Kubernetes, monederos de criptomonedas. En la misma lista estaban
~/.claude.json, ~/.claude/ y ~/.claude/mcp.json, la configuración del
propio agente de código y las direcciones de todos los servidores MCP con los
que habla.
En los repositorios a los que llegaba escribía un .claude/settings.json con un
hook SessionStart que ejecuta setup.mjs cada vez que alguien abre el
proyecto en Claude Code. Con los tokens de registro que acababa de recoger, se
republicaba a sí mismo en todos los paquetes que su víctima mantenía en npm,
PyPI y RubyGems. Esa es la parte de gusano: cada persona en la que aterrizaba se
convertía en candidata para la siguiente publicación.
Dos días le ganan a tres horas
Ahora pon un perfil de Bromure Agentic Coding en el camino.
Bromure ejecuta cada perfil como su propia máquina virtual sobre Apple Silicon, y cada paquete que el agente descarga (npm, PyPI, Cargo, RubyGems, Maven, NuGet, módulos de Go, Packagist) pasa por un proxy en el lado Mac del hipervisor antes de que un solo byte llegue al invitado.
Lo primero que ese proxy comprueba es qué antigüedad tiene la versión. Supply
Chain → el filtro de antigüedad viene activado por defecto, con un mínimo de dos
días. Las referencias flotantes (latest, rangos con circunflejo, rangos con
tilde) resuelven sin comentarios a la versión más reciente anterior al corte.
Fija algo demasiado fresco y recibes un 451 con un mensaje de error claro.
Pon eso frente a una ventana hostil de tres horas y once minutos. Ejecuta
npm install a las 20:30 UTC del 28 de agosto, en plena operación, con
^3.0.0 en tu manifiesto, y tu agente resuelve a 3.0.2, del 11 de agosto.
Nunca se entera de que existe la 3.0.3.
El filtro llegó ahí sin una firma, sin una puntuación de reputación y sin un aviso. Leyó una fecha de publicación. Eso importa aquí, porque la segunda oleada salió menos de un minuto después de la primera advertencia pública, y cualquier defensa que espere un informe ha entrado tarde en la carrera.
Qué encuentra el barrido cuando llega a ejecutarse
Supongamos que apagaste el filtro para un paquete, o que la versión ya había pasado el corte antes de que nadie se diera cuenta. La carga todavía tiene que hacer cuatro cosas, y en un perfil de Bromure cada una de ellas se topa con un control que vive en el Mac, donde el invitado no llega.
Tiene que ejecutarse. Supply Chain → Eliminación de scripts de
instalación quita preinstall, install, postinstall y prepare de los
tarballs de npm al vuelo, reescribiendo el tarball y actualizando el hash de
metadatos del registro para que la propia verificación de npm siga pasando. Ahí
se acabó la segunda oleada. Puedes apilar más comprobaciones encima: las
consultas de vulnerabilidades OSV, y el filtrado de paquetes con
socket.dev, cuya comprobación de bloqueo de paquetes comprometidos cubre
justo esta clase de cosas (scripts de instalación maliciosos, malware), o
Delpi como registro sustituto. Ninguno pregunta si existe una atestación, lo
cual importa cuando la atestación es real.
Tiene que encontrar algo. El barrido de 150 globs se ejecuta contra
/home/ubuntu dentro de una máquina virtual, no contra la carpeta personal de
tu Mac. Las carpetas compartidas son explícitas y están limitadas a ocho. Las
credenciales que encuentra son señuelos: los tokens en el invitado son
marcadores brm_… que el proxy del anfitrión cambia por los valores reales
sobre el cable, de modo que el material auténtico nunca entra en el espacio de
direcciones de la VM. Las claves privadas SSH
no están en el invitado en absoluto:
un ssh-agent por perfil en el anfitrión hace la firma, y
~/.docker/config.json contiene base64 falso. El kubeconfig es sintético, con
certificados de cliente desechables. Las peticiones de AWS se vuelven a firmar
en el anfitrión, así que cualquier cosa que se salte el proxy vuelve como
InvalidSignatureException en lugar de autenticada. Activa Exigir aprobación
para usar en una credencial y cada sustitución levanta un diálogo en tu Mac,
con una concesión limitada en el tiempo.
Tiene que llamar afuera. Guardarraíles → Conexiones salientes es un
conjunto de reglas al estilo pf: una acción, un protocolo (tcp, udp, web o
any), un nombre de host o un CIDR IPv4, puertos, y para web los métodos HTTP
que permitas. Las reglas se evalúan de arriba abajo, gana la primera que
coincide, y poner Tráfico no coincidente en Denegar convierte la lista en
una lista de permitidos. La misma política se evalúa dos veces en capas
independientes: el conmutador virtual, por IP de destino y por nombre de host
espiado en el DNS, en todos los protocolos, y el proxy del anfitrión, por SNI de
TLS y por método. Bajar un binario de Bun y empujar secretos robados a un
repositorio buzón necesitan las dos un destino, y los destinos tienen que estar
en la lista.
Tiene que publicar. Guardarraíles → GitHub en Solo lectura trata
git-receive-pack como escritura y bloquea las escrituras REST, devolviendo un
403 seco que el agente lee como un fallo de API cualquiera. Ni commit
envenenado, ni buzón, ni publicación posterior. Los mismos modos cubren GitLab,
Bitbucket, Kubernetes, AWS, DigitalOcean y los registros de contenedores.
El hook SessionStart que pensaba dejar atrás para la próxima vez está en una
carpeta personal que Borrar la carpeta personal… limpia, en Resources →
Storage. Restablecer a la base… hace lo mismo con cualquier cosa escrita
fuera de ella. Cada decisión del camino, cada destino permitido o denegado, cada
veredicto sobre un paquete, cada sustitución de credencial, aterriza en la
ventana del Registro de Seguridad en el anfitrión, donde el código del
invitado no puede editarla porque el código del invitado no llega hasta ella.
Por qué esto sigue pasando
La publicación de confianza y la procedencia merecen la pena. Cerraron el robo del token npm de un mantenedor, que era el ataque dominante hace un año. El compromiso de esta semana los rodeó sin tocar ningún token: un desconocido podía alcanzar el proceso de publicación, así que la tubería firmó una atestación verdadera sobre código hostil.
De eso no se sale auditando, porque el próximo flujo de trabajo que falle será de otra persona. Lo que sí puedes hacer es dejar de ser la primera máquina en ejecutar una versión nueva de lo que sea.
Abre un perfil, mira en Supply Chain y comprueba que el filtro de antigüedad esté activo. Debería estarlo, en dos días, sin que lo hayas tocado. Luego dedica dos minutos más: comprobación de vulnerabilidades OSV activada, socket.dev o Delpi seleccionado si tienes clave, eliminación de scripts de instalación activada. En Guardarraíles, pon Tráfico no coincidente en Denegar con una lista de permitidos corta, y deja GitHub en Solo lectura para cualquier perfil que no esté publicando. En Credenciales, activa Exigir aprobación para usar en todo lo que pueda gastar dinero.
Luego deja que el agente instale lo que necesite. Se llevará la 3.0.2. Instala Bromure Agentic Coding y deja el filtro de antigüedad en paz.