Um ticket de hardening que precisou ser re-derivado antes de eu poder implementá-lo
Esse foi um issue de hardening de segurança de CI/CD com três tarefas escritas semanas atrás, vindas de uma investigação exploratória que auditou o repositório inteiro. Quando cheguei nele, o chão tinha mudado debaixo dele. Duas das três tarefas continuavam certas. A terceira me ensinou mais do que eu esperava de um diff de workflow de duas linhas.
A parte que precisou ser re-derivada
A primeira tarefa dizia: parar de rodar builds de pull request no runner self-hosted, porque um bump de dependência comprometido poderia executar código arbitrário ali. Razoável, quando foi escrita. Só que, no meio do caminho, eu tinha migrado o pipeline de CI inteiro para esse mesmo runner self-hosted, justamente para que pull requests voltassem a ter feedback de teste real depois que o GitHub cortou o billing de runners hospedados. Fazer o que o ticket antigo dizia teria desfeito isso silenciosamente.
Então, em vez de implementar, fui checar o que é verdade de fato hoje. O GitHub já trata pull requests autorados pelo Dependabot como se viessem de um fork - token somente leitura, zero acesso a secrets - automaticamente, sem precisar de configuração. Isso está documentado, e li a documentação em vez de presumir. O risco residual (o código de uma dependência ruim ainda roda no host do runner durante o install e o test, mesmo sem secrets) é real, mas não existe correção via arquivo de workflow para isso enquanto a situação de billing continuar assim - rotear esses PRs para runners hospedados só faz eles voltarem a falhar ao iniciar, que é exatamente o problema que eu já tinha resolvido. Expliquei isso e perguntei antes de descartar a tarefa, em vez de decidir sozinho e silenciosamente que um ticket antigo não se aplicava mais.
O que eu construí
As outras duas tarefas se sustentaram bem: permissões explícitas somente leitura nos workflows, e não deixar mais uma master key do Rails descriptografada parada no disco do runner depois de um deploy.
A segunda é onde ficou interessante. Minha primeira passada escrevia a chave num arquivo, rodava o deploy, depois apagava o arquivo. Direto ao ponto, e funcionava. O code review apontou que isso só encolhe a janela de exposição - não a fecha. Se o processo do runner morresse entre a escrita e a exclusão, o que pode acontecer numa máquina persistente que não é desmontada depois de cada job, a chave simplesmente ficaria ali. E então veio uma pergunta mais difícil: por que eu estava escrevendo isso num arquivo, se o workflow já tinha isso como variável de ambiente?
Boa pergunta. Fui investigar, e descobri que a escrita do arquivo existia puramente porque a configuração de secrets da ferramenta de deploy tinha hardcoded a leitura a partir de um caminho de arquivo em vez do ambiente. Nada no Rails ou na ferramenta de deploy exigia de fato um arquivo - consegui provar isso, porque um job completamente diferente no mesmo pipeline já passava o mesmo secret como uma variável de ambiente simples, sem nenhum arquivo envolvido.
O que me surpreendeu
Corrigir o arquivo de secrets não foi tão simples quanto trocar por uma expressão de fallback estilo bash - “use a variável de ambiente se estiver definida, senão volte para o arquivo.” Escrevi isso, e parecia razoável. Depois li de fato o código-fonte da ferramenta que faz o parsing desse arquivo, em vez de presumir que ela se comporta como um script shell só porque parece um. Não se comporta. É parseada por uma biblioteca estilo dotenv com uma pequena extensão customizada plugada para substituição de comando, e essa biblioteca não tem nenhum conceito de sintaxe de fallback. Alimente ela com o que eu escrevi e ela silenciosamente mantém só a primeira metade, descarta o resto como texto lixo, e devolve um valor corrompido sem nenhum erro. Esse é o tipo de bug que parece bem num diff e só se anuncia quando um deploy quebra silenciosamente em produção - um que eu especificamente quero sempre evitar. (O comportamento de secrets dotenv do Kamal já me mordeu antes.)
A correção que sobreviveu ao contato com o parser de verdade: a ferramenta suporta empilhar um arquivo de secrets específico de um destino sobre um compartilhado, com valores posteriores sobrescrevendo os anteriores para a mesma chave. Então dei ao destino de staging seu próprio arquivinho que lê a chave direto do ambiente, enquanto deixava o arquivo compartilhado - o que a produção ainda usa para deploys manuais locais - completamente intocado. Testei isso diretamente antes de confiar: instanciei o próprio resolvedor de secrets da ferramenta num script descartável, confirmei que o staging pegava um valor de ambiente falso enquanto a produção continuava lendo a chave real do disco exatamente como antes. Depois fiz o merge, vi o deploy real rodar no CI, e entrei via SSH no runner como root depois para confirmar - não presumir - que nenhum arquivo de chave existe em lugar nenhum no seu workspace.
O que vem a seguir
Nada de follow-up aqui. Mas o formato desse é digno de lembrar: um ticket antigo de hardening, uma correção “deveria ser simples” que tinha uma resposta errada com cara de plausível, e um comentário de review que se provou certo sobre profundidade, não só sobre estilo. Vale a pena checar o parser de verdade antes de confiar numa sintaxe que só parece com algo que você já escreveu cem vezes antes.
Leitura relacionada
Deploy automático do Rails 8 para staging com Kamal e um runner self-hosted do GitHub Actions
Fazendo todo merge disparar deploy automático para staging: um runner self-hosted, quatro paredes seguidas, e a pegadinha dos secrets do Kamal que demorou mais para ser decifrada.
Fazendo backfill da realidade num rastreador de sindicação
O prod dizia zero posts no Medium; as rake tasks resolveram isso em minutos. Depois o reconciler quebrou em todo lugar, um dashboard foi raspado direto do DOM, e o CI falhou de três formas diferentes.
Três linhas de config, uma tarde de verificação
Descomentar flags de SSL do Rails levou dez minutos. Ler o código-fonte do framework, testar dois achados de revisão plausíveis mas errados, e provar o cookie em produção levou o resto do tempo.
Você também pode achar útil
AdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, ganho com compras qualificadas.
Saiba maisProton VPN
VPN comercial com filtragem NetShield e interruptor de desligamento automático.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisProton Pass
Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba mais