Qualquer um podia publicar aquele pacote
No dia 28 de agosto alguém abriu um pull request num gerador de código OpenAPI popular, escreveu um comentário de duas palavras nele, e o próprio workflow de release do projeto publicou dez versões envenenadas no npm com atestações de proveniência válidas. O atacante nunca teve credencial nenhuma. A primeira onda não trazia script de instalação algum, escondendo sua carga onde o node-gyp avalia Python. A janela inteira durou três horas e onze minutos, e o portão de idade do Bromure Agentic Coding, ligado por padrão em dois dias, ganha desse tempo sem saber nada sobre o ataque.
Alguém abriu um pull request a partir de um fork e deixou nele um comentário de duas palavras. O pipeline de release do projeto leu o comentário, baixou o código dessa pessoa, executou e assinou o que saiu dali. Não roubaram nada para chegar lá.
@7nohe/openapi-react-query-codegen transforma um schema OpenAPI em hooks do
TanStack Query. Você aponta para uma especificação e ele escreve o código
cliente por você. A Aikido conta mais de 150.000 downloads por semana. Você
adiciona um pacote assim porque ele te poupa uma tarde, e seu agente de código
o instala sem pensar duas vezes.
No dia 28 de agosto ele começou a distribuir um ladrão de credenciais.
A Aikido Security publicou a análise naquele mesmo dia, e a SafeDep publicou a sua com o mecanismo do workflow e uma linha do tempo minuto a minuto. O malware se nomeia nas próprias strings: Trinitite. As duas equipes situam esse modo de operar na família Mini Shai-Hulud, o verme npm auto-replicante que vem percorrendo contas de mantenedores o ano inteiro.
O caminho dele até o npm é o que vale ler.
O pipeline de release aceitou uma instrução de um estranho
O release.yml do projeto tinha um gatilho issue_comment. Esse é um evento
do GitHub Actions que dispara quando alguém comenta numa issue ou num pull
request, e é uma conveniência comum: mantenedores ligam isso para publicar uma
release digitando uma palavra em vez de clicar por uma interface.
Este não tinha nenhuma verificação de associação do autor. O GitHub informa ao workflow se quem escreveu o comentário é o dono do repositório, um membro, um contribuidor antigo ou um completo estranho, e cabe ao workflow olhar. Este não olhou.
A SafeDep reconstruiu a sequência. Abra um pull request a partir de um fork.
Comente npm publish nele. O pipeline acorda, baixa o código do fork do
atacante e roda pnpm install, o que executa o hook preinstall do atacante
no runner do próprio projeto. Aquele job carregava id-token: write,
suficiente para cunhar um token OIDC de trusted publishing do npm.
Dez versões maliciosas saíram, cada uma carregando uma atestação de proveniência válida, porque foi o pipeline do próprio projeto que construiu e publicou todas elas. A frase da Aikido é a que vale guardar: “quando o próprio workflow está comprometido, esse certificado se torna um sinal de confiança nada confiável.”
Verifique a proveniência de um pacote e você aprende uma coisa: se ele saiu do pipeline daquele repositório. Aqui, saiu. Ninguém forjou nada, e dá para ter uma assinatura correta sobre um malware sem que nenhum dos dois fatos pareça errado. Em maio nós vimos o mesmo desfecho chegar por um token OIDC roubado. Desta vez o atacante pulou o roubo.
A primeira onda não tinha script de instalação para achar
Escaneie aquelas oito primeiras versões atrás de hooks de instalação perigosos
e você volta de mãos vazias. Os arquivos package.json delas não declaram nada
que execute.
A carga estava no binding.gyp, o arquivo de descrição de build que o
node-gyp usa para compilar módulos nativos. A Aikido explica o truque:
“Quando o npm install processa um pacote que contém um binding.gyp, ele
invoca o node-gyp para compilar o módulo nativo. O node-gyp avalia o campo
conditions do arquivo usando Python, o que significa que expressões Python
arbitrárias podem ser colocadas ali e serão executadas durante a instalação,
mesmo sem nenhum script preinstall declarado no package.json.”
Então o atacante escreveu uma expressão Python dentro de um arquivo de build.
Ela caminha pela árvore interna de subclasses do Python até chegar em
catch_warnings, usa essa classe para alcançar __builtins__, importa os a
partir dali e chama os.system() sobre um arquivo JavaScript embarcado no
tarball. A SafeDep observa que a travessia está escrita em escapes Unicode, de
modo que um grep por os.system também volta vazio.
O JavaScript embaixo desembrulha três camadas — XOR, depois AES-128-GCM, depois
um ofuscador comercial — antes de baixar o Bun 1.4.0 do GitHub para um
diretório temporário com prefixo trinnyyyy- e rodar a carga de verdade.
Dezenove minutos depois o atacante enviou uma segunda onda com preinstall de
volta no package.json como plano B. A SafeDep data essa onda em menos de um
minuto após o primeiro relatório público de segurança. Quem conduzia isso
estava lendo as divulgações e publicando contra elas.
Três horas e onze minutos
A linha do tempo da SafeDep está em UTC. A última release limpa, 3.0.2, saiu em 11 de agosto. A primeira onda chegou em 28 de agosto às 20:00 e terminou às 20:02. A segunda rodou das 20:19 às 20:21. O npm removeu as dez versões por volta das 23:11.
Isso deixa uma janela de exposição de três horas e onze minutos. Um projeto em
^3.0.0, a faixa de acento circunflexo comum que o npm escreve por você, caía
direto nela. Se você rodou uma instalação limpa dentro dessa janela, ou se seu
CI rodou, ou seu agente de código rodou, você pegou o verme.
Uma vez em execução, ele varreu o diretório pessoal com mais de 150 padrões
glob: chaves privadas SSH, arquivos .env, ~/.docker/config.json, credenciais
AWS, Azure e GCP, tokens do npm, PyPI e RubyGems, tokens de service account do
Kubernetes, carteiras de criptomoedas. Na mesma lista estavam
~/.claude.json, ~/.claude/ e ~/.claude/mcp.json, a configuração do
próprio agente de código e os endereços de todos os servidores MCP com que ele
conversa.
Nos repositórios que conseguia alcançar ele escrevia um .claude/settings.json
com um hook SessionStart que roda setup.mjs toda vez que uma pessoa abre o
projeto no Claude Code. Com os tokens de registro que acabara de colher, ele
se republicava em cada pacote que a vítima mantinha no npm, no PyPI e no
RubyGems. Essa é a parte verme: cada pessoa em que ele caía virava candidata à
próxima release.
Dois dias ganham de três horas
Agora ponha um perfil do Bromure Agentic Coding no caminho.
O Bromure roda cada perfil como sua própria máquina virtual no Apple Silicon, e todo pacote que o agente busca (npm, PyPI, Cargo, RubyGems, Maven, NuGet, módulos Go, Packagist) passa por um proxy do lado Mac do hipervisor antes que um único byte chegue ao convidado.
A primeira coisa que esse proxy checa é a idade da versão. Supply Chain → o
portão de idade vem ligado por padrão, com um mínimo de dois dias. Referências
flutuantes (latest, faixas de acento circunflexo, faixas de til) resolvem sem
alarde para a versão mais nova anterior ao corte. Fixe algo fresco demais e você
recebe um 451 com uma mensagem de erro clara.
Coloque isso contra uma janela hostil de três horas e onze minutos. Rode
npm install às 20:30 UTC de 28 de agosto, no meio do ataque, com ^3.0.0 no
seu manifesto, e seu agente resolve para a 3.0.2, de 11 de agosto. Ele nunca
descobre que a 3.0.3 existe.
O portão chegou lá sem assinatura, sem pontuação de reputação e sem boletim. Ele leu uma data de publicação. Isso importa aqui, porque a segunda onda saiu menos de um minuto depois do primeiro aviso público, e qualquer defesa que espere por um relatório já entrou atrasada na corrida.
O que a varredura encontra quando roda mesmo assim
Suponha que você desligou o portão para um pacote, ou que a versão passou do corte antes de alguém perceber. A carga ainda tem quatro coisas a fazer, e num perfil Bromure cada uma delas encontra um controle que vive no Mac, onde o convidado não alcança.
Ela tem que rodar. Supply Chain → Remoção de scripts de instalação tira
preinstall, install, postinstall e prepare dos tarballs npm em tempo
real, reescrevendo o tarball e atualizando o hash de metadados do registro para
que a verificação do próprio npm continue passando. Lá se foi a segunda onda.
Você pode empilhar mais checagens em cima: consultas de vulnerabilidade OSV
e filtragem de pacotes pelo socket.dev, cuja checagem de bloqueio de pacotes
comprometidos cobre exatamente essa classe de coisa (scripts de instalação
maliciosos, malware), ou o Delpi como registro substituto. Nenhum deles
pergunta se existe uma atestação, o que importa quando a atestação é real.
Ela tem que achar alguma coisa. A varredura de 150 globs roda contra
/home/ubuntu numa máquina virtual, não contra o diretório pessoal do seu Mac.
Os compartilhamentos de pastas são explícitos e limitados a oito. As credenciais
que ela encontra são iscas: os tokens no convidado são marcadores brm_… que o
proxy do host troca pelos valores reais no fio, de modo que o material
verdadeiro nunca entra no espaço de endereçamento da VM. Chaves privadas SSH
não estão no convidado de jeito nenhum:
um ssh-agent por perfil no host faz a assinatura, e ~/.docker/config.json tem
base64 falso. O kubeconfig é sintético, com certificados de cliente
descartáveis. As requisições AWS são reassinadas no host, então qualquer coisa
que pule o proxy volta como InvalidSignatureException em vez de autenticada.
Ligue Exigir aprovação para uso numa credencial e cada substituição levanta
um diálogo no seu Mac, com uma concessão limitada no tempo.
Ela tem que ligar para fora. Guardrails → Conexões de saída é um
conjunto de regras no estilo pf: uma ação, um protocolo (tcp, udp, web ou
any), um hostname ou um CIDR IPv4, portas, e para web os métodos HTTP que
você permite. As regras casam de cima para baixo, o primeiro casamento vence, e
pôr Tráfego não correspondido em Negar transforma a lista numa lista de
permissão. A mesma política é avaliada duas vezes por camadas independentes: o
switch virtual, por IP de destino e hostname bisbilhotado no DNS, em todos os
protocolos, e o proxy do host, por SNI TLS e por método. Buscar um binário do
Bun e empurrar segredos roubados para um repositório de entrega morta precisam,
os dois, de um destino, e destinos têm que estar na lista.
Ela tem que publicar. Guardrails → GitHub em Somente leitura trata
git-receive-pack como escrita e bloqueia escritas REST, devolvendo um 403 seco
que o agente lê como uma falha comum de API. Sem commit envenenado, sem entrega
morta, sem release adiante. Os mesmos modos cobrem GitLab, Bitbucket,
Kubernetes, AWS, DigitalOcean e registros de contêiner.
O hook SessionStart que ela pretendia deixar para a próxima vez fica num
diretório pessoal que o Erase home… apaga, em Resources → Storage. O
Reset to base… faz o mesmo com qualquer coisa escrita fora dele. Cada decisão
pelo caminho, cada destino permitido ou negado, cada veredito sobre um pacote,
cada troca de credencial, aterrissa na janela do Log de Segurança no host,
onde o código do convidado não pode editar porque o código do convidado não
alcança.
Por que isso continua acontecendo
Trusted publishing e proveniência valem a pena. Eles fecharam o roubo do token npm de um mantenedor, que era o ataque dominante um ano atrás. O comprometimento desta semana passou ao redor deles sem tocar em token nenhum: um estranho podia alcançar o processo de release, então o pipeline assinou uma atestação verdadeira sobre código hostil.
Você não consegue auditar sua saída disso, porque o próximo workflow que falhar vai ser de outra pessoa. O que dá para fazer é parar de ser a primeira máquina a rodar uma versão nova de qualquer coisa.
Abra um perfil, olhe em Supply Chain e confira se o portão de idade está ligado. Deveria estar, em dois dias, sem que você tenha encostado nele. Depois gaste mais dois minutos: checagem de vulnerabilidades OSV ligada, socket.dev ou Delpi selecionado se você tiver chave, remoção de scripts de instalação ligada. Em Guardrails, ponha Tráfego não correspondido em Negar com uma lista de permissão curta, e deixe o GitHub em Somente leitura para todo perfil que não esteja publicando. Em Credenciais, ligue Exigir aprovação para uso em qualquer coisa capaz de gastar dinheiro.
Depois deixe o agente instalar o que ele precisa. Ele vai pegar a 3.0.2. Instale o Bromure Agentic Coding e deixe o portão de idade como está.