Volver a escritos

Cómo endurecí mi proyecto pnpm contra ataques de cadena de suministro en 39 líneas

El cooldown de versiones como defensa principal contra ataques tipo Shai-Hulud, y el caso TanStack como contexto.

En los últimos doce meses, npm acumuló más incidentes de cadena de suministro que en cualquier período comparable anterior. Septiembre 2025: chalk y debug, dieciocho paquetes con más de dos mil millones de descargas semanales en conjunto; ese mismo mes, Shai-Hulud, el primer worm autorreplicante del ecosistema, con @ctrl/tinycolor y otros 500+ paquetes. Noviembre 2025: Shai-Hulud 2.0, segunda oleada, 796 paquetes infectados. Marzo–abril 2026: axios, Bitwarden CLI, SAP. Mayo 2026: TanStack — 84 versiones maliciosas firmadas con SLSA Build Level 3 provenance válida.

Mi sitio personal, este que estás leyendo, no usa ninguno de los paquetes víctima. Pero tampoco tenía una sola línea de defensa preventiva contra el próximo. Esta tarde lo arreglé. El resultado: un solo commit, dos archivos modificados, treinta y nueve líneas. La mayoría del valor está en una sola línea.

el patrón común

Los ataques de los últimos doce meses se parecen entre sí más de lo que sugieren los titulares. Phishing al mantenedor, token OIDC robado o cuenta secuestrada. Una versión maliciosa publicada al registry. Un preinstall script que se ejecuta la primera vez que alguien hace pnpm install con esa versión arriba, roba tokens, se propaga a los paquetes del siguiente mantenedor.

Hay una constante en todos: fueron detectados y retirados de npm en menos de siete días. chalk y debug, alrededor de dos horas. Shai-Hulud 2.0, doce horas. SAP, entre dos y cuatro horas. Bitwarden CLI, una hora y media.

Esa observación es la base de la defensa más eficaz que existe hoy contra esta clase de ataque. Si tu gestor de paquetes espera siete días antes de aceptar cualquier versión nueva, ninguno te toca.

el audit

Antes de cambiar nada, miré qué tenía:

  • pnpm 10.33.0, Node 22.22.2.
  • Diez dependencias directas, dos de dev.
  • pnpm-lock.yaml con 454 paquetes y integrity SHA-512 en todos.
  • .env en .gitignore — sin secretos versionados.

Y del lado de las defensas: ninguna. Sin .npmrc, sin packageManager pinneado, sin allowlist de scripts.

Una nota al margen: la revisión inicial encontró una vulnerabilidad HIGH preexistente, no relacionada a ninguno de los ataques anteriores. devalue@5.7.1, un denial of service por deserialización de sparse arrays (GHSA-77vg-94rm-hx3p), entraba transitivamente vía @astrojs/react. Apuntada para arreglar en el mismo commit.

lo aplicado

.npmrc:

minimum-release-age=10080
audit-level=high
save-exact=true
package-manager-strict=true
engine-strict=true
fund=false

La línea que importa es la primera. Diez mil ochenta minutos son siete días. pnpm rechaza cualquier versión publicada hace menos de una semana. Esa única línea habría bloqueado chalk, debug, axios, Shai-Hulud, SAP, Bitwarden CLI y TanStack — todos los casos relevantes de los últimos doce meses.

save-exact=true elimina los rangos ^. Cada pnpm add fija la versión exacta. Combinado con el cooldown, garantiza que ningún pnpm update futuro resuelva a algo no revisado.

package-manager-strict=true valida que la versión local de pnpm coincida con la pinneada en package.json (siguiente sección). Defiende contra downgrade del propio gestor.

engine-strict=true falla si Node no satisface el rango de engines. Reproducibilidad.

audit-level=high define el umbral default para pnpm audit. Sin esto, audit reporta todo, incluyendo LOW. Ruido.

fund=false silencia los mensajes de funding. Cosmético.

package.json:

"packageManager": "pnpm@10.33.0+sha1.46a0bef28aad690765997ab6da6d5475bdd4148e",
...
"pnpm": {
  "onlyBuiltDependencies": ["esbuild", "sharp"],
  "overrides": {
    "devalue@>=5.6.3 <5.8.1": ">=5.8.1"
  }
}

packageManager pinnea pnpm a una versión exacta con hash verificado contra el registry. Corepack — incluido en Node 22 — ejecuta exactamente ese binario; cualquier otra versión o binario manipulado, falla.

pnpm.onlyBuiltDependencies es la pieza más subestimada del setup. Desde pnpm 10, los scripts preinstall y postinstall no se ejecutan por defecto. Esta allowlist autoriza explícitamente a esbuild y sharp — los únicos paquetes del árbol que los necesitan para compilar binarios nativos. Cualquier otro paquete intentando ejecutar scripts queda bloqueado. Es el vector que usa Shai-Hulud para robar credenciales.

pnpm.overrides arregla la vulnerabilidad HIGH preexistente sin esperar a que Astro publique una versión que la traiga transitivamente.

tanstack: un caso que vale la pena entender

Hay un detalle de la cronología de 2026 que merece atención, porque cuestiona una creencia común sobre supply chain security.

El 11 de mayo de 2026, entre las 19:20 y 19:26 UTC, el worm Mini Shai-Hulud comprometió el monorepo de TanStack. Ochenta y cuatro versiones maliciosas publicadas en cuarenta y dos paquetes del namespace @tanstack/*, más Mistral AI y Guardrails AI. Todas con SLSA Build Level 3 provenance válida.

Las attestations de Sigstore eran correctas: atestiguaban que los paquetes habían sido construidos por release.yml corriendo en refs/heads/main del repositorio TanStack/router. Verdad verificable. Y aun así, código malicioso.

El vector fue un Pwn Request en GitHub Actions — un evento pull_request_target que hace checkout del SHA del PR sin protección de aprobación. El atacante inyectó código en el runner mientras la pipeline legítima estaba corriendo, robó el token OIDC y publicó desde la propia CI/CD del proyecto.

SLSA no atestigua que el código del workflow haya sido autorizado a correr, ni que el commit que disparó el build sea legítimo. Solo atestigua que un build corrió en un repositorio. Las firmas eran legítimas y aun así los paquetes contenían malware. El cooldown de siete días, en cambio, los bloquea: las versiones maliciosas estuvieron arriba unas horas antes de ser detectadas y retiradas.

lo que sigue siendo responsabilidad humana

Ninguna configuración cubre esto:

  • 2FA con WebAuthn — passkey, no SMS, no TOTP — en npm y GitHub. Sin esto, el phishing del estilo que afectó a chalk y debug funciona contra cualquiera.
  • Rotar tokens viejos que hayan podido tocar versiones comprometidas: npm token list, gh auth status, los tokens del proveedor de deploy.
  • Para repos que publican paquetes: revisar workflows de GitHub Actions contra Pwn Request. Si hay pull_request_target con checkout del SHA del PR sin aprobación explícita, el riesgo es el mismo que el caso TanStack.

El cooldown da tiempo para que la comunidad detecte el ataque. Pero el ataque sigue siendo posible si la cuenta víctima es la propia.

el resumen

Los ataques de cadena de suministro de los últimos doce meses comparten un denominador común que su variedad técnica esconde: todos fueron retirados de npm en menos de una semana, casi todos en horas. Esa ventana es la que cierra minimum-release-age=10080 — siete días donde npm tiene tiempo de detectar, los mantenedores de revertir, y quien instala de no traerse lo malo.

Bloquear lifecycle scripts no autorizados con pnpm.onlyBuiltDependencies cubre el vector concreto que usan los worms para robar credenciales. Pinear el gestor con packageManager cierra el resto del perímetro de instalación. El resto del setup importa, pero como respaldo. La defensa central son esas tres piezas.

· ·