secrets_scan.py

script

← Back to skill

Content hash: 41fbdaf67daab4b41cb2f0e3ceb76f49eb0379b9c7b0bb2c06c917c6df639528
#!/usr/bin/env python3
"""Secrets hygiene scanner — a fast first-pass check for common leaks.

Scans a repo for: hardcoded credentials, weak hashes, direct secret comparisons,
tracked .env files, and missing .gitignore entries. Not a replacement for
gitleaks/bandit, but catches the classics before they ship.

Usage: python secrets_scan.py [repo_root]
"""
from __future__ import annotations

import os
import re
import sys
from pathlib import Path

# Patterns that look like secrets (heuristic, not exhaustive)
SECRET_PATTERNS = [
    (r'(?:api[_-]?key|apikey|secret|token|password|passwd)\s*[:=]\s*[\'"]?[\w\-.]{16,}[\'"]?',
     "suspected hardcoded credential"),
    (r'sk-[a-zA-Z0-9]{32,}', "OpenAI-style API key"),
    (r'ghp_[a-zA-Z0-9]{36}', "GitHub personal access token"),
    (r'AKIA[0-9A-Z]{16}', "AWS access key ID"),
    (r'eyJ[a-zA-Z0-9\-_]+\.eyJ[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+', "JWT token"),
    (r'-----BEGIN (?:RSA|EC|OPENSSH|DSA) PRIVATE KEY-----', "private key"),
]

WEAK_HASH_PATTERNS = [
    (r'hashlib\.(md5|sha1)\(', "weak hash (md5/sha1)"),
    (r'(?:==|!=)\s*(?:password|passwd|secret|token)', "direct comparison of secret"),
]


def check_gitignore(repo_root: Path) -> list[str]:
    issues = []
    gi = repo_root / ".gitignore"
    if not gi.exists():
        issues.append("ERROR: No .gitignore found")
        return issues
    content = gi.read_text()
    for entry in [".env", "*.pem", "*.key", "*.keystore", "credentials.json"]:
        if entry not in content:
            issues.append(f"WARNING: .gitignore missing '{entry}'")
    return issues


def scan_file(path: Path) -> list[str]:
    issues = []
    try:
        text = path.read_text(errors="ignore")
    except Exception:
        return issues

    for pattern, desc in SECRET_PATTERNS:
        for m in re.finditer(pattern, text, re.IGNORECASE):
            line_no = text[: m.start()].count("\n") + 1
            snippet = m.group()[:60]
            issues.append(f"  {path}:{line_no} [{desc}] {snippet}")

    for pattern, desc in WEAK_HASH_PATTERNS:
        for m in re.finditer(pattern, text, re.IGNORECASE):
            line_no = text[: m.start()].count("\n") + 1
            issues.append(f"  {path}:{line_no} [{desc}]")

    return issues


def main() -> None:
    repo_root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
    print(f"Scanning: {repo_root}\n")

    all_issues: list[str] = []

    # 1. .gitignore check
    all_issues.extend(check_gitignore(repo_root))

    # 2. Check if .env is tracked
    result = os.popen(f"cd {repo_root} && git ls-files --error-unmatch .env 2>&1").read()
    if "error" not in result:
        all_issues.append("CRITICAL: .env is tracked by git!")

    # 3. Scan source files
    scan_exts = {".py", ".js", ".ts", ".sh", ".yaml", ".yml", ".toml", ".json", ".env"}
    for path in repo_root.rglob("*"):
        if path.suffix in scan_exts and ".git" not in path.parts:
            if path.name in (".env", "credentials.json"):
                all_issues.append(f"CRITICAL: Sensitive file in repo: {path}")
            all_issues.extend(scan_file(path))

    if all_issues:
        print(f"Found {len(all_issues)} issue(s):\n")
        for issue in all_issues:
            print(issue)
        sys.exit(1)
    else:
        print("No issues found.")


if __name__ == "__main__":
    main()