3962 palavrasRead in English

O pedágio do bundler-audit: 99 ms pra checar um Gemfile.lock onde não tem nada pra achar

, em Ruby, Spinel, Compiladores, Segurança

Sábado, 24 de outubro, depois de uma longa semana de enxaqueca. Lá pelas 17h, deixei o Mac parado por uns minutos e mandei o hyperfine rodar o bundle-audit 50 vezes seguidas num Gemfile.lock onde não tinha nada pra achar: uma gem só, inventada, que não existe em base de vulnerabilidade nenhuma. Mediana: 97 ms. O mesmo lockfile, no protótipo que eu escrevi em Ruby e compilei com o Spinel: 3,0 ms. Nenhuma das duas achou vulnerabilidade, porque não tinha nenhuma. A diferença inteira está no que acontece antes do trabalho começar.

Partida no protótipo, lockfile com uma gem, mediana de 50 execuções. bundle-audit 0.9.3: 97 ms; reescrita, no CRuby: 44 ms; reescrita, com o Spinel: 3,0 ms. 0 ms 25 ms 50 ms 75 ms 100 ms bundle-audit 0.9.3 bundle-audit 0.9.3: 97 ms 97 ms reescrita, no CRuby reescrita, no CRuby: 44 ms 44 ms reescrita, com o Spinel reescrita, com o Spinel: 3,0 ms 3,0 ms
Partida no protótipo, 24/10: lockfile com uma gem que não existe na base, mediana de 50 execuções. hyperfine, Apple M3 Pro, ruby-advisory-db 03da937.

No domingo, medi de novo, com o binário publicado na release e o método escrito antes de rodar a primeira execução. Deu 98,8 ms contra 3,5 ms, 28 vezes.

Guarde essa imagem, porque ela explica o resto do texto: o bundler-audit paga um pedágio toda vez que é chamado. Pra comparar um arquivo de texto com uma base de avisos de segurança, ele sobe o Ruby, o Bundler, o Thor e o RubyGems. No seu terminal, uma vez por semana, ninguém sente. No CI, a cada push, ou num agente que roda a checagem em loop, o pedágio vira a maior parte da viagem.

Em 100 checagens do lockfile do Mastodon, a conta dá 15,8 segundos contra 1,3. A tese do post cabe numa frase: pra ferramenta que roda em loop, o custo está na partida, e dá pra tirar a partida do caminho sem mudar a saída que o seu CI lê.

Vou por partes. Primeiro, o que o bundler-audit faz e onde ele gasta o tempo, pra quem nunca parou pra olhar. Depois, o que eu chamo de “compatível” e como conferi: 984 casos comparando stdout, stderr e código de saída com o bundler-audit 0.9.3. Depois, o que precisei mudar no Ruby pra ele compilar com o Spinel, os números com as condições em que foram medidos, o índice da base que tirou o tempo que sobrava, e de onde vem a memória que ainda sobra (não é de onde eu achava). No fim vêm as críticas que essa conta não responde, a minha leitura e como usar.

Antes de tudo, quem eu sou nessa história. Tenho contribuído com o Spinel, o compilador AOT de Ruby pra C do Matz, desde o começo de outubro, então eu tenho interesse em que ele saia bem na foto. Nesse tempo, fiz vários experimentos com ele, e o spinel-bundler-audit é um deles. Por isso, não precisa acreditar só em mim: o roteiro e os dados brutos estão no repositório, e dá pra refazer a conta.

O projeto é uma alternativa compatível, não um substituto, e não tem ligação com a RubySec nem com os autores do bundler-audit. Ele usa a mesma ruby-advisory-db que a RubySec mantém, e é ela que faz o trabalho de verdade.

Os números principais vêm do binário da release v0.1.0, numa máquina só, um M3 Pro com macOS; os do protótipo, quando aparecem, estão marcados. No Linux eu conferi que funciona, com o CI verde em x86_64 e arm64 e a mesma saída no Ubuntu, no Debian e no Rocky Linux, mas o tempo lá eu ainda não medi.

TL;DR:

  • Lockfile sem nada pra achar: 98,8 → 3,5 ms (28x). É quase tudo pedágio.
  • Apps reais (Mastodon, Discourse, Forem): 10 a 12x mais rápido; até no lockfile sintético com 959 avisos, 6,8x.
  • Saída idêntica ao bundler-audit 0.9.3 nos 984 casos do oráculo.
  • Memória: um terço nos apps reais. Com centenas de avisos na saída, quase empata, e eu explico por quê.

Pule pra: os números, a compatibilidade, a memória, onde essa conta não fecha ou como usar.

O bundler-audit paga pedágio antes de trabalhar

O trabalho do bundler-audit, que você chama pelo comando bundle-audit, é simples de descrever. Ele lê o seu Gemfile.lock, pega cada gem com a versão travada ali e procura essa gem na ruby-advisory-db, uma pasta com um arquivo YAML por aviso de segurança. No commit que eu usei, o 03da937, são 1.269 avisos.

Cada aviso diz quais versões já foram corrigidas e quais nunca foram afetadas. Se a versão do seu lockfile não cai em nenhuma dessas faixas, ele imprime o aviso e sai com código 1, e é esse código que o seu CI lê.

Antes de comparar a primeira gem, o bundle-audit sobe o Ruby e o RubyGems, carrega o Bundler, que ele usa pra ler o lockfile, e carrega o Thor, que monta a linha de comando. Isso se repete a cada chamada. No lockfile da abertura, com uma gem só e que nem existe na base, quase não há comparação a fazer, e mesmo assim foram 98,8 ms. A barra do meio, na figura da abertura, mostra quanto disso é carregar código que a checagem não usa: só tirar o Bundler e o Thor, ainda no CRuby, leva a partida de 97 pra 44 ms. O Spinel tira quase todo o resto.

O pedágio não é defeito do bundler-audit. Qualquer CLI em Ruby que sobe o Bundler paga um pedágio parecido; ele só é o exemplo mais chamado no CI. Usar o Bundler pra ler o lockfile é a escolha segura pra uma gem que precisa entender todo formato de lockfile que já existiu. O custo só aparece quando a ferramenta passa a ser chamada em loop.

No seu terminal, uma vez por semana, 99 ms não incomodam ninguém. No CI, a checagem roda a cada push. Num agente de IA, o loop é ainda mais curto: o agente muda uma versão no Gemfile, roda a checagem, lê a saída e tenta de novo. É nesse loop que a conta da abertura aparece.

Compatível quer dizer o diff vazio, não “parecido”

Uma ferramenta que o CI lê pelo código de saída não pode ser parecida. Se o bundler-audit sai com 1 e a minha sai com 0, o build passa com uma gem vulnerável. Por isso o projeto tem um oráculo.

O oráculo é um script que roda os dois programas nos mesmos casos e compara stdout, stderr e código de saída, byte a byte. Do outro lado está o bundler-audit 0.9.3 de verdade, não uma expectativa escrita à mão. A base de avisos fica fixada num commit, e os lockfiles dos apps também. Quem rodar o oráculo amanhã compara as mesmas entradas que eu.

No lockfile do Mastodon, por exemplo, as duas imprimem isto e saem com código 1:

$ bundle-audit check
Name: pghero
Version: 4.0.1
GHSA: GHSA-5287-hmjh-qmqq
Criticality: Medium
URL: https://github.com/ankane/pghero/security/advisories/GHSA-5287-hmjh-qmqq
Title: Insecure direct object reference (IDOR)
Solution: update to '>= 4.1.0'

Vulnerabilities found!

O spinel-bundler-audit check imprime o mesmo texto, byte a byte.

São 984 casos. A maior parte, 758, são lockfiles nos limites de cada aviso. Pra cada gem da base, o oráculo monta lockfiles nas versões que os avisos citam e nas vizinhas: uma acima, uma abaixo, um .rc1, um .0. É ali que aparece erro de faixa de versão.

O resto cobre formatos de saída, opções, listas de ignore, formatos de lockfile, a linha de comando e a manutenção da base. E entram os lockfiles reais do Mastodon, do Discourse e do Forem, cada um em texto, em -v e em JSON. O resultado foi 984 de 984 idênticos numa rodada isolada. No CI, que roda sem os casos de terminal, foram 979 de 979 em cada uma das três plataformas. Foi também a primeira vez que a ferramenta compilou no Linux, e o CI ficou verde de primeira, sem nenhuma correção.

O caminho até aí teve um caso teimoso. No protótipo, o binário bateu em 953 de 954, e o que sobrava era um \r a mais numa linha. Ele vinha do script(1), que o oráculo usa pra simular um terminal, e aparecia na saída do próprio bundle-audit dependendo da carga da máquina. Era o terminal, não a ferramenta. O oráculo do projeto passou a normalizar esse \r, como já fazia com o nome da ferramenta, a versão e as datas.

Algumas diferenças eu aceitei de propósito, e o README lista cada uma:

  • Opções lidas com optparse, não com o Thor. Opção desconhecida vira erro, em vez de ser tomada como diretório. -Gfile funciona, opção longa pode ser abreviada e -i A B C continua igual. Nesses casos, o oráculo confere a resposta do binário contra o esperado, não contra o bundler-audit.
  • Erro de configuração em uma linha. Um .bundler-audit.yml inválido sai com a mensagem. O bundler-audit imprime o backtrace.
  • Resolver do sistema. Host interno é resolvido pelo resolver do sistema operacional, não pelo Resolv do Ruby.
  • Sem Rake task e sem tree. E version e help mostram o texto da própria ferramenta.

Ruby limpo compila; Ruby esperto nem sempre

O Spinel compila o programa inteiro pra C antes de rodar, e pra isso precisa saber o tipo de cada coisa na compilação. Ruby direto, com classes, métodos, Hash, Array e String, ele compila bem. O que ele recusa é o Ruby esperto: method_missing, constante criada em tempo de execução, metaprogramação em geral.

O bundler-audit em si é simples, mas carrega o Bundler e o Thor, que são grandes e usam bastante metaprogramação. Então eu não tentei compilar o bundler-audit. Escrevi a ferramenta do zero, sem copiar o código dele, usando o comportamento dele como especificação e o oráculo como juiz. Do primeiro commit, no sábado à noite, até a release, foram uma noite e uma manhã.

No caminho, bati em três limites do Spinel. O psych dele não constrói Date, e todo arquivo da ruby-advisory-db tem uma linha date: sem aspas. A ferramenta lê essa linha com uma regex e tira ela do texto antes do YAML.safe_load.

O IPAddr com prefixo IPv6 precisa de inteiros maiores que 64 bits. Em vez de ligar o --int-overflow=promote, a ferramenta confere à mão as seis sub-redes privadas que o bundler-audit usa, e uma tabela de casos checada contra o IPAddr de verdade garante que a conta bate. E não há Struct: cada registro virou uma classe simples, com attr_reader.

Apareceram também dois bugs do Spinel, que eu anotei e contornei. Um sort_by sobre os valores de um Hash que começa vazio era recusado. E o Dir.glob devolvia a caixa do padrão, não a do disco: foi um lockfile com Rack maiúsculo que denunciou o segundo.

O resultado: 1.511 linhas de Ruby, contra 2.218 do bundler-audit 0.9.3, sem contar o Thor e o Bundler, que ele carrega e eu não. Menos linhas não é mérito por si só. O bundler-audit faz coisas que eu deixei de fora, como a Rake task.

Os números: a partida some, e o que sobra é o trabalho de verdade

Os números principais deste post são do binário publicado na release v0.1.0, com o SHA256 conferido, contra o bundler-audit 0.9.3, no mesmo Mac. Escrevi o método e as hipóteses num arquivo e fiz o commit antes da primeira execução. Assim eu não tinha como ajustar o critério depois de ver o resultado.

Cada caso rodou no hyperfine 2.0 em três rodadas de 50, com 5 warmups e a ordem das ferramentas invertida na segunda rodada. O Docker ficou fechado, e a carga da máquina caiu de 2,3 pra 1,4 durante a medição. Pra conferir contra ruído, contei também as instruções executadas, que quase não variam com a carga. O binário executa de 9 a 50 vezes menos instruções, o que confirma a diferença de tempo.

Teve um tropeço. No ensaio do roteiro, a análise não achou os dados e mesmo assim concluiu “hipótese aceita”, porque não havia nada pra contrariar. Agora ela falha se faltar um caso.

Quanto disso é pedágio? Num lockfile sem nada pra achar

  bundle-audit 0.9.3 spinel-bundler-audit 0.1.0
tempo (mediana de 150) 98,8 ms 3,5 ms
faixa interquartil 97,5–99,7 ms 3,4–3,6 ms
memória (pico, mediana) 27,8 MiB 4,3 MiB

Quase tudo é pedágio: o binário faz a mesma checagem em 3,5 ms. A medição do domingo confirma a do protótipo, agora com o binário que você baixa.

Quanto disso é pedágio? Testando em projetos reais

Tempo de um check, mediana de 150 execuções, bundler-audit contra spinel-bundler-audit. Lockfile limpo (1 gem): 98,8 contra 3,5 ms; Mastodon (345 gems): 158,4 contra 12,9 ms; Discourse (344 gems): 181,2 contra 17,1 ms; Forem (438 gems, 40 avisos): 179,9 contra 16,9 ms; Sintético (473 gems, 959 avisos): 279,4 contra 40,8 ms. bundler-audit 0.9.3 spinel-bundler-audit 0.1.0 mais rápido 0 ms 100 ms 200 ms 300 ms Lockfile limpo (1 gem) Lockfile limpo (1 gem), bundler-audit: 98,8 ms 98,8 ms Lockfile limpo (1 gem), spinel-bundler-audit: 3,5 ms 3,5 ms 28,2x Mastodon (345 gems) Mastodon (345 gems), bundler-audit: 158,4 ms 158,4 ms Mastodon (345 gems), spinel-bundler-audit: 12,9 ms 12,9 ms 12,3x Discourse (344 gems) Discourse (344 gems), bundler-audit: 181,2 ms 181,2 ms Discourse (344 gems), spinel-bundler-audit: 17,1 ms 17,1 ms 10,6x Forem (438 gems, 40 avisos) Forem (438 gems, 40 avisos), bundler-audit: 179,9 ms 179,9 ms Forem (438 gems, 40 avisos), spinel-bundler-audit: 16,9 ms 16,9 ms 10,6x Sintético (473 gems, 959 avisos) Sintético (473 gems, 959 avisos), bundler-audit: 279,4 ms 279,4 ms Sintético (473 gems, 959 avisos), spinel-bundler-audit: 40,8 ms 40,8 ms 6,8x
Mediana de 150 execuções (3 rodadas × 50), binário da release v0.1.0. hyperfine 2.0, Apple M3 Pro, 25/10/2026, ruby-advisory-db 03da937.
Caso bundle-audit spinel-bundler-audit razão memória (pico)
Mastodon (345 gems, 1 aviso) 158,4 ms 12,9 ms 12,3x 38,2 → 13,7 MiB
Discourse (344 gems, 1 aviso) 181,2 ms 17,1 ms 10,6x 38,4 → 12,9 MiB
Forem (438 gems, 40 avisos) 179,9 ms 16,9 ms 10,6x 39,9 → 15,4 MiB
Forem, -F json 181,6 ms 17,3 ms 10,5x 40,1 → 15,6 MiB
Sintético (473 gems, 959 avisos) 279,4 ms 40,8 ms 6,8x 47,0 → 42,2 MiB

Nos apps, o trabalho de verdade aparece, e a razão cai de 28x pra 10 a 12x. Em milissegundos, a economia cresce: 146 ms no Mastodon, 239 no sintético. O sintético é um lockfile montado pra estressar, com uma gem em cada versão vulnerável. Nenhum app real tem 959 avisos abertos, então ele serve de teto.

E a primeira chamada, quando o binário ainda não tem o índice da base? Montando o índice do zero a cada execução, ele levou 120 ms no Mastodon, ainda abaixo dos 158 do bundle-audit. O índice se paga já na primeira chamada.

Como ler as tabelas: as medianas são de 150 execuções, e as três rodadas concordam entre si. No Mastodon, o intervalo de confiança de 95% da razão vai de 12,2x a 12,4x. Memória é o pico de RSS de cada execução, medido pelo próprio hyperfine. Diferença de 1 ms aqui é ruído. O roteiro e os resultados completos estão no repositório.

O índice troca centenas de YAMLs por um arquivo

O bundler-audit lê um arquivo YAML por aviso, pra cada gem do lockfile que aparece na base. O índice troca isso por um arquivo só, de 1,29 MB, com os 1.269 avisos já decodificados. Ele fica em ~/.cache/spinel-bundler-audit/ e é refeito depois de cada update.

Cada índice vale pra um commit da base. Pra saber em que commit a base está, a ferramenta lê o .git/HEAD e as refs direto do disco, sem abrir um processo do git. Se o commit mudou, o índice é refeito na primeira checagem. Se a base não é um checkout do git, a ferramenta lê os YAMLs um por um, como o bundler-audit.

A primeira versão do índice lia o arquivo inteiro e montava todos os avisos já na leitura. A memória subiu junto. A segunda guarda no começo do arquivo uma tabela com a posição de cada gem. A checagem lê a tabela e depois só os avisos das gems do seu lockfile. O preço: um aviso editado à mão e sem commit não aparece até o commit mudar. Pra esse caso, SPINEL_BUNDLER_AUDIT_NO_INDEX=1 lê os arquivos direto.

A memória que sobra não é do índice, é do coletor

A tabela dos apps tem um ponto fraco à vista. No sintético, o binário usa 42 MiB, quase os 47 do bundler-audit. A minha primeira suspeita foi o índice. A segunda foi a saída, que nesse caso passa de 250 KB. Medi as duas.

Caso padrão -q (sem saída) sem índice GC o tempo todo
Sintético 42 MiB 41 MiB 43 MiB 16 MiB
Mastodon 13 MiB 13 MiB 12 MiB 7 MiB
Forem 15 MiB 15 MiB 14 MiB 7 MiB
Limpo 4 MiB 4 MiB 3 MiB 4 MiB

Pico de RSS, mediana de 3 execuções, binário da release.

Com -q, sem imprimir nada, a memória é a mesma. Sem o índice, também. Os dois suspeitos estavam limpos, e a segunda versão do índice cumpriu o papel dela.

O pico só cai quando eu ligo o SPINEL_GC_STRESS, que faz o coletor de lixo (GC) do Spinel rodar o tempo todo: 16 MiB no sintético e 7 nos apps. O resto é memória que o programa já descartou e o coletor ainda não recolheu. A política do Spinel dobra o limite do GC quando uma coleta libera pouco, e num programa que vive 40 ms quase não há coleta. É uma troca de tempo por memória, não um leak.

Tem conserto dos dois lados. Na ferramenta, dá pra alocar menos, decodificando só os campos que o relatório usa (issue #2). No Spinel, dá pra ajustar o limite do GC (issue #3). Quanto cada opção custa em tempo, eu ainda não medi.

No meio da investigação, acabei cometendo alguns erros de medição. O principal foi passar o caminho da base como relativo, e ele não valia a partir da raiz do app. A ferramenta não achava a base e baixava uma cópia nova, que é o comportamento certo e o mesmo do bundler-audit. Com isso, a medição do Mastodon mostrou um pico de 43 MiB, quando o certo eram 13. Foi esse número fora do esperado que me fez olhar de novo o script de medição, que o meu agente tinha montado.

Onde essa conta não fecha

  • Uma máquina só, e é um Mac. O CI típico roda em Linux. Lá eu conferi que o binário funciona e dá a mesma saída, mas não medi o tempo.
  • A vantagem encolhe quando o trabalho cresce. São 28x no lockfile limpo, 10 a 12x nos apps e 6,8x no sintético. O pedágio é fixo; o trabalho, não.
  • Com centenas de avisos, a memória quase empata. São 47 contra 42 MiB no sintético. A causa está explicada e tem issue aberta, mas hoje é assim.
  • 150 ms num job de CI que leva minutos: vale a pena? Num job só, ninguém percebe. O ganho aparece no que roda em loop, como um agente que audita a cada mudança no Gemfile.
  • O bundler-audit é maduro e mantido; o meu projeto é 0.1.0. Ele tem anos de uso em produção, e um oráculo não substitui isso.
  • O índice não vê aviso editado sem commit. Pra base mexida à mão, o ideal é desligar o índice com SPINEL_BUNDLER_AUDIT_NO_INDEX=1 spinel-bundler-audit check.

Agora eu falo por mim

O Spinel não deixa todo Ruby mais rápido, e não é isso que esse experimento mostra. O que ele mostra é mais estreito. Pra uma ferramenta pequena, chamada muitas vezes, que passa mais tempo subindo do que trabalhando, compilar tira o custo de onde ele está.

É o critério que eu uso pra escolher o que portar: ferramenta que CI e agente chamam em loop, como teste, formatação, lint e auditoria. Escrita em Ruby limpo, ela compila. Escrita com metaprogramação, ainda não. Isso empurra pra um tipo de código com classes simples e pouca mágica, que é o que eu já gosto de escrever.

Como usar hoje

O código está em github.com/aristotelesbr/spinel-bundler-audit, e a release v0.1.0 saiu em 25 de outubro. A instalação é uma linha, no macOS e no Linux. O script escolhe o binário da sua máquina, confere contra o SHA256SUMS da release e instala em ~/.local/bin. Não precisa de Ruby. Roda no macOS 11 ou mais novo e no Linux com glibc 2.34 ou mais nova, o que inclui Ubuntu 22.04, Debian 12, RHEL 9 e Amazon Linux 2023.

curl -fsSL https://raw.githubusercontent.com/aristotelesbr/spinel-bundler-audit/main/install.sh | sh

Os comandos são os do bundler-audit, e a base fica no mesmo lugar, então as duas ferramentas podem dividir a mesma configuração. No CI, basta um passo com spinel-bundler-audit check --update. Num agente, atualize a base uma vez com spinel-bundler-audit update e chame check quantas vezes quiser.

Trocar de ferramenta não muda os tokens. Como a saída é a mesma, o agente lê exatamente o mesmo texto, 93 tokens no Mastodon e 4.106 no Forem (contados com o tokenizador o200k_base, que é uma aproximação). A diferença aparece fora do happy path. Um .bundler-audit.yml inválido custa 874 tokens no bundler-audit, que imprime o backtrace inteiro, e 23 no binário, que imprime uma linha com o erro.

E uma dica que vale pra qualquer ferramenta: se quem vai ler a saída é o modelo, peça texto. O mesmo relatório do Forem custa 4.106 tokens em texto e 16.861 em -F json, quatro vezes mais. JSON é pra outro programa ler.

Voltando ao começo

Voltando ao que eu prometi no começo, uma pergunta de cada vez.

O pedágio é real? É. Com a máquina parada e um lockfile sem nada pra achar, foram 98,8 ms no bundle-audit contra 3,5 ms no binário da release, 28 vezes. E nem precisa do Spinel pra ver parte disso: no protótipo, só reescrever sem Bundler e sem Thor, ainda rodando no CRuby, já levou a partida de 97 pra 44 ms.

Compatível mesmo? Os 984 casos do oráculo bateram byte a byte numa rodada isolada, e no CI das três plataformas bateram os 979 que não dependem de terminal. As diferenças de propósito estão no README, uma por uma.

Fica mais rápido onde importa? No lockfile do Mastodon, 158,4 ms contra 12,9 ms, 12 vezes; no Discourse e no Forem, 10,6. Até a primeira execução, montando o índice do zero, levou 120 ms, abaixo dos 158 do bundle-audit. É esse número que decide se a ferramenta vale pro seu CI.

E a memória? Nos apps reais, cai pra um terço: 38 contra 13 MiB no Mastodon. No sintético com 959 avisos, quase empata, 47 contra 42 MiB. Não é o índice nem a saída: é lixo que o GC do Spinel ainda não recolheu, e tem issue aberta pra isso.

Três coisas, pra não terminar em prosa bonita:

  • Meça a partida antes de otimizar o trabalho. No caso limpo, o bundle-audit gastava 98,8 ms e quase nada disso era checagem.
  • Compatível é o que o diff diz. “Parecido” não serve pra uma ferramenta que o seu CI lê pelo código de saída. Eu diria que montar o oráculo antes de escrever a primeira linha de otimização é o que deixa você otimizar sem medo: toda mudança que mantém o diff vazio está valendo.
  • Desconfie da primeira suspeita. A memória que sobrava parecia culpa do índice, e o índice estava limpo. Foram três medições simples, com -q, sem índice e com o GC rodando o tempo todo, pra achar o culpado de verdade.

O bundler-audit continua sendo a referência, e a ruby-advisory-db continua fazendo o trabalho. O que eu tirei foi o pedágio. Não confie só nos meus números: o roteiro e os dados brutos estão no repositório. Rode os dois no seu Gemfile.lock e compare a saída.