Cenário real: Você commita um
.envcom senhas de banco, credenciais AWS, tokens de API. Faz mais 30 commits em cima. Remove o arquivo. Faz push. Respira aliviado.
Errado. O arquivo ainda está lá — em cada clone, em cada fork, nos caches do GitHub, no histórico imutável que qualquer
git checkout <sha-antigo>entrega de bandeja.
Este guia cobre dois cenários distintos:
- Repo único — você sabe exatamente onde está o problema e quer resolver agora
- Múltiplos repos — você tem dezenas de repositórios, não lembra quais têm segredos, e precisa de uma operação forense + cirúrgica em escala
Por que deletar o arquivo não resolve
O Git armazena snapshots. Quando você commita um .env, ele cria um objeto blob com o conteúdo daquele arquivo. Esse objeto fica no pack do repositório para sempre — mesmo que o commit seguinte delete o arquivo. Qualquer pessoa com acesso pode fazer:
git log --all --full-history -- .env # vê todos os commits que tocaram o arquivo
git show <sha-do-commit-antigo>:.env # lê o conteúdo exato na época
git checkout <sha-antigo> -- .env # restaura o arquivo localmente
E se o repo for público, bots já varreram e indexaram o conteúdo minutos após o push.
Pré-requisitos (único e múltiplos repos)
Testado em WSL com Debian Trixie. Funciona em qualquer Linux/macOS.
# git-filter-repo — substituto oficial e recomendado do git filter-branch
pip install git-filter-repo --break-system-packages
# GitHub CLI — necessário apenas para o cenário multi-repo
gh auth login
# ou: export GITHUB_TOKEN=ghp_xxxxxxxxxxxxx
# Versões mínimas
git --version # >= 2.24.0
python3 --version # >= 3.6
git-filter-repo --version
Por que
git-filter-repoe nãogit filter-branch?
O
filter-branchfoi oficialmente deprecado. É lento (ordens de magnitude), deixa refs de backup que podem expor exatamente o que você tentou apagar, e o syntax é uma armadilha. Ogit-filter-repoé 10–100x mais rápido, não deixa rastros, e opera diretamente nos objetos pack.
CENÁRIO 1 — Repo único
Quando usar
Você sabe qual repo tem o problema e quer agir rápido.
Passo 1 — Clone limpo (obrigatório)
O git-filter-repo exige um clone fresco. Não tente rodar no seu clone de trabalho.
git clone --mirror git@github.com:seu-usuario/seu-repo.git repo-mirror
cd repo-mirror
O --mirror baixa todas as branches, tags e refs — necessário para garantir que a purga seja completa em todo o histórico.
Passo 2 — Confirmar que o problema existe no histórico
Antes de qualquer coisa, verifique:
# Listar todos os commits que tocaram o .env
git log --all --full-history --oneline -- .env
# Ler o conteúdo do arquivo em um commit específico
git show <sha>:.env
# Buscar por string exata em todo o histórico (útil quando você sabe a senha)
git log --all -S "minha_senha_aqui" --oneline
Passo 3 — Purgar arquivos específicos
# Remover um ou mais arquivos de TODO o histórico (todas as branches e tags)
git filter-repo --invert-paths \
--path .env \
--path .env.example \
--path .env.local \
--path .env.production \
--path .env.staging \
--path secrets.json \
--path config/database.yml
Para remover por padrão (regex):
# Qualquer arquivo .env* em qualquer subdiretório
git filter-repo --invert-paths --path-regex '(^|/)\.env(\.|$)'
# Chaves privadas em qualquer lugar do histórico
git filter-repo --invert-paths --path-regex '\.(pem|key|p12|pfx)$'
Passo 4 — Substituir valor sem remover o arquivo inteiro
Se o segredo estava no meio de um arquivo que você precisa manter (ex: um config.py com uma senha hardcoded em alguma versão antiga), use --replace-text:
# Criar arquivo de substituições
cat > replacements.txt << 'EOF'
minha_senha_super_secreta==>***REMOVED***
AKIAIOSFODNN7EXAMPLE==>***REMOVED***
postgres://admin:senha123@host==>postgres://admin:***REMOVED***@host
EOF
git filter-repo --replace-text replacements.txt
Isso percorre cada versão de cada arquivo em todo o histórico e substitui o texto. O arquivo continua existindo no histórico — só o valor exposto é apagado.
Passo 5 — Verificar a purga
# Confirmar que o arquivo sumiu do histórico
git log --all --full-history --oneline -- .env
# (deve retornar vazio)
# Confirmar que o conteúdo sensível não existe mais em nenhum blob
git log --all -S "minha_senha_aqui" --oneline
# (deve retornar vazio)
Passo 6 — Force push de volta ao GitHub
# Recolocar o remote (o filter-repo remove por segurança)
git remote add origin git@github.com:seu-usuario/seu-repo.git
# Forçar reescrita de todo o histórico remoto
git push --mirror --force
⚠️ Se houver branch protection no repo, desative temporariamente em:
Settings → Branches → editar a regra → desmarcar "Require pull request reviews"
Passo 7 — Detectar e remover bloat no repo único
Se além de segredos o repo ficou inchado com assets pesados, node_modules ou vendor commitados por engano:
# Ver os maiores objetos no histórico (rápido — lê direto do pack)
git cat-file --batch-check --batch-all-objects \
| awk '$2=="blob" && $3 > 204800 {print $3, $1}' \
| sort -rn \
| head -20
# Resolver o sha para o caminho do arquivo
git log --all --find-object=<sha-do-blob> --name-only --format=
# Remover diretórios inteiros do histórico
git filter-repo --invert-paths \
--path node_modules/ \
--path vendor/ \
--path .yarn/cache \
--path Pods/
# Remover por extensão (imagens, zips, mídia)
git filter-repo --invert-paths \
--path-regex '\.(jpg|jpeg|png|gif|psd|mp4|mp3|wav|zip|tar\.gz|rar|pdf)$'
💡 Dica: o
git cat-file --batch-check --batch-all-objectsé ordens de magnitude mais rápido quegit logpara inventariar objetos. Ele lê direto do pack sem percorrer a árvore de commits. Em repos grandes, a diferença é de segundos vs. minutos — e ele captura arquivos que já foram deletados mas ainda pesam no histórico.
Passo 8 — Atualizar o .gitignore para não acontecer de novo
# No seu clone de trabalho (não o mirror):
cat >> .gitignore << 'EOF'
.env
.env.*
!.env.example
secrets.json
credentials.json
*.pem
*.key
*.p12
*.pfx
terraform.tfvars
EOF
git add .gitignore
git commit -m "chore: adicionar arquivos sensíveis ao .gitignore"
git push
CENÁRIO 2 — Múltiplos repositórios
Quando usar
- Você tem muitos repos e não sabe quais têm problemas
- Quer uma operação em lote com relatório antes de agir
- Precisa cobrir repos públicos e privados
A maioria dos tutoriais online cobre um repo, um arquivo. O problema real é diferente: você tem 40, 80, 190+ repositórios. Alguns são de 5 anos atrás, quando você não usava .gitignore. As senhas podem estar em qualquer arquivo: settings.py, appsettings.json, docker-compose.yml, terraform.tfvars, uma connection string num comentário de 2018.
A abordagem correta tem três fases:
FASE 1 → AUDIT : Descobrir onde estão os problemas (não modifica nada)
FASE 2 → REVIEW : Revisar o relatório antes de qualquer ação destrutiva
FASE 3 → PURGE : Reescrever o histórico somente onde necessário
Script de Auditoria — git-audit.py
Lista todos os seus repos (com paginação automática, suporta 190+), faz clone --mirror de cada um, varre o histórico inteiro com regex e detecta segredos e bloat — sem modificar nada.
# Auditoria completa (segredos + bloat)
python3 git-audit.py
# Apenas repos públicos, apenas segredos (mais rápido)
python3 git-audit.py --only-public --only-secrets
Nada é modificado. Apenas leitura.
---
Revisando o relatório antes de purgar
# Repos públicos com segredos — estes são urgentes
jq '.results[] | select(.private == false and (.secrets | length > 0)) | .repo' audit_report.json
# Contagem de segredos por repo
jq '.results[] | select((.secrets | length) > 0) | {repo, total: (.secrets | length)}' audit_report.json
# Espaço total a recuperar com remoção do bloat
jq '[.results[].bloat.large_files[].size_kb] | add // 0 | "\(.) KB"' audit_report.json
Script de Purga em Lote — git-purge.py
Processa apenas os repos que realmente têm problemas, detectados pela auditoria. Lê o audit_report.json e executa git-filter-repo + force push somente onde necessário.
# Simular sem alterar nada (recomendado antes do real)
python3 git-purge.py --dry-run
# Purgar tudo que foi detectado
python3 git-purge.py
# Purgar apenas um repo específico
python3 git-purge.py --repo meu-repo-problematico
# Apenas segredos, sem mexer no bloat
python3 git-purge.py --only-secrets
⚠️ Avisos que outros artigos não mencionam
Revogar as credenciais é mais urgente que a purga.
O tempo médio para bots varreram um segredo exposto num repo público é menos de 60 segundos após o push. Se o arquivo foi para um repo público, assuma que as credenciais já foram coletadas. Invalide-as antes de começar qualquer limpeza de histórico.
O force push reescreve o histórico para todo mundo.
Quem clonou o repo antes terá histórico divergente. Não adianta git pull — precisará deletar o clone local e fazer um novo. Comunique antes de rodar.
O GitHub mantém caches de objetos por até 90 dias.
Mesmo após o force push, o conteúdo pode ser acessível via URL direta de objeto. Para acelerar a invalidação, abra um ticket em support.github.com informando os SHAs ou as URLs afetadas.
Branch protection impede o force push.
Desative temporariamente em: Settings → Branches → editar regra → desmarcar "Require pull request reviews".
Repos forkados são independentes.
A purga não afeta forks. Se o repo tinha forks quando o segredo foi exposto, esses forks podem ainda preservar o histórico original. Solicite remoção ao GitHub Support se necessário.
Prevenção: nunca mais passar por isso
Pre-commit hook local (zero dependências)
cat > .git/hooks/pre-commit << 'HOOK'
#!/bin/bash
# Bloqueia commit de arquivos sensíveis e padrões de segredo
BLOCKED_FILES=(".env" ".env.local" ".env.production" ".env.staging"
"secrets.json" "credentials.json" "terraform.tfvars")
BLOCKED_PATTERNS=(
"AKIA[0-9A-Z]{16}"
"ghp_[A-Za-z0-9]{36}"
"-----BEGIN.*PRIVATE KEY"
"sk_live_[A-Za-z0-9]{24}"
"SG\.[A-Za-z0-9\-_]{22}"
)
for f in "${BLOCKED_FILES[@]}"; do
if git diff --cached --name-only | grep -qxF "$f"; then
echo "❌ BLOQUEADO: '$f' não deve ser commitado."
exit 1
fi
done
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if git diff --cached -U0 | grep -qE "$pattern"; then
echo "❌ BLOQUEADO: possível segredo detectado (padrão: $pattern)"
exit 1
fi
done
exit 0
HOOK
chmod +x .git/hooks/pre-commit
Para aplicar em todos os repos novos automaticamente:
git config --global core.hooksPath ~/.git-hooks
mkdir -p ~/.git-hooks
cp .git/hooks/pre-commit ~/.git-hooks/pre-commit
GitHub Actions — scan em cada PR
# .github/workflows/secret-scan.yml
name: Secret Scan
on:
pull_request:
push:
branches: [main, master]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # histórico completo, não só o último commit
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
O Gitleaks mantém mais de 150 padrões de detecção cobrindo todas as plataformas da tabela de detecção acima, e bloqueia o PR automaticamente se encontrar algo.
Referência rápida
| Situação | Comando |
|---|---|
| Ver se arquivo existiu no histórico | git log --all --full-history --oneline -- arquivo |
| Ler conteúdo num commit antigo | git show <sha>:caminho/arquivo |
| Buscar string em todo o histórico | git log --all -S "texto" --oneline |
| Purgar arquivo por caminho | git filter-repo --invert-paths --path arquivo |
| Purgar por regex | git filter-repo --invert-paths --path-regex 'padrao' |
| Substituir valor sem remover arquivo | git filter-repo --replace-text replacements.txt |
| Inventariar objetos grandes no pack | git cat-file --batch-check --batch-all-objects \| awk '$2=="blob" && $3>204800' |
| Resolver sha para caminho | git log --all --find-object=<sha> --name-only --format= |
| Forçar reescrita no remoto | git push --mirror --force |