Saltar al contenido

Ataques de confusión de dependencias y cómo prevenirlos

Cómo los ataques de confusión de dependencias explotan los gestores de paquetes para inyectar código malicioso, y las defensas concretas que lo evitan.

5 min de lectura
Diagrama que muestra un paquete público malicioso reemplazando un paquete interno privado mediante la manipulación del número de versión

En febrero de 2021, un investigador de seguridad demostró que podía ejecutar código dentro de las redes de Apple, Microsoft, PayPal y decenas de otras empresas aprovechando una vulnerabilidad en la forma en que los gestores de paquetes resuelven las dependencias. A esta técnica se le llama confusión de dependencias, y funciona porque la mayoría de gestores de paquetes consultan los registros públicos antes —o junto— a los privados.

Si tu organización publica paquetes internos en un registro privado, eres vulnerable a menos que hayas tomado medidas explícitas para evitarlo.

Cómo funciona el ataque

El ataque se aprovecha de la brecha entre los registros de paquetes privados y públicos. Cuando un proyecto depende de @company/auth-utils desde un registro privado, el gestor de paquetes también podría consultar el registro público de npm. Si un atacante publica un paquete llamado auth-utils (sin el scope) en npm con un número de versión superior, algunas configuraciones preferirán la versión pública.

jsonjson
// ❌ Vulnerable package.json — unscoped private package names
{
  "dependencies": {
    "auth-utils": "^1.2.0",
    "payment-service-sdk": "^3.0.0",
    "internal-logger": "^2.1.0"
  }
}
// An attacker publishes "auth-utils@99.0.0" to npm
// Package manager sees the higher version on the public registry
// Installs the attacker's malicious package instead
jsonjson
// ✅ Scoped packages with registry pinning
{
  "dependencies": {
    "@yourcompany/auth-utils": "^1.2.0",
    "@yourcompany/payment-service-sdk": "^3.0.0",
    "@yourcompany/internal-logger": "^2.1.0"
  }
}
// Scoped packages (@yourcompany/*) route to your private registry
// Attacker cannot claim your organization's scope on npm

El ataque depende de tres condiciones: nombres de paquetes internos sin scope, un gestor de paquetes que consulta ambos registros, y la ausencia de versiones fijas o comprobaciones de integridad. Elimina cualquiera de ellas y el ataque fracasa.

Capa de defensa 1: Asignar scope a todos los paquetes internos

La defensa más sencilla y efectiva es usar scopes de npm para todos los paquetes internos. Cuando eres dueño del scope @yourcompany en el registro público de npm, nadie más puede publicar paquetes bajo ese scope.

shbash
# Claim your organization's scope on npm (even if you never publish there)
npm login --registry=https://registry.npmjs.org
npm org create yourcompany
# Now no attacker can publish @yourcompany/* packages publicly
tstypescript
// ❌ Internal package without scope
// package.json of your internal library
{
  "name": "feature-flags-sdk",
  "version": "1.3.0"
}
// Attacker can publish "feature-flags-sdk@99.0.0" on npm
 
// ✅ Internal package with scope
{
  "name": "@yourcompany/feature-flags-sdk",
  "version": "1.3.0"
}
// Only members of @yourcompany npm org can publish this

Incluso si nunca publicas paquetes en el registro público, reclama tu scope de organización de forma defensiva. Es gratis y bloquea el vector de ataque más común.

Capa de defensa 2: Configuración del registro

Configura tu gestor de paquetes para que dirija los scopes internos a tu registro privado y todo lo demás al registro público. Nunca dejes que el gestor de paquetes "busque" en ambos registros el mismo paquete.

iniini
# .npmrc — explicit registry routing
# All @yourcompany packages come from your private registry
@yourcompany:registry=https://npm.yourcompany.com/
 
# Everything else comes from the public registry
registry=https://registry.npmjs.org/
 
# Optional: require authentication for private registry
//npm.yourcompany.com/:_authToken=${NPM_PRIVATE_TOKEN}
ymlyaml
# .yarnrc.yml (Yarn Berry / Yarn 2+)
npmScopes:
  yourcompany:
    npmRegistryServer: "https://npm.yourcompany.com/"
    npmAuthToken: "${NPM_PRIVATE_TOKEN}"
 
npmRegistryServer: "https://registry.npmjs.org/"
tstypescript
// ❌ Single registry fallback — dangerous
// .npmrc with only:
// registry=https://npm.yourcompany.com/
// If private registry is down, npm falls back to public registry
// Attacker waits for an outage, or the fallback fetches malicious packages
 
// ✅ Explicit routing — no fallback ambiguity
// .npmrc with scoped registries as shown above
// @yourcompany/* → private registry (no fallback)
// everything else → public npm (no confusion)

Capa de defensa 3: Archivos de bloqueo y comprobaciones de integridad

Los archivos de bloqueo fijan versiones exactas e incluyen hashes de integridad. Incluso si el gestor de paquetes resuelve una versión maliciosa, la discrepancia del hash de integridad hace que la instalación falle.

jsonjson
// package-lock.json includes integrity hashes
{
  "node_modules/@yourcompany/auth-utils": {
    "version": "1.2.0",
    "resolved": "https://npm.yourcompany.com/@yourcompany/auth-utils/-/auth-utils-1.2.0.tgz",
    "integrity": "sha512-abc123def456..."
  }
}
shbash
# CI should always use frozen lockfile installation
# npm
npm ci  # Fails if lock file is out of sync with package.json
 
# yarn
yarn install --frozen-lockfile
 
# pnpm
pnpm install --frozen-lockfile
shbash
# ❌ Running 'npm install' in CI
# This can update the lock file, potentially pulling in new versions
npm install
 
# ✅ Running 'npm ci' in CI
# This installs exactly what the lock file specifies
# Fails fast if lock file and package.json disagree
npm ci

Confirma tu archivo de bloqueo en el control de versiones y exige npm ci (o equivalente) en todos los pipelines de CI. Cualquier desarrollador que ejecute npm install y obtenga cambios inesperados en el archivo de bloqueo debe investigar antes de confirmarlos.

Capa de defensa 4: Auditoría automatizada

Ejecuta auditorías de dependencias como parte de tu pipeline de CI. Busca vulnerabilidades conocidas y fuentes de paquetes inesperadas.

ymlyaml
# GitHub Actions — dependency audit step
name: Security Audit
on:
  pull_request:
    paths:
      - 'package.json'
      - 'package-lock.json'
      - '.npmrc'
 
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
 
      - name: Install with frozen lockfile
        run: npm ci
 
      - name: Run npm audit
        run: npm audit --audit-level=high
 
      - name: Check package sources
        run: |
          # Verify all @yourcompany packages resolve to private registry
          grep -E '"resolved": "https://npm.yourcompany.com' package-lock.json \
            | wc -l
          # Verify NO @yourcompany packages resolve to public registry
          if grep -E '@yourcompany.*registry.npmjs.org' package-lock.json; then
            echo "ERROR: Internal package resolving to public registry!"
            exit 1
          fi
tstypescript
// Custom script to validate package sources
import { readFileSync } from 'fs';
 
interface PackageLockEntry {
  version: string;
  resolved: string;
  integrity: string;
}
 
function auditPackageSources(lockfilePath: string): void {
  const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf-8'));
  const packages = lockfile.packages || {};
 
  for (const [name, meta] of Object.entries<PackageLockEntry>(packages)) {
    if (name.includes('@yourcompany/')) {
      const isPrivateRegistry = meta.resolved?.startsWith(
        'https://npm.yourcompany.com'
      );
      if (!isPrivateRegistry) {
        console.error(
          `SECURITY: ${name} resolves to unexpected registry: ${meta.resolved}`
        );
        process.exit(1);
      }
    }
  }
 
  console.log('All internal packages resolve to private registry.');
}
 
auditPackageSources('package-lock.json');

Resumen de la defensa en profundidad

Ninguna defensa por sí sola es suficiente. Apílalas:

ymlyaml
defense_layers:
  1_scoping:
    action: "Scope all internal packages under @yourcompany"
    blocks: "Attacker cannot claim your scope on public registry"
 
  2_registry_routing:
    action: "Pin scopes to specific registries in .npmrc"
    blocks: "Package manager never queries wrong registry"
 
  3_lock_files:
    action: "Use npm ci with integrity hashes in CI"
    blocks: "Even if resolution is wrong, hash mismatch stops install"
 
  4_auditing:
    action: "Automated CI checks for package source URLs"
    blocks: "Catches misconfigurations before they reach production"
 
  5_monitoring:
    action: "Alert on new packages appearing in lock file diffs"
    blocks: "Human review of unexpected dependency changes"

Conclusiones clave

  1. La confusión de dependencias explota el orden de resolución del gestor de paquetes — los paquetes públicos con versiones superiores anulan a los privados
  2. Asigna scope a todos los paquetes internos (@yourcompany/*) y reclama el scope de la organización en los registros públicos de forma defensiva
  3. Fija los paquetes con scope a tu registro privado en .npmrc — nunca dejes que los gestores de paquetes busquen el mismo nombre en ambos registros
  4. Usa npm ci en los pipelines de CI — los archivos de bloqueo congelados con hashes de integridad detectan discrepancias de resolución
  5. Automatiza la auditoría de fuentes — verifica que los paquetes internos siempre se resuelvan en tu registro privado, no en npm
  6. Capas tus defensas — el scope, la configuración del registro, los archivos de bloqueo y la auditoría juntos proporcionan una protección robusta
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX