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

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:

  1. Criar um repositório no slug liberado. O GitHub dá a ele um novo ID numérico.
  2. Adicionar um workflow com o nome de arquivo do vínculo e a permissão id-token: write, e rodá-lo.
  3. O GitHub emite um token OIDC cujo claim de repositório é o slug antigo.
  4. 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.
  5. 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.

Um nome livre, um workflow que bate, uma credencialVínculo npm trustrepo: acme/sdkworkflow: release.ymlrepo_id: (não guardado)mantenedor renomeiaacme/sdk → acme/sdk-v2acme/sdk fica livrepara novo registroRepo novo do atacante: acme/sdkrepository_id: new.github/workflows/release.ymlpermissions: id-token: writeToken OIDC: repository = acme/sdkTroca no npm: slug ✓ workflow ✓IDs: nada com que comparar→ credencial de publicação emitidaRegistro do npm@acme/[email protected]marcada como latestcom proveniênciaUm agente de código:npm install @acme/sdkresolve latest,executa, ninguém olhandoNomes ilustrativos. A Bloom encontrou mais de 1.500 pacotes nesse estado e não publicou a lista.
A cadeia do SlugFest. O vínculo guarda um nome, uma renomeação libera o nome, e um repositório novo com o nome antigo passa na verificação do npm e publica sob o pacote original.

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.

No Bromure: para o agente, a versão nova ainda não existeVM da máquina (Ubuntu)$ npm install @acme/sdk+ @acme/[email protected]$ npm i @acme/[email protected]451 published 3 hours agoGH_TOKEN=ghp_… (substituto)ANTHROPIC_API_KEY=sk-ant-api03-brm-…nenhum segredo real para levarProxy, no MacBarreira de idade (padrão 2 dias):versões mais novas saem, latest → 4.1.3Opcional: socket.dev, OSV,remoção de scripts de instalaçãoSubstituto para outro host → 451, VM pausadaPATCH /repos/acme/sdk (renomear)→ diálogo Prompt before writea política fica no host, fora do alcance do agenteRegistro do npm4.2.0 há 3 horasdo slug retomado4.1.3 há 5 mesesa release do mantenedordois dias para quemantenedor, npm escanners percebamSecurity Timeline, no Macnpm · @acme/[email protected] · baixado · máquina, hora · filtre por pacote, exporte CSV quando a lista sair
A mesma instalação a partir de uma máquina Bromure. A versão recém-publicada não aparece na listagem que o agente vê, uma requisição fixada recebe um 451, um payload que chega a rodar só encontra substitutos, e todo download fica registrado.

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.