SSD Nodes Learn Hosting plans →
Guias Matt ConnorPor Matt Connor · Atualizado 2026-08-27

Ataques da cadeia npm: como proteger seu servidor

Veja como ataques do npm chegam a um app Node no VPS: releases maliciosos, scripts postinstall, typosquatting e o deploy que bloqueia versões comprometidas.

O que é um ataque à cadeia de fornecimento do npm no seu servidor

Um ataque à cadeia de fornecimento do npm chega ao seu servidor através de um pacote que escolheu instalar. Não envolve nenhuma porta aberta nem uma etapa de exploração. O npm (node package manager) instala código, e instalar código executa código. Por isso, uma pequena aplicação Node instala várias centenas de pacotes que nunca leu, e qualquer um deles pode publicar uma nova versão dentro de uma hora.

O seu deploy obtém uma versão maliciosa porque o comando de instalação pediu a versão mais recente que corresponde aos requisitos. Esse código é executado com os privilégios de quem executou a instalação. Tudo o que se segue resulta dessas duas frases.

Os cenários estão ordenados pela frequência com que afetam uma pessoa que faz o deploy de uma aplicação Node num VPS. Essa ordem não seria usada por uma empresa de grande dimensão, porque uma empresa desse tipo tem um registry interno, uma equipa de revisão e um mirror do registry público. Você tem um script de deploy.

Forma 1: uma conta de maintainer é comprometida e publica um patch

O registry do npm não permite que alguém altere o conteúdo de uma versão que já existe. Por isso, um atacante que faça phishing a um maintainer ou roube um token de publicação não consegue reescrever 4.18.2. Pode publicar 4.18.3.

Consulte o seu package.json. Uma linha como "express": "^4.18.2" não significa a versão 4.18.2. O acento circunflexo significa “qualquer versão 4.x igual ou superior a esta”, e ~4.18.2 significa “qualquer versão 4.18.x”. npm install resolve esse intervalo no momento da execução. Assim, o mesmo commit git, implementado duas vezes na mesma tarde, pode instalar dois conjuntos de código diferentes. Essa diferença é a superfície de ataque. Nada na sua máquina precisa de ser comprometido para que ela se abra.

As releases maliciosas são normalmente comunicadas e removidas, mas a remoção ocorre depois de as pessoas as terem instalado. Quem fez o deploy durante esse intervalo tem o código no disco. Um pipeline que resolve intervalos em cada execução entra automaticamente nesse intervalo várias vezes por semana, sem que ninguém decida fazê-lo.

Forma 2: um script de instalação é executado pelo utilizador que faz o deploy

O package.json de um pacote pode declarar preinstall, install, postinstall e prepare no seu bloco scripts. O npm executa-os durante a instalação. Estes scripts não estão isolados e ninguém os revê. São comandos shell executados pelo utilizador que introduziu o comando de instalação, no diretório pessoal desse utilizador, com o acesso de rede desse utilizador e todo o ambiente dessa shell.

Por isso, a pergunta útil não é o que o pacote pode fazer. É o que esse utilizador pode ler. Num servidor normal de deploy, isso inclui ~/.npmrc, que contém um token de registry, ~/.ssh/id_ed25519, usado como chave de deploy para SSH (secure shell), ~/.aws/credentials, ~/.docker/config.json e todas as variáveis exportadas na shell, que é normalmente onde DATABASE_URL está definido.

Um payload deste tipo não precisa de persistência nem de escalada de privilégios. Lê alguns ficheiros, envia-os para um host através de HTTPS e termina com o status 0. Não vê nada porque o npm oculta a saída dos scripts de instalação por predefinição. Desative essa opção e monitorize o que é realmente executado:

npm ci --foreground-scripts

foreground-scripts partilha a entrada, a saída e o erro padrão com o processo do npm. Assim, os scripts de compilação mostram a saída no seu terminal, em vez de a escreverem num buffer que o npm descarta quando a instalação é concluída com sucesso.

Forma 3: typosquats e o nome que não escreveu exatamente

Um typosquat é um pacote publicado com um nome semelhante ao de um pacote popular, à espera de um comando de instalação escrito ou colado incorretamente. O mecanismo é o comando, não o código. Por isso, um lockfile não ajuda neste caso: adiciona o nome errado uma vez e, a partir daí, o lockfile fixa-o corretamente.

A variante que afeta equipas, e não apenas utilizadores individuais, é a confusão de dependências. O seu pacote interno chama-se billing-utils e está num registry privado. Se não existir nenhum pacote chamado billing-utils no registry público, qualquer pessoa pode publicar um. O npm resolve nomes sem escopo usando o registry público predefinido, por isso a cópia pública pode ser escolhida. A correção é usar um escopo que controla e uma associação desse escopo a um registry, em .npmrc:

@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}

Agora, @yourorg/billing-utils só é obtido desse host, porque a associação entre o escopo e o registry é consultada antes do registry predefinido. Um nome interno sem escopo não tem nenhuma associação, por isso não tem essa proteção.

Antes de adicionar uma nova dependência, analise o pacote em vez de olhar para o respetivo indicador de downloads:

npm view some-lib repository.url maintainers time.created time.modified

Um pacote criado no mês passado e publicado por uma conta que não consegue associar a um repositório público representa um risco diferente de um pacote com seis anos de histórico. Nenhum destes factos é uma prova. Ambos são fáceis de verificar.

Forma 4: a dependência cujo responsável mudou sem aviso

Os mantenedores transferem a responsabilidade pelos pacotes. Alguém fica sem disponibilidade, outra pessoa oferece ajuda, os direitos de publicação mudam e nenhum aviso chega aos projetos que dependem desse pacote. Não houve comprometimento. A confiança que concedeu em 2021 está agora nas mãos de outra pessoa.

Esta é a forma mais lenta e mais difícil de detetar, e nenhum comando responde diretamente a esta questão. Há duas medidas que reduzem o risco. Verifique quem pode publicar antes de adotar um pacote, usando a linha npm view acima. Depois, leia o diff quando um pacote de que realmente depende for atualizado:

npm diff --diff-name-only --diff=some-lib@1.4.2 --diff=some-lib@1.4.3
npm diff --diff=some-lib@1.4.2 --diff=some-lib@1.4.3

A primeira forma mostra apenas os nomes dos ficheiros alterados. É suficientemente rápida para ser executada em todas as atualizações de um pacote importante. Vale a pena ler integralmente uma versão de correção que altere um script de build, adicione um ficheiro na raiz do pacote ou edite o bloco scripts antes de ela chegar ao seu servidor.

Compilar a partir de um lockfile versionado com npm ci

package-lock.json regista a versão exata de cada pacote na árvore, o URL de origem de cada um, um hash de integridade sha512 de cada tarball e o pacote que o exigiu. Faça commit desse ficheiro. É o único ficheiro que indica o que foi efetivamente testado.

Depois, instale com npm ci, nunca com npm install, em qualquer máquina que não seja um portátil de desenvolvimento:

npm ci --omit=dev --ignore-scripts

npm ci difere de npm install em aspetos que são todos importantes neste contexto. Exige que exista um lockfile. Remove qualquer node_modules existente antes de começar, para que restos de uma implementação anterior não permaneçam nesta implementação. Nunca escreve em package.json nem no lockfile, pelo que uma instalação não pode avançar silenciosamente para outra versão. Se o lockfile e package.json não coincidirem, termina com um erro em vez de resolver a diferença.

Esse erro é uma funcionalidade, não um inconveniente. Significa que uma alteração de dependência tem de chegar como um commit revisto por alguém, e não como efeito secundário de uma implementação às 02:00.

O hash de integridade é verificado em cada download. Um tarball cujos bytes não correspondam ao hash registado faz a instalação falhar com code EINTEGRITY, em vez de ser descompactado. Seja preciso sobre o que isto garante: prova que o ficheiro recebido é o ficheiro fixado pelo lockfile. É a mesma garantia que verificar downloads com checksums fornece, com as mesmas limitações. Não indica se a versão fixada era maliciosa quando foi publicada.

Um detalhe sobre --omit=dev: esses pacotes continuam a ser resolvidos e continuam a ser escritos no lockfile. Apenas não são colocados no disco. Menos pacotes no disco significa menos scripts de instalação e menos código carregado em runtime, pelo que vale a pena fazê-lo. Isto não remove uma dependência da árvore.

Trate os scripts de instalação como código e saiba recusá-los

Pode desativar os scripts de instalação. Coloque isto no .npmrc do projeto e faça commit juntamente com o lockfile:

ignore-scripts=true
save-exact=true

ignore-scripts=true impede o npm de executar os scripts declarados nas dependências. save-exact=true faz o npm install some-lib escrever 1.4.2 em package.json em vez de ^1.4.2, para que um intervalo de resolução nunca entre acidentalmente no seu manifest.

Isto quebra algumas funcionalidades, e deve saber como lidar com isso antes de ativar a opção. Os pacotes que compilam um addon nativo ou descarregam um binário pré-compilado fazem esse trabalho num script de instalação. Com os scripts desativados, a instalação é concluída, mas a falha aparece mais tarde, em runtime, como um módulo que não consegue carregar o seu ficheiro de binding. A solução é uma allowlist:

npm ci --ignore-scripts
npm rebuild better-sqlite3

npm rebuild <package> executa os scripts de compilação desse pacote específico. Assim, toma uma decisão por pacote, em vez de conceder permissões de execução indiscriminadas a algumas centenas de desconhecidos que nunca irá conhecer.

Para ver a dimensão atual dessa concessão, consulte o npm:

npm query ":attr(scripts, [postinstall])"

Isto apresenta todos os pacotes da árvore instalada que contêm um script postinstall. Numa aplicação típica, a lista é mais curta do que se espera, o que torna a allowlist prática.

Separe a compilação do processo que serve o tráfego

O utilizador de deploy precisa de escrever em node_modules. O processo que responde aos pedidos HTTP não precisa. Se forem a mesma conta, o código executado durante a instalação pode reescrever o código que serve os seus utilizadores, e o código executado em runtime também pode reescrevê-lo.

Separe as funções. Faça a compilação com um utilizador, sirva a aplicação com outro e torne o diretório servido apenas de leitura para a conta que executa o serviço:

sudo useradd --system --home-dir /srv/nodeapp --shell /usr/sbin/nologin nodeapp
sudo chown -R deploy:nodeapp /srv/nodeapp
sudo chmod -R o-rwx /srv/nodeapp

Depois, deixe o systemd impor essa separação. Escreva /etc/systemd/system/nodeapp.service:

[Unit]
Description=Node application
After=network-online.target

[Service]
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/nodeapp/current
EnvironmentFile=/etc/nodeapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/nodeapp/shared
NoExecPaths=/srv/nodeapp/shared
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

[Install]
WantedBy=multi-user.target

ProtectSystem=strict monta todo o sistema de ficheiros como apenas de leitura para este serviço, exceto /dev, /proc, /sys e tudo o que indicar em ReadWritePaths. Assim, uma tentativa da aplicação para escrever em node_modules falha com EROFS: read-only file system. Pode verificar e reproduzir isto nos seus próprios logs em cerca de um minuto. NoExecPaths trata do diretório de uploads com escrita: o serviço pode escrever ficheiros nesse diretório, mas o kernel recusa-se a executá-los. Essa opção requer o systemd 249 ou mais recente, e o Ubuntu 24.04 inclui a versão 255.

Há duas armadilhas neste ficheiro de unidade. Primeiro, não adicione MemoryDenyWriteExecute=yes. Essa opção aparece na maioria das listas de hardening do systemd e impede o arranque do Node, porque o V8 compila JavaScript para código de máquina em runtime e precisa de páginas que sejam simultaneamente graváveis e executáveis. Segundo, obtenha o caminho ExecStart a partir de command -v node. Se o Node tiver sido instalado com um gestor de versões, ficará no diretório pessoal do utilizador de deploy. ProtectHome=yes oculta esse diretório do serviço, e a unidade falha imediatamente com status=203/EXEC e uma linha no log a indicar que o executável não foi encontrado.

Verifique o resultado em vez de confiar no ficheiro:

sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probe

systemd-analyze security lista todas as definições de hardening com o respetivo nível de exposição, para que possa ver quais continuam com o valor predefinido. O touch deve falhar com Permission denied, porque nodeapp não é proprietário de nada em current. Se for bem-sucedido, a propriedade dos ficheiros está incorreta e as definições do systemd estão a ocultar silenciosamente esse problema.

Uma nota sobre EnvironmentFile: o systemd lê esse ficheiro como root antes de mudar para User=nodeapp, por isso o ficheiro pode ser root:root com o modo 600. A aplicação continua a receber as variáveis. Qualquer pessoa com uma shell como nodeapp ainda pode lê-las a partir de /proc/<pid>/environ. Isto protege o segredo em repouso, mas não o processo em execução.

Manter as credenciais de deploy fora do ambiente de build

Os scripts de instalação herdam o ambiente. Esse facto, por si só, deve determinar onde faz o build.

A opção mais segura é fazer o build noutro local que não seja o servidor de produção e copiar o diretório final para lá. A máquina de build fica então apenas com um token de registry só de leitura. Não fica com uma chave SSH de deploy, uma chave de acesso à cloud, uma palavra-passe da base de dados nem credenciais de login de um container registry.

npm token create --read-only

Um token só de leitura pode obter pacotes, mas não pode publicá-los. Se for roubado do ambiente de build, a perda limita-se à capacidade de descarregar pacotes públicos.

Se tiver de fazer o build no servidor, faça-o como o utilizador deploy, com um ambiente deliberadamente restrito, e mantenha os segredos de runtime em /etc/nodeapp/env, que deploy não pode ler. O mesmo raciocínio aplica-se à automação de build que aloja por conta própria: um runner self-hosted do GitHub Actions guarda tokens e executa código publicado arbitrário em cada job, o que faz dele a máquina de maior valor numa pequena implementação. Qualquer programa que não tenha escrito e que receba todo o seu ambiente pertence à mesma categoria. É por isso que manter os segredos fora do ambiente de um agente de IA é o mesmo problema com um programa diferente no meio.

Fixe a versão ou inclua no repositório o que não consegue auditar

Uma dependência com versão fixa é aquela cuja versão não pode mudar sem um commit. O lockfile versionado já faz isso para toda a árvore. Há dois casos que exigem medidas adicionais.

As dependências transitivas são o primeiro caso. Não controla aquilo de que as suas dependências dependem. overrides em package.json força uma versão em qualquer ponto da árvore:

{
  "overrides": {
    "some-transitive-lib": "1.4.2"
  }
}

Execute npm install uma vez depois de o adicionar, para que o lockfile registe o resultado, e depois faça commit dos dois ficheiros.

O segundo caso é um pacote que não consegue auditar nem remover. Inclua-o no repositório. npm pack descarrega o tarball exato que o registry forneceria, e uma dependência file: instala a partir da sua cópia:

npm pack some-lib@1.4.2
mkdir -p vendor && mv some-lib-1.4.2.tgz vendor/
{
  "dependencies": {
    "some-lib": "file:vendor/some-lib-1.4.2.tgz"
  }
}

O tarball passa a estar no seu repositório e não pode mudar sem que o altere. Também passa a ser responsável pelas atualizações dele para sempre, por isso use esta opção para o pequeno pacote abandonado de que não consegue prescindir, não para o seu framework web.

Também existe um período de arrefecimento que não tem qualquer custo:

npm install --before=2026-08-01

A opção before reconstrói a árvore usando apenas versões publicadas nessa data ou antes dela. Ao atualizar dependências, recue uma ou duas semanas na data. Assim, ignora o período em que uma versão problemática está disponível, mas ainda não foi reportada. É uma medida pouco precisa, porque também adia correções de segurança legítimas. Use-a para resolver os intervalos de versões, leia o que mudou e depois faça commit do lockfile. A mesma abordagem é útil para as ferramentas de linha de comandos que instala a partir do npm em vez de usar como dependências. Uma chamada npx sem versão fixa descarrega o que tiver sido lançado nessa manhã, enquanto fixar uma versão exata do dsh garante que duas máquinas executam o mesmo código.

Como saber qual versão foi efetivamente instalada?

O lockfile no git indica o que deveria ter sido instalado. O disco indica o que está instalado. Apenas o segundo é uma evidência.

npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"

npm ls lê node_modules, portanto informa o que está fisicamente presente, e não o que o lockfile pretendia instalar. A linha node -e lê o manifest instalado pelo caminho. Isso funciona mesmo para pacotes cujo campo exports impede importações de subcaminhos e imprime uma versão sem desenhar uma árvore à volta.

Para obter a outra parte da comparação, leia o git:

git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'

Torne a relação entre os dois permanente incluindo o commit no layout de deploy. Faça o release em /srv/nodeapp/releases/<short commit sha> e aponte /srv/nodeapp/current para ele com um symlink. A resposta à pergunta "o que está em execução agora" passa a ser readlink /srv/nodeapp/current. Essa informação fica disponível às 03:00 para alguém que não foi a pessoa que fez o deploy.

Por fim, verifique o que o registry pode confirmar:

npm audit signatures

Isso valida as assinaturas do registry nos pacotes da árvore instalada e valida as atestações de proveniência dos pacotes que as tiverem. A proveniência associa um tarball publicado ao build público de integração contínua (CI) que o produziu. Assim, uma atestação validada permite rastrear o código até um commit, em vez de até um laptop desconhecido. A cobertura não é universal. Por isso, interprete uma atestação em falta como "sem informação", e não como "pacote inválido".

O que fazer depois de uma versão comprometida chegar ao seu servidor

Comece pelo que foi executado e pelo utilizador que o executou.

Se o código foi executado durante a instalação, assuma que tudo o que estava legível para o utilizador de compilação foi comprometido. Faça a rotação do token do registry, das chaves SSH no respetivo diretório pessoal, das credenciais cloud e de qualquer segredo exportado nessa shell. A rotação é a única resposta adequada, porque não é possível provar que um ficheiro não foi lido.

Se o código foi executado em runtime por uma conta de serviço com privilégios limitados, o conjunto acessível é muito menor: as próprias variáveis de ambiente da aplicação e tudo o que estiver ao alcance do respetivo acesso à rede. Esse é o fundamento de executar serviços como utilizadores sem privilégios num VPS. Isto não impede o comprometimento. Determina que parte da máquina fica acessível e se o comprometimento sobrevive a um reinício.

Depois, faça um novo build em vez de limpar o sistema. Elimine node_modules, fixe o pacote afetado numa versão anterior à versão comprometida em package.json, execute npm install uma vez para atualizar o lockfile, faça commit e implemente com npm ci. Não repare uma árvore no local. Não é possível enumerar tudo o que um script de instalação alterou.

Registe também a janela temporal: o primeiro deploy que poderia ter obtido a versão e o deploy que a removeu. Esse intervalo indica quais dos seus próprios logs deve consultar. Só é possível responder a essa questão se os seus releases tiverem nomes baseados em commits.

O que nada disso corrige

Um lockfile não torna uma dependência segura. Ele transforma o momento em que aceitou essa dependência numa decisão datada e revista, em vez de num efeito secundário de um deploy. Cada prática acima faz a mesma conversão: transforma um acidente numa escolha.

npm audit não é uma defesa neste caso. Compara a sua árvore com uma base de dados de vulnerabilidades comunicadas. Por isso, encontra problemas que já foram publicados e identificados. Um ataque à cadeia de fornecimento não tem nome durante todo o período em que é útil para o atacante. Execute npm audit para detetar problemas antigos e conhecidos. Não espere que indique algo sobre um release disponibilizado há quatro horas.

Reduzir o número de dependências ajuda mais do que qualquer ferramenta deste guia. É também o conselho menos popular. Cada pacote que não adiciona representa mais um publisher que não pode ser alvo de phishing em seu nome e mais um script de instalação que nunca é executado com o seu utilizador de deploy.

Nada disto é específico do npm. As mesmas quatro formas aplicam-se ao PyPI, RubyGems, imagens de contentores e gestor de pacotes da sua distribuição. O problema manifesta-se mais no npm porque as árvores de dependências são mais profundas e os scripts de instalação são executados por predefinição. Tudo o que amplia uma ferramenta que já utiliza herda o mesmo problema. Por isso, determinar a que recursos um plugin do dsh pode aceder antes de o instalar é o mesmo exercício que ler um script postinstall, mas com as permissões do seu agente no lugar das do utilizador de implementação. A parte da máquina envolvente que lhe cabe proteger depende do local onde o agente é executado. Esta é uma parte da questão mais ampla sobre a segurança do alojamento em VPS.

FAQ

O npm ci protege contra um pacote npm comprometido?

Protege contra a alteração da versão sem o seu conhecimento. O npm ci instala exatamente o que package-lock.json regista, verifica cada tarball com o respetivo hash de integridade sha512 e termina com um erro se package.json e o lockfile forem incompatíveis, em vez de resolver a diferença. Não indica se a versão fixada é segura. Se fizer commit de um lockfile que fixa uma versão maliciosa, o npm ci instalará fielmente essa versão em todos os servidores que administra, sempre.

Devo definir ignore-scripts=true para tudo?

Defina-o e depois crie uma allowlist. O ignore-scripts=true no .npmrc do projeto impede a execução dos scripts de instalação das dependências. Isso elimina o caminho mais direto entre um pacote malicioso e as credenciais do utilizador de deploy. Os pacotes que compilam um addon nativo ou obtêm um binário pré-compilado precisam realmente dos seus scripts. Com os scripts desativados, falham mais tarde em runtime com um ficheiro de binding em falta, em vez de falharem durante a instalação. Execute npm ci --ignore-scripts e depois npm rebuild <package> para os poucos pacotes em que decidiu confiar. O npm query ":attr(scripts, [postinstall])" mostra quantos existem realmente.

Como descubro qual versão de um pacote o meu servidor instalou efetivamente?

Consulte o disco, não o lockfile. O npm ls <package> indica o que está presente em node_modules, e node -e "console.log(require('./node_modules/<package>/package.json').version)" imprime apenas a cadeia da versão. O lockfile no git responde a uma pergunta diferente: o que deveria ter sido instalado. Comparar os dois é precisamente o objetivo. Fazer o deploy para um diretório com o nome do commit do git mantém ambas as respostas disponíveis meses depois, quando precisar delas.

O npm audit deteta ataques à cadeia de fornecimento?

Não. O npm audit compara a sua árvore com uma base de dados de vulnerabilidades comunicadas. Por isso, só encontra problemas que já tenham sido publicados e recebido um identificador. Uma release maliciosa ainda não foi comunicada durante as horas ou os dias em que a sua instalação é relevante. O npm audit signatures é o comando mais útil: verifica as assinaturas do registry em toda a árvore instalada e verifica as atestações de proveniência quando o publisher as disponibiliza. Isso indica se um tarball veio de um build público, em vez de uma máquina desconhecida.

Porque é importante executar a aplicação com um utilizador sem privilégios se o ataque ocorre durante a instalação?

Porque as duas falhas têm alcance diferente e deve proteger-se contra ambas. O código executado durante a instalação corre como o utilizador de deploy e pode ler as chaves SSH, os tokens do registry e as credenciais cloud desse utilizador. O código em runtime corre como a conta de serviço. Com User=nodeapp, ProtectSystem=strict e sem credenciais no disco, o seu alcance fica limitado ao ambiente da própria aplicação e à respetiva base de dados. Separar as contas também significa que o processo que serve tráfego não pode reescrever node_modules. Assim, um comprometimento em runtime desaparece no reinício seguinte, em vez de se tornar permanente.