O nome antigo ainda podia publicar
Em 13 de outubro, a Bloom Security revelou o SlugFest: o trusted publishing do npm identificava um repositório do GitHub pela string dono/nome, então qualquer um que pegasse o nome antigo de um repositório renomeado ou apagado podia publicar novas versões de pacotes que não eram seus, sem roubar credencial nenhuma. A Bloom encontrou mais de 1.500 pacotes expostos, com mais de 10 milhões de downloads, e aponta os agentes de IA como os instaladores que pegam uma versão nova sem ninguém olhando. No Bromure Agentic Coding, uma versão tão nova não existe para o agente, a instalação roda numa VM que só guarda substitutos, e a renomeação que abre o buraco espera um clique.
O trusted publishing do npm lembrava o nome do repositório e esquecia qual repositório era. Quando o mantenedor o renomeou, a próxima pessoa a criar um repositório com esse nome pôde publicar.
Na segunda-feira, 13 de outubro, Golan Myers, da Bloom Security, publicou o SlugFest, uma falha no trusted publishing do npm. O The Hacker News cobriu no mesmo dia. Myers explica com uma fechadura: “É como uma fechadura que confere o endereço na porta em vez de quem é o dono da casa; quando o dono se muda, quem se muda para lá ganha a chave.”
A Bloom contou mais de 1.500 pacotes expostos, baixados mais de 10 milhões de vezes no total e mais de um milhão de vezes por mês. O ataque não precisa de “conhecimento interno, roubo de credenciais nem condição de corrida”. A Bloom não divulgou os pacotes: “Não estamos publicando uma lista dos pacotes afetados porque o problema ainda é comum.”
A Bloom também diz quem instala esses pacotes hoje. Pela sua contagem, um agente de programação instala em média 70 pacotes por mês, e uma máquina de desenvolvedor cerca de 350. Nas palavras da Bloom, uma versão maliciosa publicada através de um nome retomado “é puxada e roda sem nenhum humano no circuito”.
Como o trusted publishing conferia o repositório
O npm abriu o trusted publishing para todo mundo em julho de 2025. Em vez
de guardar um token do npm na CI, o mantenedor roda npm trust github --allow-publish e diz ao npm qual repositório do GitHub e qual arquivo de
workflow podem publicar o pacote. Na hora de publicar, o GitHub Actions
emite um token OIDC de vida curta que descreve a execução do workflow, o
npm o confere contra o vínculo e devolve uma credencial de publicação. Não
existe segredo de longa duração para roubar.
O vínculo guardava o repositório como um slug, owner/repo, mais o nome do
arquivo de workflow. Não guardava os repository_id e repository_owner_id
numéricos que o GitHub atribui para sempre. O npm só os registrava depois
da primeira publicação bem-sucedida. Até lá, qualquer repositório que
respondesse pelo nome certo e rodasse o arquivo de workflow certo batia.
No GitHub, nomes ficam livres. Um mantenedor renomeia um repositório, transfere para uma organização, apaga, ou renomeia a própria conta. O slug antigo fica disponível, e outra pessoa pode registrá-lo. Os passos da Bloom:
- Criar um repositório no slug liberado. O GitHub dá a ele um novo ID numérico.
- Adicionar um workflow com o nome de arquivo do vínculo e a permissão
id-token: write, e rodá-lo. - O GitHub emite um token OIDC cujo claim de repositório é o slug antigo.
- O endpoint de troca do npm bate slug e nome de arquivo e emite uma credencial de publicação. Os IDs numéricos estão no token, mas o npm não tinha com o que compará-los.
- Publicar uma nova versão de um pacote que não é seu, com o nome dele, o número de downloads dele e todo projeto que depende dele.
A Bloom executou a cadeia de ponta a ponta contra um scope do npm e repositórios que ela controla, publicando a partir de uma segunda organização em que só o slug batia. O trusted publishing do GitLab tinha a mesma brecha.
A release resultante ainda sai assinada. O trusted publishing a partir de um repositório público gera uma atestação de proveniência, e esta apontaria a execução do workflow no repositório do atacante, sob o slug que o mantenedor configurou. Em agosto, um comentário de duas palavras num pull request fez o próprio pipeline de release de um projeto publicar dez versões envenenadas com proveniência válida. O SlugFest nem precisa do pipeline da vítima.
Quatro meses até a correção
A Bloom reportou a falha pelo programa do GitHub no HackerOne em 6 de junho. O GitHub a validou em 9 de junho. A última resposta substantiva veio em 8 de julho, dizendo que a equipe estava “trabalhando para corrigir o problema e determinar seu risco e sua gravidade”. Seis cobranças e um pedido de mediação ao HackerOne ficaram sem resposta.
O changelog do GitHub de 2 de
outubro
traz a correção. Um vínculo de trusted publishing que não publicou nada nas
48 horas seguintes à sua criação agora expira e não pode mais autorizar uma
publicação. Depois da primeira publicação bem-sucedida, o vínculo fica preso
à identidade numérica do repositório. Mudar de repositório exige um vínculo
novo, com uma nova janela de 48 horas. O npm também passou a rejeitar
tokens emitidos por workflows issue_comment, o gatilho do ataque de
agosto.
O changelog não diz o que aconteceu com os vínculos criados antes de 2 de
outubro. O conselho da Bloom aos mantenedores é rodar npm trust list em
cada pacote, conferir se cada slug ainda aponta para um repositório que
eles controlam e recriar o vínculo depois de qualquer renomeação ou
transferência.
Se você instala pacotes em vez de publicá-los, esse conselho não faz nada
por você. Não dá para ver o vínculo de confiança de um pacote a partir do
npm install. Os 1.500 nomes não foram divulgados. Uma release maliciosa
vinda de um slug retomado parece qualquer outra release, proveniência
incluída. A frase da própria Bloom sobre o caso dos agentes: “Basta ser o
pacote que um agente escolhe.”
A mesma instalação, a partir de uma máquina Bromure
No Bromure Agentic Coding, o agente roda numa VM Linux no seu Mac, e toda
requisição que a VM faz, registry.npmjs.org incluído, passa por um proxy
no host. A política de cadeia de suprimentos roda nesse proxy, fora da VM,
então nada que o agente ou um pacote faça dentro da máquina consegue
alterá-la. Vamos acompanhar a release do SlugFest até uma máquina Bromure.
Uma versão tão nova não aparece
A barreira de idade vem ligada por padrão em toda máquina nova, com mínimo
de dois dias. Quando o npm pede @acme/sdk ao registro, o proxy tira da
listagem toda versão publicada nos últimos dois dias e move latest para a
versão mais recente que já tem idade suficiente.
O agente roda npm install @acme/sdk, o mesmo comando que rodaria em
qualquer lugar, e recebe a última release real do mantenedor. A versão do
atacante não está na listagem que o agente vê. Se o agente fixar o número
dela porque uma página da web ou uma issue mandou, a requisição do tarball
recebe HTTP 451, e o npm mostra o motivo no terminal do agente:
Bromure Supply-Chain Security blocked this request: npm package @acme/[email protected]
published 3 hours ago — policy requires 2 days minimum
A barreira trabalha só com a data de publicação, então você não precisa da lista da Bloom nem de uma assinatura de malware. Um atacante que retoma um slug tem que publicar uma versão nova, e uma versão nova é uma versão jovem. Enquanto o agente instala a versão anterior, o mantenedor, o npm e os scanners têm dois dias para notar uma release que ninguém da equipe fez. Um mantenedor cujo vínculo nunca foi usado tem bons motivos para olhar quando ele publicar pela primeira vez.
Mais verificações depois de dois dias
Ligue mais camadas no painel Supply Chain da máquina e a versão as encontra assim que passar pela barreira. Com o socket.dev selecionado, todo tarball é verificado antes de ser servido, e pacotes marcados como malware recebem um 451. Com a verificação do OSV ligada, uma versão com advisory publicado é bloqueada a partir da gravidade que você escolher. Com Strip preinstall / install / postinstall / prepare from npm tarballs on the fly, o proxy remove os hooks de instalação do tarball antes que o npm o veja, o que tira o meio de entrega de que depende a maior parte do malware do npm.
O payload encontra substitutos
Digamos que uma release passe mesmo assim e rode. Ela roda dentro da VM, e
as credenciais que estão lá são substitutos. GH_TOKEN,
~/.git-credentials, a chave da Anthropic, as chaves da AWS: cada uma é uma
string com o formato da real, derivada do valor real e de um salt por
instalação, e inútil em qualquer lugar a não ser pelo proxy no seu Mac,
para requisições ao host a que pertence. Um payload que as manda para o
próprio servidor manda strings que falham na autenticação em todo lugar.
Essa requisição também passa pelo proxy, que inspeciona toda requisição de
saída em busca de substitutos indo para o host errado. Um substituto ghp_
a caminho de um servidor de coleta recebe um 451, a VM pausa, e This
environment may have been compromised abre no Mac com o nome da
credencial, o host a que ela pertence e o host para onde foi enviada.
Sua renomeação espera um clique
O SlugFest começa com uma renomeação, uma transferência ou um repositório
apagado, e agentes também fazem essa faxina. Numa máquina Bromure, o GitHub
fica sob Prompt before write por padrão. Renomear um repositório é um
PATCH na API do GitHub, e apagar um é um DELETE. Qualquer um dos dois
para num diálogo do macOS, Allow write on "github.com" from workspace
"…"?, mostrando o método e o caminho como apareceram na rede. Quando um
agente arruma um projeto renomeando acme/sdk para acme/sdk-v2, você vê
isso antes de o nome antigo ficar livre, e sabe qual vínculo do npm
recriar.
Quando a lista sair
A Bloom vai publicar os pacotes afetados em algum momento. Aí alguém vai
perguntar qual das suas máquinas instalou um deles, e quando. A Security
Timeline registra todo download de pacote de toda máquina, qualquer que
seja a política: npm · @acme/[email protected], a máquina, a hora, e se foi
baixado, segurado pela barreira de idade ou bloqueado. Filtre pelo nome do
pacote e exporte o resultado em CSV. O registro fica no Mac, onde o agente
não consegue editá-lo.
A versão nova não aparece
A barreira de idade, ligada por padrão em dois dias, tira as versões
recentes da listagem do registro e leva latest de volta. O agente
instala a última release real do mantenedor sem saber que algo
aconteceu.
Requisições fixadas recebem um 451
Se o agente pedir a versão nova pelo número, o download é recusado e o npm mostra a idade do pacote e a política no terminal do agente.
O payload encontra substitutos
Tudo o que roda na VM encontra tokens substitutos. Enviado ao host errado, um deles recebe um 451, a VM pausa e o Mac mostra um alerta.
Renomeações e instalações ficam registradas
O Prompt before write segura a renomeação de repositório que libera um slug. A Security Timeline registra todo download de pacote, por máquina e versão.
O próximo nome
A correção do GitHub prende o vínculo à identidade numérica do repositório depois da primeira publicação e faz expirar os vínculos que nunca publicam, o que fecha essa brecha. O trusted publishing protege a credencial de publicação e diz qual pipeline construiu a release. Em agosto, o pipeline era o da própria vítima, disparado pelo comentário de um estranho. Desta vez, o repositório de um estranho respondia pelo nome certo. As duas releases tinham proveniência.
Seus agentes instalam o que o resolvedor devolver, 70 pacotes por mês pela contagem da Bloom. Rode-os numa máquina Bromure, onde uma release de dois dias ainda não existe e um payload que chega a rodar só encontra substitutos. Para saber mais sobre a barreira de idade, leia Qualquer um podia publicar aquele pacote e Revogar o token era o gatilho.