Segurança • 10 min de leitura

O Git Não Esquece — Mas Você Pode Fazer Ele Esquecer

O Git Não Esquece — Mas Você Pode Fazer Ele Esquecer

Cenário real: Você commita um .env com 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:

  1. Repo único — você sabe exatamente onde está o problema e quer resolver agora
  2. 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-repo e não git filter-branch?

O filter-branch foi oficialmente deprecado. É lento (ordens de magnitude), deixa refs de backup que podem expor exatamente o que você tentou apagar, e o syntax é uma armadilha. O git-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 que git log para 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çãoComando
Ver se arquivo existiu no históricogit log --all --full-history --oneline -- arquivo
Ler conteúdo num commit antigogit show <sha>:caminho/arquivo
Buscar string em todo o históricogit log --all -S "texto" --oneline
Purgar arquivo por caminhogit filter-repo --invert-paths --path arquivo
Purgar por regexgit filter-repo --invert-paths --path-regex 'padrao'
Substituir valor sem remover arquivogit filter-repo --replace-text replacements.txt
Inventariar objetos grandes no packgit cat-file --batch-check --batch-all-objects \| awk '$2=="blob" && $3>204800'
Resolver sha para caminhogit log --all --find-object=<sha> --name-only --format=
Forçar reescrita no remotogit push --mirror --force