Voltar para todas as publicações
Publicado em · por Renaud Deraison

O sinal era o carimbo de data e hora

Em 8 de julho de 2026 o npm lançou a sua maior mudança de segurança em dezasseis anos — scripts de instalação desativados por padrão. Em menos de uma semana, dois comprometimentos distintos do npm moveram a sua carga útil para disparar quando o pacote é importado em vez de quando é instalado, contornando diretamente o novo padrão e o --ignore-scripts. O mecanismo que o ecossistema acabara de passar um ano a aprender a vigiar já se tinha deslocado. A única coisa que não se moveu foi a idade da versão maliciosa — e é esse o sinal que a barreira de idade da Bromure verifica.

O ecossistema inteiro passou um ano a aprender que os scripts de instalação do npm eram o perigo. A 8 de julho o npm desativou-os por padrão. Três dias depois, um comprometimento tirou a sua carga útil do script de instalação; três dias mais tarde, um segundo chegou sem qualquer script de instalação. Ambos disparam quando se importa o pacote. A correção e o contorno cruzaram-se na mesma semana.

Um programador adiciona @asyncapi/generator a um projeto a 14 de julho e corre npm install. O pacote não tem preinstall, não tem install, não tem gancho postinstall — nada para um scanner de scripts assinalar, nada para o novíssimo padrão do npm ignorar. A instalação termina limpa. Na primeira vez que o código faz require('@asyncapi/generator'), um loader ligado ao próprio ponto de entrada do pacote lança um processo Node desanexado, puxa um segundo estágio de 8,2 MB por IPFS, desencripta-o e começa a caçar credenciais. Nada na instalação pareceu errado, porque a parte que estava errada não aconteceu na instalação.

A correção que chegou para o ataque do ano passado

Durante quase um ano, a história dos ataques à cadeia de fornecimento do npm foi a mesma: um pacote comprometido carrega um gancho postinstall, e o npm install executa-o automaticamente na sua máquina. Então a 8 de julho o npm lançou a versão 12 — o maior redesenho de segurança nos dezasseis anos da ferramenta. Os scripts de ciclo de vida das dependências estão agora desativados por padrão: “os scripts preinstall, install e postinstall das dependências não correm a menos que os autorize”. As dependências Git e as fontes por URL remoto também passaram a ser opt-in. É uma melhoria real, e fecha uma porta por onde um ano de worms passou.

Fecha uma porta. Os atacantes já tinham encontrado a outra.

A carga útil mudou-se para a importação

A 11 de julho, o pacote npm jscrambler foi comprometido — cinco versões maliciosas publicadas ao longo de cerca de três horas. As três primeiras, 8.14.0 a 8.17.0, “corriam o dropper a partir de um gancho preinstall”, o padrão clássico. Depois, a meio da campanha, o atacante mudou de porta. “A partir da 8.18.0 o gancho de instalação desaparece por completo — o dropper idêntico é em vez disso injetado como uma função auto-executável no topo de dist/index.js”, de modo que “dispara quando o pacote é importado ou o seu CLI é executado em vez de na instalação”. A Socket nomeia a razão sem rodeios: “Isto é uma evasão deliberada: derrota os scanners que só inspecionam scripts preinstall/postinstall e sobrevive a npm install --ignore-scripts”.

Três dias depois, a organização npm @asyncapi foi comprometida da mesma forma, mas construída para isso desde o início. Cinco versões em quatro pacotes — @asyncapi/specs 6.11.2-alpha.1 e 6.11.2, @asyncapi/generator 3.3.1, @asyncapi/generator-components 0.7.1, @asyncapi/generator-helpers 1.1.1 — foram republicadas depois de um atacante usar um workflow do GitHub Actions mal configurado para extrair o token do bot do projeto. A conclusão da Microsoft é a que importa aqui: “Todos os pacotes afetados não declaravam qualquer gancho preinstall, install ou postinstall no package.json”. O loader ficava em vez disso dentro do ponto de entrada normal de cada pacote — index.js para as specs, um validator.js no fundo dos templates do gerador. E o conselho de mitigação é explícito: “Não confie no npm install --ignore-scripts como mitigação; esta campanha executa quando o módulo é importado, não através de um gancho de ciclo de vida”.

jscrambler · 11 jul.8.18.0: hook removidoAsyncAPI · 14 jul.sem hook nenhumPORTA DE INSTALAÇÃOpreinstall / postinstallfechada por padrão (npm v12)fechada por --ignore-scriptsfechadaPORTA DE IMPORTAÇÃOrequire() / importloader no ponto de entradanpm v12 não lhe tocaabertaMESMA CARGA ÚTILprocesso node desanexadoestágio 8,2 MB via IPFScoletor de credenciaisC2 · 85.137.53.71
Mesma carga útil, uma porta diferente. Durante um ano a carga veio pela porta dos scripts de instalação: o npm corre preinstall/postinstall automaticamente. O npm v12 (8 de julho) fecha essa porta por padrão, e o --ignore-scripts fecha-a manualmente. Mas uma segunda porta leva à mesma sala — o código que corre quando se importa o pacote. O jscrambler (11 de julho) moveu o seu dropper de um gancho preinstall para dist/index.js; o AsyncAPI (14 de julho) chegou sem qualquer gancho de instalação, com o seu loader ligado ao próprio ponto de entrada do pacote. Fechar a porta de instalação não toca na porta de importação.

Dois atores sem ligação, com três dias de intervalo, a chegar ambos ao mesmo movimento na mesma semana em que a correção emblemática do ecossistema entrou em serviço. Isto não é coincidência. É o que acontece quando uma defesa nomeia um mecanismo: o mecanismo torna-se um ponto de apoio, e estar noutro sítio custa ao atacante umas linhas de código. Um scanner que pergunta “este pacote tem um gancho de instalação?” recebe um “não” verídico do AsyncAPI, e deixa-o passar.

A única coisa que não se moveu

Retire o mecanismo de entrega e olhe para o que se manteve constante nos dois comprometimentos. As versões do jscrambler estiveram ativas umas horas. A versão estável do AsyncAPI foi publicada às 08h30 UTC e a deteção a jusante começou às 08h49 — dezanove minutos. Cada versão maliciosa dos dois incidentes foi retirada bem dentro de duas horas após ser publicada. A carga útil passou do gancho de instalação para o ponto de entrada; a entrega passou de um script de ciclo de vida para um loader em tempo de execução. A única propriedade que não se moveu é que cada versão envenenada era novíssima. Uma versão recém-publicada é a impressão digital partilhada de um comprometimento, porque essa é a forma do ataque: roubar um token, publicar, ser apanhado, ser removido. A janela é curta por construção.

É essa a propriedade que a barreira de idade da Bromure verifica, e está ativa por padrão. Cada busca de pacote — npm, PyPI, Cargo e os restantes — passa pelo proxy man-in-the-middle do host antes de a instalação do agente ver um único byte, e o proxy recusa qualquer versão mais nova do que o limite, dois dias por padrão. Uma referência flutuante como latest ou um intervalo semver resolve-se silenciosamente para a versão mais recente que seja suficientemente antiga; uma referência fixa para uma versão demasiado nova volta como um 451 com um erro da Bromure a explicar porquê. Nunca pergunta onde vive a carga útil. Não lhe importa se o código dispara em preinstall ou em require(), se o gancho está presente ou ausente, se --ignore-scripts está definido. Uma versão publicada há dezanove minutos é recusada porque tem dezanove minutos. Ambas estas campanhas estavam mortas à chegada perante uma barreira de dois dias — não porque a Bromure reconheceu a carga útil, mas porque nunca olhou para a carga útil de todo.

VERIFICAÇÃO DE MECANISMO · VÊ A CARGA“tem um gancho de instalação?”preinstall / install / postinstallo AsyncAPI responde, com verdade:nãopassa na verificaçãoloader dispara no require()BARREIRA DE IDADE BROMURE · VÊ A VERSÃO“que idade tem esta versão?”limite: 2 dias, por padrãoo registo indica publicada:há 19 minutosrecusada · 451carga nunca resolve
Duas maneiras de decidir se deixar passar um pacote. Uma verificação de mecanismo faz uma pergunta sobre a carga útil — tem um gancho de instalação? — e o AsyncAPI responde “não” com veracidade, por isso a verificação deixa-o passar. A barreira de idade da Bromure faz uma pergunta sobre a versão — que idade tem? — e uma versão publicada há dezanove minutos é recusada com um 451 independentemente de onde o seu código dispara. O mecanismo passou da instalação para a importação entre dois ataques numa semana; a idade não se moveu, porque uma publicação recente é a forma do comprometimento.

A barreira de idade não afirma que os pacotes novos são maliciosos — a maioria está bem, e uma versão de dois dias ainda é muito recente. Afirma algo sobre onde o risco se concentra. A janela perigosa de um comprometimento são as primeiras horas depois de um token roubado empurrar uma versão, antes de um mantenedor reparar e a retirar. Esperar dois dias não torna um pacote seguro, mas tira a sua instalação do intervalo exato onde estes ataques vivem, e fá-lo sem inspecionar uma única linha do código que está prestes a executar. Para um pacote de que precisa no dia em que sai, a barreira aceita uma isenção por pacote; tudo o resto espera os seus dois dias.

Quando uma versão já tem idade suficiente à mesma

Uma barreira de idade é um filtro, não um muro. Defina um limite mais curto, isente um pacote de que precisa no primeiro dia, ou retire uma versão maliciosa que ficou por detetar tempo suficiente para envelhecer além dos dois dias, e o código envenenado chega à instalação. A resposta da Bromure para esse caso é a que os artigos anteriores sobre a cadeia de fornecimento descrevem, e é a razão pela qual a barreira de idade se pode dar ao luxo de ser um filtro em vez de toda a defesa: o agente que corre a instalação não está no seu Mac.

Está dentro de uma VM Linux descartável, a uma fronteira de hipervisor de distância. Por isso quando o loader do AsyncAPI dispara e o seu coletor de credenciais parte à procura — e procura a fundo, varrendo mais de uma centena de variáveis de ambiente incluindo NPM_TOKEN, AWS_ACCESS_KEY e ANTHROPIC_API_KEY, além de .npmrc, ~/.aws/credentials, id_ed25519 e o kubeconfig no disco — encontra as cópias da VM, que são strings brm_… de fachada. Os valores reais vivem no host. Quando o agente faz um pedido legítimo que precisa de um, o proxy do host troca o falso pelo valor real na ligação, só para esse salto, e troca-o de volta; o segredo nunca aterra na memória da VM para o coletor recolher. A chave ~/.ssh que a carga copia é um par descartável criado para esse perfil. E o envio C2 que a carga tenta — para 85.137.53.71, neste caso — sai pelo mesmo proxy do host que regista cada destino, por isso um processo Node que nunca iniciou a abrir um socket para um IP desconhecido é uma linha no Registo de Segurança, não uma transferência silenciosa. Quando a janela fecha, a VM e tudo o que a carga fez lá dentro desaparecem.

É a mesma família de runtime Miasma que entrou no próprio âmbito npm da Red Hat mais cedo este ano. O coletor é maduro e reutilizado entre campanhas; o mecanismo de entrega é a parte que não para de mudar.

A lição desconfortável desta semana em particular é que uma defesa presa a um mecanismo tem uma meia-vida medida em dias. O ecossistema passou um ano a fazer dos scripts de instalação a coisa a vigiar, lançou a correção, e viu dois atores distintos deslocar a carga útil antes mesmo de as notas de versão arrefecerem. Não se ganha essa corrida a nomear o próximo mecanismo, porque há sempre outro sítio para o código disparar. Ganha-se a verificar algo que o atacante não pode mudar de forma barata — que idade tem a versão — e a correr o que escapa num sítio onde não pode levar nada. Experimente.