npm audit no alcanza: simulé un supply chain attack sobre mis dependencias de Node y encontré lo que el scanner no ve
La solución correcta para proteger las dependencias de un proyecto Node es no confiar en npm audit. Sé que suena raro — es la herramienta oficial, sale en toda la documentación, el CI verde te dice que estás bien. Pero después de simular el mismo vector que destruyó el caso PyTorch Lightning sobre mi propio stack, tengo que decirte que el badge verde es la parte más peligrosa de toda la cadena.
Déjame explicar qué encontré.
Supply chain attack npm dependencias node producción: el problema que audit no modela
Cuando salió el post sobre el malware en PyTorch Lightning me quedé picado. Lo cubrí desde el ángulo ML (lo podés leer acá), pero la pregunta que me quedó dando vueltas fue distinta: ¿qué pasa si el mismo vector lo corro sobre mis dependencias de Node?
No sobre un proyecto de juguete. Sobre mi stack real: Next.js, Railway, PostgreSQL, con TypeScript y una docena de librerías de terceros que instalé sin pensar demasiado. El tipo de proyecto donde corrés npm install a las 11pm porque hay un deploy urgente y no te fijás en el postinstall.
Acá está la tesis, clara y sin vueltas: npm audit detecta vulnerabilidades conocidas. Un supply chain attack bien ejecutado no usa vulnerabilidades conocidas — usa confianza. Son dos modelos de amenaza completamente distintos y el ecosistema Node hace un trabajo pésimo diferenciándolos.
Cómo estructuré la simulación — metodología propia, sin laboratorio ficticio
Primero aclaro qué hice y qué no hice. No publiqué paquetes maliciosos. No infecté nada real. Trabajé con un entorno de staging aislado, cloné mi package.json de producción y ejecuté la simulación contra esa copia. Los hallazgos son sobre vectores de ataque documentados aplicados a dependencias que existen en mi stack ahora mismo.
Empecé con el inventario honesto:
# Listar dependencias directas con sus versiones fijadas
npm list --depth=0 --json | jq '.dependencies | keys'
# Contar el árbol completo — esto fue el primer shock
npm list --all 2>/dev/null | wc -l
# Output: 1.847 líneas
# Dependencias directas: 23
# Todo el árbol transitivo: 847 paquetes
847 paquetes para un proyecto con 23 dependencias directas. Cada uno de esos 847 tiene un maintainer, tiene un historial de publicaciones, y tiene acceso total al filesystem en tiempo de instalación vía postinstall. npm audit me reportó 0 vulnerabilidades críticas. Cero.
Vector 1 — Typosquatting en el árbol transitivo
El typosquatting clásico (publicar lodahs en lugar de lodash) es conocido. Lo que no se habla tanto es el typosquatting dentro del árbol transitivo — un paquete que conocés instala una dependencia que no conocés, y esa dependencia tiene un nombre visualmente similar a algo legítimo.
Corrí este script contra mi package-lock.json:
#!/bin/bash
# Extraer todos los paquetes del lock y buscar nombres sospechosos
# Criterio: distancia de Levenshtein <= 2 contra paquetes top-1000 de npm
cat package-lock.json | \
jq -r '.packages | keys[]' | \
grep -v "^node_modules/@" | \ # ignorar scoped por ahora
sed 's|node_modules/||' | \
sort -u > mis_paquetes.txt
# Comparar contra lista de paquetes populares
# (descargué el top-1000 de npm registry stats)
while read pkg; do
python3 -c "
import sys
from difflib import SequenceMatcher
nombre = '$pkg'
with open('npm_top1000.txt') as f:
for linea in f:
legit = linea.strip()
ratio = SequenceMatcher(None, nombre, legit).ratio()
# Alertar si son similares pero no idénticos
if 0.85 < ratio < 1.0:
print(f'SOSPECHOSO: {nombre} similar a {legit} ({ratio:.2f})')
"
done < mis_paquetes.txt
Resultado: 3 paquetes con similitud > 0.88 a nombres populares. Los tres resultaron ser legítimos — variantes con prefijo del mismo autor. Pero el punto es que nunca los había auditado manualmente y npm audit no los flagueó una sola vez.
Vector 2 — Lifecycle scripts con acceso irrestricto
Este es el que más me incomodó. Corrí un análisis de todos los scripts preinstall, install y postinstall en mi árbol de dependencias:
# Buscar lifecycle scripts en todo el árbol
find node_modules -name "package.json" -not -path "*/node_modules/*/node_modules/*" | \
xargs jq -r 'select(.scripts) |
{
name: .name,
version: .version,
preinstall: .scripts.preinstall,
install: .scripts.install,
postinstall: .scripts.postinstall
} |
select(.preinstall != null or .install != null or .postinstall != null)' \
2>/dev/null | jq -s '.'
47 paquetes en mi árbol tienen lifecycle scripts. Cuarenta y siete. Revisé manualmente los primeros 20 y encontré:
- 12 legítimos (compilación de binarios nativos, generación de tipos)
- 6 que hacen network calls durante la instalación — fetchean configuraciones, telemetría opt-out, verifican licencias
- 2 que escriben en directorios fuera de
node_modules
Los 2 que escriben fuera: uno es un paquete de fonts que copia archivos a /usr/local/share/fonts si tiene permisos. El otro es una herramienta de CLI que crea un archivo de configuración en ~/.config/. Nada malicioso. Pero ambos tienen el mecanismo exacto que usaría un atacante. Y npm audit: silencio total.
Vector 3 — Maintainer takeover silencioso
Este fue el experimento más interesante. El vector de maintainer takeover — donde alguien toma control de una cuenta npm y publica una versión nueva con payload malicioso — es el más difícil de detectar porque la firma del paquete es legítima.
Simulé el escenario así: elegí 5 paquetes de mi árbol con menos de 50 dependientes en npm (paquetes de nicho, sin mucho escrutinio), chequeé la actividad de sus maintainers y la frecuencia de publicación histórica:
# Para cada paquete, ver historial de versiones y fechas
for pkg in "paquete-a" "paquete-b" "paquete-c" "paquete-d" "paquete-e"; do
echo "=== $pkg ==="
# Obtener historial de publicaciones del registry
curl -s "https://registry.npmjs.org/$pkg" | \
jq -r '.time | to_entries | .[-10:] | .[] | "\(.key) → \(.value)"'
done
Encontré un paquete — no voy a nombrarlo, pero sí le mandé un mail al maintainer — con 15 meses sin actividad y una versión nueva publicada hace 3 semanas. El changelog decía "dependency update". Las dependencias que agregó son legítimas. Pero el patrón (inactividad larga + publicación nueva + changelog vago) es exactamente el fingerprint de un account takeover.
npm audit sobre ese paquete: 0 vulnerabilidades. Correcto técnicamente, porque no hay CVE registrado. Pero el riesgo es real.
Los errores que cometemos todos — y que yo cometí hasta hace tres meses
Error 1: Confundir "sin vulnerabilidades conocidas" con "seguro"
npm audit busca CVEs. Un supply chain attack bien ejecutado no registra CVE hasta después de que el daño está hecho. Son ventanas temporales distintas — el ataque existe semanas antes que el aviso.
Error 2: Lockfiles como garantía de reproducibilidad, no de seguridad
El package-lock.json garantiza que instalás las mismas versiones. No garantiza que esas versiones no fueron comprometidas después de que generaste el lock. Si el registro de npm sirve una versión diferente para el mismo número de versión (lo que no debería pasar pero pasó antes), tu lock no te salva.
Empecé a usar checksums verificables explícitos. El integrity field del lockfile ayuda, pero hay que validarlo activamente:
# Verificar integridad de todos los paquetes instalados
# comparando contra el lock
npm ci --ignore-scripts # primero, sin ejecutar lifecycle scripts
# Después, verificar que los hashes coincidan
node -e "
const lock = require('./package-lock.json');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
// Iterar sobre paquetes y verificar integridad
Object.entries(lock.packages || {}).forEach(([pkgPath, pkgData]) => {
if (!pkgPath || !pkgData.integrity) return;
// El campo integrity usa sri hashing (sha512)
console.log(\`✓ \${pkgPath}: \${pkgData.integrity.slice(0, 20)}...\`);
});
"
Error 3: npm install en CI con acceso a secrets
Esto me lo enseñó el caso de mis agentes autónomos en Railway — cuando un proceso tiene acceso a variables de entorno con credenciales, cualquier código que corra en ese proceso las puede exfiltrar. Correr npm install (con lifecycle scripts) en el mismo step donde inyectás DATABASE_URL o RAILWAY_TOKEN es darle a cada postinstall acceso a tus secretos.
La separación que implementé:
# .github/workflows/deploy.yml — separar install de deploy
jobs:
install-deps:
runs-on: ubuntu-latest
# Sin acceso a secrets de producción
steps:
- uses: actions/checkout@v4
- name: Instalar dependencias SIN lifecycle scripts
run: npm ci --ignore-scripts
- name: Ejecutar solo scripts de build conocidos
run: npm run build # solo lo que yo definí
deploy:
needs: install-deps
# Acá sí tenemos secrets — pero npm install ya terminó
environment: production
steps:
- name: Deploy a Railway
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: railway up
Qué cambié en mi stack después de esto
Tres cambios concretos que implementé en producción:
1. --ignore-scripts por defecto en CI
# En lugar de npm ci
npm ci --ignore-scripts
# Y un allowlist explícita para scripts legítimos
npm run build # solo mis propios scripts
2. Socket.dev en el pipeline
Socket.dev hace exactamente lo que npm audit no hace: analiza comportamiento, no solo CVEs. Tiene integración con GitHub Actions. Desde que lo agregué, bloqueó 2 paquetes que instalé distraído — uno con una network call en postinstall sin documentar, otro con acceso a process.env en runtime que no correspondía al propósito del paquete.
3. Audit manual de lifecycle scripts antes de merge
Automaticé la detección en el PR:
#!/bin/bash
# scripts/audit-lifecycle.sh — corre en pre-commit
# Detectar lifecycle scripts nuevos o modificados
git diff HEAD~1 package-lock.json | \
grep '"scripts"' -A 5 | \
grep -E '"(pre|post)?install"' && \
echo "⚠️ Lifecycle script detectado en cambio de dependencias — revisión manual requerida" && \
exit 1
echo "✓ Sin lifecycle scripts nuevos"
No es perfecto. Es un primer filtro.
FAQ — Supply chain attacks en npm y Node.js
¿npm audit es completamente inútil?
No, pero su alcance es mucho más chico de lo que parece. npm audit es bueno para vulnerabilidades conocidas con CVE asignado. Para eso funciona. El problema es que la mayoría de los supply chain attacks activos no tienen CVE al momento del ataque — el CVE llega después, cuando alguien descubre el problema. Para protección proactiva, necesitás herramientas adicionales como Socket.dev o Snyk con análisis de comportamiento.
¿Qué tan fácil es hacer un typosquatting attack en npm?
Técnicamente es trivial — crear una cuenta en npm y publicar un paquete con nombre similar a uno popular lleva minutos. npm tiene controles automáticos para nombres que son casi idénticos a paquetes muy descargados, pero el espacio de variantes es enorme y los controles tienen agujeros. El vector más efectivo hoy no es typosquatting directo sino inyección en el árbol transitivo: comprometer un paquete de tercer o cuarto nivel que nadie audita.
¿--ignore-scripts rompe algo en producción?
Depende del proyecto. Los casos donde rompe son: paquetes con binarios nativos que necesitan compilarse (node-sass, bcrypt, canvas), paquetes que generan tipos en postinstall, y algunos CLI tools. La solución es mantener una allowlist explícita de scripts que sabés que son legítimos y correrlos manualmente después. Para la mayoría de proyectos web, --ignore-scripts + build manual cubre el 95% sin fricciones.
¿El package-lock.json protege contra maintainer takeover?
Parcialmente. El lockfile fija la versión y el hash de integridad (campo integrity con SHA-512). Si el registry sirve un archivo diferente para la misma versión, el hash no va a matchear y la instalación falla. Pero si el atacante publicó una versión nueva (ej: 2.1.4 maliciosa en lugar de comprometer 2.1.3), y vos corrés npm update o aceptás el cambio en el lock, ya estás expuesto. El lock no te protege de actualizaciones que vos mismo aprobás.
¿Cuántos proyectos reales tienen lifecycle scripts en sus dependencias?
En mi experiencia con cuatro proyectos Node de producción: entre el 5% y el 8% de los paquetes del árbol transitivo tienen algún lifecycle script. La mayoría son legítimos (compilación de binarios). Pero en un árbol de 800 paquetes eso son entre 40 y 65 paquetes con capacidad de ejecutar código arbitrario en la máquina del developer o en CI con acceso a secrets.
¿Sirve de algo tener el CI en Docker para mitigar esto?
Bastante. Correr el build en un contenedor sin acceso a secrets de producción reduce la superficie de exfiltración significativamente. Lo cubrí en detalle cuando documenté mi stack Docker Compose en producción durante 30 días — la separación de contextos es uno de los beneficios que no aparece en los benchmarks de performance pero que en seguridad vale oro. No elimina el riesgo de supply chain attack, pero lo contiene: si un postinstall malicioso roba variables de entorno, en un CI bien configurado esas variables no deberían estar ahí todavía.
Lo que aprendí y lo que todavía no me cierra
El experimento confirmó lo que sospechaba: npm audit es una herramienta de compliance, no de seguridad. Marca una casilla. Le decís al auditor "corremos npm audit en CI" y técnicamente es verdad. Pero el modelo de amenaza que resuelve es el más fácil de los que existen.
Lo que sí me cierra: la combinación de --ignore-scripts en CI, Socket.dev para análisis de comportamiento, y revisión manual del diff de lifecycle scripts en PRs cubre los tres vectores que encontré. No es perfecto — nada lo es — pero la superficie de ataque se achica de forma concreta y medible.
Lo que no me cierra todavía: el problema de mantenimiento de paquetes abandonados es estructural. No hay señal clara en el ecosistema npm para distinguir "este paquete está estable y no necesita actualizaciones" de "este paquete está muerto y nadie va a detectar si lo comprometen". La actividad de commits no alcanza. Los downloads tampoco. Es un problema no resuelto y me da más miedo que cualquier CVE.
El mismo nerviosismo que tuve cuando inspeccioné lo que Chrome instalaba sin pedirme permiso lo tengo ahora cada vez que corro npm install sin --ignore-scripts. La diferencia es que en Chrome no podía hacer mucho. Acá sí tengo palancas. Y eso es exactamente lo que voy a seguir tirando.
Si corrés Node en producción y nunca auditaste los lifecycle scripts de tus dependencias transitivas, hacelo esta semana. No porque vayan a estar comprometidos — probablemente no. Sino porque no sabés si lo están, y esa incertidumbre es el verdadero problema.
¿Encontraste algo raro en el árbol de dependencias de tus propios proyectos? Contame — me interesa armar un mapa de patrones comunes en stacks Node reales.
Artículos Relacionados
pnpm vs npm vs yarn vs bun: la comparativa definitiva que nadie te va a dar en 2025
Usé los cuatro en proyectos reales. Uno me rompió un monorepo a las 3am. Otro me salvó la vida en producción. Te cuento todo sin filtros.
Mutex deadlock en producción: los patrones que encontré en mi codebase y cómo los diagnostiqué
Tres deadlocks en producción, todos con la misma cara: el servicio dejaba de responder sin error, sin panic, sin log. Lo que encontré al diagnosticarlos cambió cómo pienso el diseño de locks en async Rust.
Guardrails reales para agentes autónomos después de que uno casi me destruye la infra
Después de que un agente autónomo casi me borra la base de datos de producción, implementé una capa de guardrails real. Acá están los controles, el código y los logs que me salvaron el cuero.
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.