Estava a fazer uma revisão geral do meu projeto, à procura de coisas esquecidas. Encontrei uma que preferia não ter encontrado: um ficheiro de configuração de deploy com a chave que assina os tokens de login, a palavra-passe do administrador e a palavra-passe para o envio de emails. Em texto simples, no histórico do git, em três branches.
Como foi possível
O ficheiro estava no .gitignore. O problema é que foi lá parar depois de ter sido feito commit uma primeira vez, e o .gitignore só ignora ficheiros que ainda não são seguidos: um ficheiro já seguido continua a sê-lo, alteração após alteração, mesmo que o nome esteja na lista.
O repositório era privado, mas privado não significa seguro: veem-no os colaboradores, os serviços de CI, os backups e todos os computadores onde foi clonado.
Removê-lo de todo o histórico
Apagar o ficheiro com um novo commit não serve: ele continua em todos os commits anteriores. É preciso reescrever o histórico de todos os branches. Usei git filter-repo num clone mirror, depois fiz push forçado e verifiquei que nenhum commit o continha:
# 1. Stop tracking the file (it stays on disk)
git rm --cached .env.deploy
git commit -m "Stop tracking .env.deploy"
# 2. Rewrite the history of EVERY branch on a mirror clone
git clone --mirror git@github.com:me/my-repo.git
cd my-repo.git
git filter-repo --path .env.deploy --invert-paths
git push --force --mirror
# 3. Verify: no commit on any branch may still contain it
git log --all --oneline -- .env.deploy
Depois de uma reescrita destas, quem tiver uma cópia local tem de a realinhar com o novo remoto: os commits antigos não podem voltar a ser publicados.
Porque não chega
Aqui está a lição mais importante. Mesmo depois da reescrita, os commits antigos podem continuar acessíveis: o GitHub, por exemplo, mantém referências internas aos pull requests que um push forçado não consegue apagar. E não se sabe quem descarregou o repositório no passado.
Remover um segredo do histórico serve para não piorar a situação; a única coisa que a resolve é mudá-lo. Na prática:
- Nova chave de assinatura dos tokens: invalida todas as sessões existentes, por isso todos terão de voltar a fazer login. É o preço a pagar.
- Nova palavra-passe de administrador.
- Revogação da palavra-passe da aplicação de email e criação de uma nova.
- Atualização das variáveis em todos os ambientes onde são usadas.
A segunda vez, o mesmo padrão
Algumas semanas depois encontrei o mesmo problema noutra forma: um script de instalação do servidor, muito prático, que escrevia as credenciais diretamente no ficheiro de configuração. É o padrão recorrente: o script de conveniência que guarda os segredos para não ter de configurar nada. Reescrevi-o para gerar as chaves no momento e pedir as palavras-passe a quem o executa:
# Before: secrets written into a script tracked by git
# JWT_SECRET=hardcoded-value...
# After: generated or asked at runtime, never committed
JWT_SECRET="$(openssl rand -base64 48)"
read -rsp "SMTP password: " SMTP_PASS; echo
umask 077 # the .env file is readable by its owner only
printf 'JWT_SECRET=%s\nSMTP_PASS=%s\n' "$JWT_SECRET" "$SMTP_PASS" > .env
Os hábitos que evitam que volte a acontecer
- Um
.env.exampleno repositório, com os nomes das variáveis e sem valores; o.envverdadeiro nunca entra no repositório. - Um scanner de segredos antes de cada commit, como o gitleaks num hook de pre-commit: bloqueia o commit antes de o mal estar feito.
- A proteção de push oferecida pelo GitHub e pelo GitLab, que rejeita pushes com segredos reconhecíveis.
- Nunca segredos em scripts: gerá-los em runtime ou lê-los do ambiente.
- Uma verificação periódica do histórico: um segredo encontrado por ti é um incidente pequeno, um encontrado por outros não.
Em resumo
Um segredo num repositório deve ser tratado como comprometido no momento em que o encontras. Reescrever o histórico é necessário mas não suficiente: a verdadeira correção é mudar todas as credenciais expostas. Depois, convém tornar o problema impossível de repetir, com scanners e regras claras. Reuni mais práticas de segurança neste guia.