Volver a todas las publicaciones
Publicado el · por Renaud Deraison

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.

un desconocidopull request desdeun fork, y luego unnpm publishrelease.ymlon: issue_commentsin mirar quién esdescarga el forkpnpm installejecuta su códigoid-token: writenpm OIDCpublicación de confianzatoken acuñadoa petición, según diseño10 versionespublicadas en npmprocedencia váliday no es mentiraNingún paso de esta cadena necesitó un secreto robado. El eslabón débil estaba por encima de toda firma.
Cómo diez versiones firmadas acabaron publicadas por alguien sin ninguna credencial. El flujo de publicación se disparó con un comentario sin comprobar quién lo había escrito, descargó código del fork del atacante, lo ejecutó durante la instalación de dependencias y usó su propio permiso id-token para acuñar un token legítimo de publicación en npm. La atestación de procedencia del resultado es exacta: la tubería del proyecto sí construyó y publicó eso.

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.

11 ago3.0.2 — última limpia28 ago, 20:00 & 20:19 UTColeada 1 (binding.gyp), oleada 2 (preinstall)23:11 UTCnpm retira las diezexposición: 3 h 11 minFiltro de antigüedad, dos días por defecto: un rango toma la última versión anterior al corte — 3.0.2.
La ventana de ataque frente al corte por defecto. Diez versiones envenenadas existieron en npm durante tres horas y once minutos. El filtro de antigüedad de Bromure viene activado con un mínimo de dos días, así que un rango con circunflejo resuelve a la 3.0.2 del 11 de agosto y las versiones envenenadas nunca llegan a ser candidatas. El filtro no emite juicio alguno sobre el paquete; solo se niega a ser el primero.

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.

lo que aún necesita el gusano1. ejecutarse al instalarbinding.gyp, o un hook preinstall2. encontrar credenciales150+ globs por la carpeta personal3. conectarse hacia fuerabajar Bun, dejar los datos en un buzón4. publicar más adelanteempujar commits, republicar paquetesdónde aterriza en el Macproxy de paquetes del anfitriónantigüedad, scripts quitados, OSV,filtrado con socket.dev o Delpiuna carpeta personal de VMmarcadores brm_…, base64 falso,kubeconfig sintético, sin claves SSHConexiones salientesreglas del anfitrión, gana la primera,comprobadas en el conmutador y el proxyGuardarraíles, Solo lecturagit-receive-pack y escrituras RESTdevuelven 403 desde el anfitrión
Las cuatro cosas que Trinitite necesita una vez en marcha, y dónde aterriza cada una en un perfil de Bromure Agentic Coding. La ejecución se topa con la eliminación de scripts y con el filtrado de paquetes en el proxy del anfitrión. El barrido de credenciales encuentra marcadores en una carpeta personal de VM. La salida de red y la publicación se topan con un conjunto de reglas en el anfitrión y con los Guardarraíles. Ninguno de estos controles vive en el invitado, así que el código que corre en el invitado no puede cambiarlos.

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.