Secret Scanner em Python Puro: O Caçador de Senhas Vazadas Que Encontra API Keys Esquecidas no Seu Código (Antes do Hacker)

Você já commitou uma senha no Git? Não precisa responder em voz alta. Eu sei que sim. Todo mundo já. A questão não é se aconteceu, mas quando alguém vai encontrar.

Ferramentas como TruffleHog e GitLeaks existem pra isso. São ótimas. Mas elas exigem instalação, configuração, e às vezes você só precisa de um script que roda em qualquer máquina, sem dependências, e te diz agora se tem algo perigoso no seu repositório.

É isso que vamos construir hoje.


O Problema: Seu Código é um Campo Minado

Uma pesquisa da GitGuardian de 2024 encontrou 12,8 milhões de secrets vazados em repositórios públicos do GitHub. Não estamos falando de projetos amadores. Empresas como Uber, Tesla e Slack já tiveram incidentes por API keys commitadas.

O cenário clássico: você está desenvolvendo, cola uma chave de API no código pra testar rápido, esquece de remover, commita, e pronto — essa chave tá no histórico do Git pra sempre. Mesmo que você remova depois, git log não esquece.

E se você tem um projeto com 200 arquivos e 15 desenvolvedores? Boa sorte conferindo na mão.


O Scanner: 150 Linhas, Zero Dependências

Aqui está o scanner completo. Ele varre diretórios recursivamente, identifica possíveis secrets usando regex e entropia, e gera um relatório:

#!/usr/bin/env python3
"""
secret_scanner.py — Caçador de secrets em codebases.
Zero dependências. Usa apenas biblioteca padrão do Python.

Uso:
    python3 secret_scanner.py /caminho/do/projeto
    python3 secret_scanner.py . --exclude node_modules,.git,__pycache__
    python3 secret_scanner.py . --json > relatorio.json
"""

import os
import re
import sys
import json
import math
import argparse
from collections import defaultdict
from datetime import datetime

# ─── Padrões de secrets conhecidos ───────────────────────────────────
# Cada padrão tem: nome, regex, e nível de confiança (high/medium/low)
SECRET_PATTERNS = [
    # Cloud & Infra
    {
        "name": "AWS Access Key",
        "regex": r"(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}",
        "confidence": "high",
    },
    {
        "name": "AWS Secret Key",
        "regex": r"""(?i)aws[_.\-]?secret[_.\-]?access[_.\-]?key\s*[:=]\s*['\"]?([A-Za-z0-9/+=]{40})['\"]?""",
        "confidence": "high",
    },
    {
        "name": "Google API Key",
        "regex": r"AIza[0-9A-Za-z\-_]{35}",
        "confidence": "high",
    },
    {
        "name": "Google OAuth Client Secret",
        "regex": r"""(?i)client[_.\-]?secret\s*[:=]\s*['\"]?([A-Za-z0-9\-_]{24,})['\"]?""",
        "confidence": "medium",
    },
    {
        "name": "GitHub Token",
        "regex": r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}",
        "confidence": "high",
    },
    {
        "name": "GitLab Token",
        "regex": r"glpat-[A-Za-z0-9\-_]{20,}",
        "confidence": "high",
    },

    # Pagamentos
    {
        "name": "Stripe Secret Key",
        "regex": r"[sr]k_live_[0-9a-zA-Z]{24,}",
        "confidence": "high",
    },
    {
        "name": "Stripe Publishable Key",
        "regex": r"pk_(?:live|test)_[0-9a-zA-Z]{24,}",
        "confidence": "medium",
    },

    # Comunicação
    {
        "name": "Slack Token",
        "regex": r"xox[aboprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24,}",
        "confidence": "high",
    },
    {
        "name": "Slack Webhook",
        "regex": r"https://hooks\.slack\.com/services/T[A-Z0-9]{8}/B[A-Z0-9]{8}/[a-zA-Z0-9]{24}",
        "confidence": "high",
    },
    {
        "name": "Discord Bot Token",
        "regex": r"[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27,}",
        "confidence": "medium",
    },
    {
        "name": "Telegram Bot Token",
        "regex": r"[0-9]{8,10}:[a-zA-Z0-9\-_]{35}",
        "confidence": "medium",
    },

    # Genéricos (alta taxa de falso-positivo, mas importantes)
    {
        "name": "Private Key Block",
        "regex": r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
        "confidence": "high",
    },
    {
        "name": "Connection String",
        "regex": r"(?i)(?:mongodb|postgres|mysql|redis|amqp)://[^\s'\"]+:[^\s'\"]+@[^\s'\"]+",
        "confidence": "high",
    },
    {
        "name": "Password em Variável",
        "regex": r"""(?i)(?:password|passwd|pwd|senha)\s*[:=]\s*['\"]([^'"]{8,})['\"]""",
        "confidence": "medium",
    },
    {
        "name": "API Key Genérica",
        "regex": r"""(?i)(?:api[_\-]?key|apikey)\s*[:=]\s*['\"]([A-Za-z0-9\-_]{20,})['\"]""",
        "confidence": "medium",
    },
    {
        "name": "JWT Token",
        "regex": r"eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+",
        "confidence": "medium",
    },
    {
        "name": "Bearer Token",
        "regex": r"""(?i)bearer\s+[A-Za-z0-9\-._~+/]+=*""",
        "confidence": "low",
    },
]

# ─── Extensões de arquivo para escanear ──────────────────────────────
SCANNABLE_EXTENSIONS = {
    ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rb",
    ".php", ".cs", ".rs", ".swift", ".kt", ".scala", ".sh", ".bash",
    ".zsh", ".fish", ".yml", ".yaml", ".json", ".toml", ".ini",
    ".cfg", ".conf", ".env", ".properties", ".xml", ".html", ".vue",
    ".svelte", ".tf", ".hcl", ".sql", ".lua", ".r", ".m", ".dart",
    ".c", ".cpp", ".h", ".hpp", ".md", ".txt", ".rst", ".dockerfile",
    ".gradle", ".groovy", ".ps1", ".bat", ".cmd",
}

# Diretórios para pular sempre
DEFAULT_EXCLUDES = {
    ".git", "node_modules", "__pycache__", ".venv", "venv",
    ".tox", ".mypy_cache", ".pytest_cache", "dist", "build",
    ".next", ".nuxt", "vendor", ".idea", ".vscode",
}


def calculate_entropy(data: str) -> float:
    """Calcula entropia de Shannon de uma string.
    Strings com alta entropia (>4.5) tendem a ser secrets gerados aleatoriamente."""
    if not data:
        return 0.0
    freq = defaultdict(int)
    for char in data:
        freq[char] += 1
    length = len(data)
    entropy = 0.0
    for count in freq.values():
        prob = count / length
        if prob > 0:
            entropy -= prob * math.log2(prob)
    return entropy


def is_binary_file(filepath: str) -> bool:
    """Verifica se arquivo é binário (pula imagens, compilados, etc)."""
    try:
        with open(filepath, "rb") as f:
            chunk = f.read(8192)
            if b"\x00" in chunk:
                return True
    except (IOError, OSError):
        return True
    return False


def scan_file(filepath: str) -> list:
    """Escaneia um arquivo e retorna lista de possíveis secrets encontrados."""
    findings = []
    try:
        with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
            lines = f.readlines()
    except (IOError, OSError):
        return findings

    for line_num, line in enumerate(lines, start=1):
        stripped = line.strip()

        # Pula comentários óbvios e linhas muito curtas
        if len(stripped) < 10:
            continue

        for pattern in SECRET_PATTERNS:
            matches = re.finditer(pattern["regex"], line)
            for match in matches:
                matched_text = match.group(0)

                # Filtro de entropia para reduzir falsos positivos
                # Secrets reais têm entropia alta (são aleatórios)
                entropy = calculate_entropy(matched_text)

                # Pula matches de baixa entropia em padrões genéricos
                if pattern["confidence"] in ("low", "medium") and entropy < 3.5:
                    continue

                # Pula exemplos óbvios (YOUR_KEY_HERE, xxx, etc)
                if re.search(r"(?:example|sample|your[_\-]?|placeholder|xxx+|test)", matched_text, re.I):
                    continue

                findings.append({
                    "file": filepath,
                    "line": line_num,
                    "pattern": pattern["name"],
                    "confidence": pattern["confidence"],
                    "match": matched_text[:80] + ("..." if len(matched_text) > 80 else ""),
                    "entropy": round(entropy, 2),
                    "context": stripped[:120],
                })

    return findings


def scan_directory(
    root_path: str,
    excludes: set = None,
    scan_all: bool = False,
) -> dict:
    """Varre um diretório recursivamente buscando secrets."""
    excludes = DEFAULT_EXCLUDES | (excludes or set())
    results = {
        "scan_time": datetime.now().isoformat(),
        "root": os.path.abspath(root_path),
        "files_scanned": 0,
        "files_skipped": 0,
        "total_findings": 0,
        "by_confidence": {"high": 0, "medium": 0, "low": 0},
        "by_pattern": defaultdict(int),
        "findings": [],
    }

    for dirpath, dirnames, filenames in os.walk(root_path):
        # Remove diretórios excluídos (modifica in-place pra afetar o walk)
        dirnames[:] = [
            d for d in dirnames
            if d not in excludes and not d.startswith(".")
            or d == ".env"  # .env deve ser escaneado
        ]

        for filename in filenames:
            filepath = os.path.join(dirpath, filename)

            # Filtro por extensão (a menos que scan_all)
            ext = os.path.splitext(filename)[1].lower()
            if not scan_all and ext not in SCANNABLE_EXTENSIONS:
                results["files_skipped"] += 1
                continue

            # Pula binários
            if is_binary_file(filepath):
                results["files_skipped"] += 1
                continue

            results["files_scanned"] += 1
            file_findings = scan_file(filepath)

            for finding in file_findings:
                results["findings"].append(finding)
                results["total_findings"] += 1
                results["by_confidence"][finding["confidence"]] += 1
                results["by_pattern"][finding["pattern"]] += 1

    return results


def print_report(results: dict, use_color: bool = True):
    """Imprime relatório formatado no terminal."""
    COLORS = {
        "high": "\033[91m" if use_color else "",    # Vermelho
        "medium": "\033[93m" if use_color else "",  # Amarelo
        "low": "\033[94m" if use_color else "",     # Azul
        "reset": "\033[0m" if use_color else "",
        "bold": "\033[1m" if use_color else "",
        "dim": "\033[2m" if use_color else "",
    }

    print(f"\n{COLORS['bold']}╔══════════════════════════════════════════╗{COLORS['reset']}")
    print(f"{COLORS['bold']}║     🔍 SECRET SCANNER — RELATÓRIO       ║{COLORS['reset']}")
    print(f"{COLORS['bold']}╚══════════════════════════════════════════╝{COLORS['reset']}\n")

    print(f"📁 Diretório: {results['root']}")
    print(f"📄 Arquivos escaneados: {results['files_scanned']}")
    print(f"⏭️  Arquivos pulados: {results['files_skipped']}")
    print(f"⏰ Data: {results['scan_time']}\n")

    total = results["total_findings"]
    if total == 0:
        print(f"✅ {COLORS['bold']}Nenhum secret encontrado!{COLORS['reset']}")
        print(f"   (Isso não garante segurança total — revise manualmente arquivos críticos)\n")
        return

    print(f"{COLORS['bold']}⚠️  {total} possível(is) secret(s) encontrado(s):{COLORS['reset']}")
    print(f"   🔴 Alta confiança: {results['by_confidence']['high']}")
    print(f"   🟡 Média confiança: {results['by_confidence']['medium']}")
    print(f"   🔵 Baixa confiança: {results['by_confidence']['low']}\n")

    # Top padrões encontrados
    if results["by_pattern"]:
        print(f"{COLORS['bold']}📊 Padrões mais encontrados:{COLORS['reset']}")
        for pattern, count in sorted(results["by_pattern"].items(), key=lambda x: -x[1])[:5]:
            print(f"   • {pattern}: {count}x")
        print()

    # Detalhes de cada finding
    print(f"{COLORS['bold']}{'─' * 60}{COLORS['reset']}\n")

    # Agrupa por arquivo
    by_file = defaultdict(list)
    for f in results["findings"]:
        by_file[f["file"]].append(f)

    for filepath, findings in sorted(by_file.items()):
        print(f"{COLORS['bold']}📄 {filepath}{COLORS['reset']}")
        for f in findings:
            color = COLORS.get(f["confidence"], "")
            print(f"   {color}[{f['confidence'].upper()}]{COLORS['reset']} "
                  f"Linha {f['line']}: {f['pattern']}")
            print(f"   {COLORS['dim']}Match: {f['match']}{COLORS['reset']}")
            print(f"   {COLORS['dim']}Entropia: {f['entropy']} | "
                  f"Contexto: {f['context'][:80]}{COLORS['reset']}")
            print()

    # Recomendações
    print(f"{COLORS['bold']}🛡️  RECOMENDAÇÕES:{COLORS['reset']}")
    print("   1. Revise cada finding acima manualmente")
    print("   2. Revogue imediatamente qualquer secret confirmado")
    print("   3. Mova secrets para variáveis de ambiente (.env) ou cofre (Vault, SOPS)")
    print("   4. Adicione este scanner ao seu CI/CD (pre-commit hook)")
    print("   5. Use git-filter-repo para limpar secrets do histórico do Git")
    print()


def main():
    parser = argparse.ArgumentParser(
        description="🔍 Secret Scanner — Encontre secrets vazados no seu código"
    )
    parser.add_argument("path", help="Diretório para escanear")
    parser.add_argument(
        "--exclude", default="",
        help="Diretórios extras para excluir (separados por vírgula)"
    )
    parser.add_argument("--json", action="store_true", help="Saída em JSON")
    parser.add_argument("--all", action="store_true", help="Escaneia todos os arquivos (ignora extensão)")
    parser.add_argument("--no-color", action="store_true", help="Desativa cores")

    args = parser.parse_args()

    if not os.path.isdir(args.path):
        print(f"❌ Diretório não encontrado: {args.path}", file=sys.stderr)
        sys.exit(1)

    excludes = set(args.exclude.split(",")) if args.exclude else set()
    results = scan_directory(args.path, excludes, scan_all=args.all)

    if args.json:
        print(json.dumps(results, indent=2, ensure_ascii=False))
    else:
        print_report(results, use_color=not args.no_color)

    # Exit code 1 se encontrou secrets de alta confiança
    if results["by_confidence"]["high"] > 0:
        sys.exit(1)


if __name__ == "__main__":
    main()

Como Funciona: Três Camadas de Detecção

O scanner opera em três camadas para equilibrar cobertura e precisão:

Camada 1 — Regex Específicos (Alta Confiança): Padrões únicos de serviços como AWS (AKIA...), GitHub (ghp_...), Stripe (sk_live_...). Estes são praticamente infalsos positivos — se matchou, é secret real.

Camada 2 — Regex Genéricos + Filtro de Entropia (Média Confiança): Padrões como password = "..." ou api_key = "...". Aqui entra a entropia de Shannon — strings com alta entropia (>4.5 bits por caractere) tendem a ser secrets gerados aleatoriamente. Strings como password = "admin123" têm entropia baixa e são filtradas.

Camada 3 — Detecção de Estruturas (Contexto): Connection strings de bancos de dados, blocos de chave privada, JWTs. Estes têm formato reconhecível mesmo sem saber o serviço específico.

Entropia de Shannon na Prática

A fórmula é simples: para cada caractere na string, calculamos a frequência e aplicamos -p * log2(p). Quanto mais “aleatória” a string, maior a entropia:

  • `”admin123″` → entropia ~2.5 (poucos caracteres, padrões óbvios)
  • `”sk_live_4eC39HqLyjWDarjtT1zdp7j3″` → entropia ~5.2 (alta aleatoriedade)
  • `”YOUR_API_KEY_HERE”` → entropia ~3.8 (mas é filtrado pelo check de placeholder)

Isso elimina uma quantidade enorme de falsos positivos que outros scanners simples geram.


Integração com Git Pre-Commit Hook

A melhor forma de usar o scanner é impedir que secrets entrem no Git em primeiro lugar. Adicione como pre-commit hook:

#!/bin/bash
# .git/hooks/pre-commit

# Escaneia apenas arquivos que serão commitados
CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)

if [ -z "$CHANGED_FILES" ]; then
    exit 0
fi

# Cria um diretório temporário com só os arquivos alterados
TEMP_DIR=$(mktemp -d)
for file in $CHANGED_FILES; do
    mkdir -p "$TEMP_DIR/$(dirname "$file")"
    git show ":$file" > "$TEMP_DIR/$file" 2>/dev/null
done

# Roda o scanner
python3 secret_scanner.py "$TEMP_DIR" --no-color
EXIT_CODE=$?

# Limpa
rm -rf "$TEMP_DIR"

if [ $EXIT_CODE -ne 0 ]; then
    echo ""
    echo "❌ COMMIT BLOQUEADO: possíveis secrets detectados!"
    echo "   Revise os findings acima e remova os secrets antes de commitar."
    echo "   Para forçar (se for falso positivo): git commit --no-verify"
    exit 1
fi

exit 0

Instale o hook:

chmod +x .git/hooks/pre-commit

Pronto. Agora cada commit passa pelo scanner automaticamente.


Box: O Perrengue do Olivetto 🫠

Já deixei uma chave da AWS exposta num repositório público por 47 dias. Quarenta e sete. O repositório era um projeto pessoal de automação — daqueles que você cria às 2 da manhã pra resolver um problema pontual e esquece.

A chave estava hardcoded num arquivo config.py. Não era um .env, não era variável de ambiente. Era string literal, commitada no primeiro commit, visível pra qualquer pessoa.

Descobri porque a conta da AWS veio com um custo de R$ 847,00 num mês. Alguém encontrou a chave, criou instâncias EC2 t3.xlarge, e estava minerando criptomoeda. Na minha conta.

O scanner que mostrei neste post? Ele encontra essa chave em 0.3 segundos. AKIA no início da string é um padrão que não tem como confundir. Mas eu não tinha esse scanner na época.

A lição (cara): todo projeto, por menor que seja, merece um scan de secrets antes do primeiro push. Hoje uso pre-commit hook em tudo, sem exceção.


Rodando na Prática

Clone seu projeto e rode:

# Scan básico
python3 secret_scanner.py ~/meu-projeto

# Excluir diretórios específicos
python3 secret_scanner.py . --exclude vendor,seeds,fixtures

# Saída JSON para integração com CI/CD
python3 secret_scanner.py . --json | python3 -m json.tool

# Scan agressivo (todos os arquivos, independente de extensão)
python3 secret_scanner.py . --all

O exit code do script é 1 quando encontra secrets de alta confiança — perfeito pra usar em pipelines de CI:

# .github/workflows/secret-scan.yml
name: Secret Scan
on: [push, pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Secret Scanner
        run: python3 secret_scanner.py . --no-color

Limitações (Porque Honestidade Importa)

O scanner é bom, mas não é perfeito:

  • **Não escaneia o histórico do Git.** Se um secret foi commitado e removido, ele ainda está no `.git/objects`. Para isso, use `git-filter-repo` ou TruffleHog.
  • **Falsos negativos existem.** Secrets em formatos incomuns ou obfuscados podem passar. Regex é heurística, não mágica.
  • **Falsos positivos acontecem.** Especialmente com JWTs e tokens genéricos em arquivos de teste. Sempre revise manualmente.
  • **Não substitui análise humana.** O scanner é uma rede de segurança, não um substituto para code review.

O Que Fazer Depois de Encontrar um Secret

Se o scanner encontrou algo real:

  • **Revogue imediatamente.** Entre no painel do serviço e regenere a chave.
  • **Limpe o histórico do Git** (se estiver lá):
# Use git-filter-repo (moderno, seguro)
git filter-repo --path arquivo-com-secret.py --invert-paths

# Ou BFG Repo-Cleaner (mais rápido para históricos grandes)
bfg --replace-text passwords.txt repo.git
  • **Rote para um cofre.** AWS Secrets Manager, HashiCorp Vault, ou simplesmente `.env` + `.gitignore`.
  • **Adicione o scanner ao CI** pra impedir que aconteça de novo.

E Agora?

Você tem um scanner funcional, pre-commit hook, e um plano de remediação. O que mais dá pra fazer?

  • **Extensão pra escanear Dockerfiles** (muitas secrets vazam em `ENV` directives)
  • **Integração com webhooks** pra alertar no Slack/Discord quando encontra algo
  • **Modo “diff”** que escaneia só o que mudou entre dois commits

Qual desses você quer ver no próximo post? Ou tem alguma ideia de segurança que quer ver transformada em Python puro?

Deixa nos comentários o que você quer ver o Olivetto construir depois. 🔧

Posts Similares