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

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.

um estranhopull request de umfork, e então umnpm publishrelease.ymlon: issue_commentsem checar o autorbaixa o forkpnpm installroda o código deleid-token: writenpm OIDCtrusted publishingtoken cunhadosob demanda, por design10 versõespublicadas no npmproveniência válidae não é mentiraNenhum passo dessa cadeia exigiu um segredo roubado. O elo fraco estava acima de qualquer assinatura.
Como dez releases assinadas foram publicadas por alguém sem credencial nenhuma. O workflow de release disparou num comentário sem checar quem o escreveu, baixou o código do fork do atacante, executou durante a instalação de dependências e usou a própria permissão id-token para cunhar um token legítimo de publicação no npm. A atestação de proveniência do resultado é precisa: o pipeline do projeto de fato construiu e publicou aquilo.

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.

11 de ago3.0.2 — última limpa28 de ago, 20:00 & 20:19 UTConda 1 (binding.gyp), onda 2 (preinstall)23:11 UTCnpm remove as dezexposição: 3 h 11 minPortão de idade, dois dias por padrão: uma faixa resolve para a última versão anterior ao corte — 3.0.2.
A janela de ataque contra o corte padrão. Dez versões envenenadas existiram no npm por três horas e onze minutos. O portão de idade do Bromure vem ligado com um mínimo de dois dias, então uma faixa de acento circunflexo resolve para a 3.0.2, de 11 de agosto, e as versões envenenadas nunca chegam a ser candidatas. O portão não faz julgamento algum sobre o pacote; ele só se recusa a ser o primeiro.

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.

o que o verme ainda precisa1. rodar na instalaçãobinding.gyp, ou um hook preinstall2. achar credenciais150+ globs no diretório pessoal3. conectar para forabuscar o Bun, depositar os dados4. publicar adianteempurrar commits, republicaronde isso aterrissa no Macproxy de pacotes no hostidade, scripts removidos, OSV,filtragem socket.dev ou Delpium diretório pessoal de VMmarcadores brm_…, base64 falso,kubeconfig sintético, sem chaves SSHConexões de saídaregras no host, o primeiro vence,conferidas no switch e no proxyGuardrails, Somente leituragit-receive-pack e escritas RESTdevolvem 403 vindo do host
As quatro coisas de que o Trinitite precisa depois de rodar, e onde cada uma aterrissa num perfil do Bromure Agentic Coding. A execução encontra a remoção de scripts e a filtragem de pacotes no proxy do host. A varredura de credenciais encontra marcadores num diretório pessoal de VM. A saída de rede e a publicação encontram um conjunto de regras no host e os Guardrails. Nenhum desses controles vive no convidado, então o código que roda no convidado não consegue mudá-los.

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á.