SSD Nodes Learn 🎉 VPS desde $5.50/mês
Guias Matt ConnorPor Matt Connor

Ataques da cadeia npm no seu servidor Node

Veja como patches maliciosos, scripts postinstall e typosquats chegam ao seu VPS, e qual pratica de deploy impede que uma nova versao seja instalada.

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 uma 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 pode obter várias centenas de pacotes que nunca leu, e qualquer um deles pode publicar uma nova versão daqui a uma hora.

A sua implementação obtém uma versão maliciosa porque o comando de instalação pediu a versão mais recente compatível. 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 implementa uma aplicação Node num único VPS. Essa ordem não seria usada por uma empresa de grande dimensão, porque uma empresa desse tipo tem um registo interno, uma equipa de revisão e um espelho do registo público. Você tem um script de implementação.

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

O registo npm não permite alterar 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. Publica 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 do git, implementado duas vezes na mesma tarde, pode instalar dois conjuntos de código diferentes. Essa diferença é a superfície de ataque. Não é necessário comprometer nada na sua máquina para que ela fique exposta.

As releases maliciosas são normalmente comunicadas e removidas, mas a remoção acontece depois de terem sido instaladas. Quem fez a implementação 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 com a conta do 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 de shell executados com a conta do 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, a resposta inclui ~/.npmrc, que contém um token do 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.

Uma carga útil 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 estado 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 observe 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 escrevem no seu terminal em vez de serem enviados para um 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 indivíduos, é 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 no registry público predefinido, portanto, a cópia pública pode ser escolhida. A correção é usar um escopo que controla, juntamente com um mapeamento de registry para esse escopo, em .npmrc:

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

Agora, @yourorg/billing-utils só é obtido desse host, porque o mapeamento de escopo para registry é consultado antes do registry predefinido. Um nome interno sem escopo não tem mapeamento e, por isso, não tem esta proteção.

Antes de adicionar uma nova dependência, analise-a 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 do 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 responsáveis transferem a manutenção dos pacotes. Alguém fica sem disponibilidade, outra pessoa oferece ajuda, os direitos de publicação mudam e nenhuma notificação chega aos projetos que dependem desse pacote. Nada foi comprometido. 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 ajudam a reduzi-la. Verifique quem pode publicar antes de adotar um pacote, usando a linha npm view acima. Depois, leia o diff quando houver alterações num pacote de que realmente depende:

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, o que é suficientemente rápido para ser executado em cada atualização de um pacote importante para si. Uma versão de correção que altere um script de build, adicione um ficheiro na raiz do pacote ou edite o bloco scripts merece ser lida na íntegra antes de chegar ao seu servidor.

Crie 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 pacote, um hash de integridade sha512 de cada tarball e o pacote que o exigiu. Faça commit deste 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 o portátil de um desenvolvedor:

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

npm ci difere de npm install em aspetos que são todos relevantes neste caso. 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, por isso uma instalação não pode avançar silenciosamente para outra versão. Se o lockfile e package.json não coincidirem, o comando termina com um erro em vez de resolver a diferença.

Esse erro é uma funcionalidade, não um incómodo. 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 transferência. Se os bytes de um tarball não corresponderem ao hash registado, a instalação falha com code EINTEGRITY em vez de o descompactar. Seja preciso sobre o que isto garante: prova que o ficheiro recebido é o ficheiro fixado pelo lockfile, a mesma garantia que verificar transferências com checksums oferece, e está limitada da mesma forma. 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, por isso vale a pena fazê-lo. Isto não remove uma dependência da sua á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 que o npm execute os scripts declarados nas dependências. save-exact=true faz com que npm install some-lib escreva 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 causa falhas, e deve saber como resolvê-las antes de ativar esta 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 termina com sucesso, mas a falha surge mais tarde, em runtime, como um módulo que não consegue carregar o seu ficheiro de binding. A solução é usar uma allowlist:

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

npm rebuild <package> executa os scripts de compilação desse pacote específico. Assim, tomou uma decisão para cada pacote, em vez de conceder permissão de execução abrangente 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 incluem um script postinstall. Numa aplicação típica, a lista é mais curta do que as pessoas esperam, 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 utilizadores. O código executado em runtime também pode fazê-lo.

Separe-os. 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 aplicar essa política. 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 listar em ReadWritePaths. Assim, uma tentativa da aplicação de escrever em node_modules falha com EROFS: read-only file system. Pode reproduzir o erro nos seus próprios logs em cerca de um minuto. NoExecPaths abrange o diretório de uploads com escrita permitida: o serviço pode escrever ficheiros nesse local, mas o kernel recusa-se a executá-los. Essa opção requer systemd 249 ou mais recente, e o Ubuntu 24.04 inclui a versão 255.

Esta unidade tem duas armadilhas. Primeiro, não adicione MemoryDenyWriteExecute=yes. Esta opção aparece na maioria das listas de hardening do systemd e impede o Node de iniciar, 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. Nesse caso, ProtectHome=yes oculta esse diretório do serviço e a unidade falha imediatamente com status=203/EXEC, acompanhada de uma linha de log a indicar que o executável não foi localizado.

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 e o respetivo nível de exposição, para que possa ver quais continuam com o valor predefinido. 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 ter 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.

Mantenha as credenciais de deploy fora do ambiente de build

Os scripts de instalação herdam o ambiente. Esse facto deve determinar onde faz o build.

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

npm token create --read-only

Um token com permissões 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 princípio aplica-se à automação de build que aloja internamente: um runner self-hosted do GitHub Actions mantém tokens e executa código publicado arbitrário em cada job, o que o torna a máquina de maior valor numa implementação pequena. Qualquer programa que não tenha escrito e que receba todo o seu ambiente pertence à mesma categoria. Por isso, manter os segredos fora do ambiente de um agente de IA é o mesmo problema com um programa diferente no meio.

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

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

As dependências transitivas são o primeiro caso. Você não controla de quais pacotes 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 a adicionar, para que o lockfile registe o resultado, e depois faça commit dos dois ficheiros.

O segundo caso é um pacote que você não consegue auditar nem remover. Inclua-o no repositório. npm pack descarrega o tarball exato que o registry serviria, e uma dependência file: é instalada 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 você altere o repositório. Você também passa a ser responsável pelas atualizações indefinidamente. Por isso, use esta abordagem para o pacote pequeno e abandonado do qual não consegue prescindir, e não para o seu framework web.

Também existe um período de espera que não custa nada:

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, defina uma data de uma ou duas semanas atrás. Assim, evita o intervalo em que uma versão maliciosa está disponível, mas ainda não foi comunicada. É uma medida pouco precisa, porque também atrasa 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.

Como sei qual versão foi realmente 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 lsnode_modules e, por isso, informa o que está fisicamente presente, em vez do que o lockfile pretendia instalar. A linha node -e lê o manifesto instalado pelo caminho, o que funciona mesmo para pacotes cujo campo exports bloqueia importações de subcaminhos, e imprime uma única versão sem desenhar a á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á a ser executado agora" passa a ser readlink /srv/nodeapp/current e 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

Isto valida as assinaturas do registry dos pacotes na árvore instalada e valida as atestações de proveniência dos pacotes que as possuem. A proveniência associa um tarball publicado à build pública 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 release maliciosa 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, presuma que tudo o que estava acessível ao utilizador de build foi comprometido. Faça a rotação do token do registry, das chaves SSH no diretório pessoal desse utilizador, 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 restrita, o conjunto acessível é muito menor: as próprias variáveis de ambiente da aplicação e tudo o que o acesso de rede dela conseguir alcançar. Esse é o motivo para executar serviços como utilizadores sem privilégios numa VPS. Isso não impede o comprometimento. Determina quanto da máquina o comprometimento alcança e se sobrevive a um reinício.

Depois, faça um rebuild em vez de limpar o sistema. Elimine node_modules, fixe o pacote afetado numa versão abaixo da versão maliciosa em package.json, execute npm install uma vez para atualizar o lockfile, faça commit e faça o deploy 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 as suas releases tiverem nomes baseados em commits.

O que nada disto 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. Todas as práticas acima fazem a mesma conversão, de acidente para 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 bugs antigos e conhecidos, mas não espere que detete uma versão lançada há quatro horas.

Reduzir o número de dependências ajuda mais do que qualquer ferramenta deste guia, e é o conselho menos popular. Cada pacote que não adiciona representa mais um publicador 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 ao gestor de pacotes da sua distribuição. O problema manifesta-se mais no npm porque as árvores são mais profundas e os scripts de instalação são executados por predefinição. A parte da máquina envolvente que lhe cabe proteger depende de onde ela é executada, o que faz parte da questão mais ampla sobre a segurança do alojamento VPS.

FAQ

O npm ci protege contra um pacote npm comprometido?

Protege contra a alteração da versão sem o seu conhecimento. 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 diferentes, em vez de resolver a diferença. Isto não indica se a versão fixada é segura. Se fizer commit de um lockfile que fixa uma versão maliciosa, 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. ignore-scripts=true no .npmrc do projeto impede a execução dos scripts de instalação das dependências, eliminando 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 devido à ausência do ficheiro de binding, em vez de falharem durante a instalação. Execute npm ci --ignore-scripts e, depois, npm rebuild <package> para os poucos pacotes que decidiu confiar. npm query ":attr(scripts, [postinstall])" mostra quantos são realmente.

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

Leia o disco, não o lockfile. npm ls <package> indica o que está presente em node_modules, e node -e "console.log(require('./node_modules/<package>/package.json').version)" apresenta apenas a string 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 mais tarde, quando precisar delas.

O npm audit deteta ataques à cadeia de fornecimento?

Não. npm audit compara a sua árvore com uma base de dados de vulnerabilidades comunicadas. Por isso, só deteta problemas que já foram publicados e receberam um identificador. Uma release maliciosa não é comunicada durante as horas ou os dias em que a sua instalação é relevante. 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 produziu. Isto indica se um tarball veio de uma build pública ou de uma máquina desconhecida.

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

Porque as duas falhas têm alcance diferente e é necessário 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 próprio ambiente da aplicação e à respetiva base de dados. Separar as contas também significa que o processo que serve o tráfego não pode reescrever node_modules. Assim, um comprometimento em runtime desaparece no reinício seguinte, em vez de se tornar permanente.