#!/usr/bin/env python3 """ Security pre-commit checker. Runs inside a Docker container — do not execute directly on the host. Checks: 1. Hardcoded secrets / credentials in staged files 2. Dangerous patterns (eval, exec, shell=True, pickle) 3. Weak cryptography (MD5, SHA1 for passwords, DES) 4. SQL injection risk (f-strings / .format() / % in execute/query/text()) 5. Missing input sanitization (raw request attributes passed to DB) 6. Debug/development flags left in code 7. bandit static analysis on Python files """ import os import re import subprocess import sys from pathlib import Path # ── Patterns ───────────────────────────────────────────────────────────────── SECRET_PATTERNS = [ # Only match lowercase/camelCase variable names — excludes ALL_CAPS test constants (r'(?i)(? list[Path]: """Read staged files from STAGED_FILES env var (set by the pre-commit hook).""" raw = os.environ.get("STAGED_FILES", "") files = [] for line in raw.splitlines(): line = line.strip() if not line: continue p = Path(line) if p.suffix in SKIP_EXTENSIONS: continue if any(part in SKIP_DIRS for part in p.parts): continue if p.name in SKIP_FILES: continue if p.exists(): files.append(p) return files def scan_file(path: Path) -> list[tuple[int, str, str]]: findings = [] try: content = path.read_text(errors="ignore") except Exception: return findings for line_no, line in enumerate(content.splitlines(), start=1): for category, pattern, message in ALL_PATTERNS: if re.search(pattern, line): # Skip lines that are clearly comments explaining the pattern stripped = line.strip() if stripped.startswith("#") or stripped.startswith("//"): continue findings.append((line_no, category, message)) return findings def run_bandit(py_files: list[Path]) -> tuple[bool, str]: if not py_files: return True, "" result = subprocess.run( ["python", "-m", "bandit", "-q", "-ll", "--", *[str(f) for f in py_files]], capture_output=True, text=True ) passed = result.returncode == 0 return passed, result.stdout + result.stderr # ── Main ────────────────────────────────────────────────────────────────────── def main() -> int: staged = get_staged_files() if not staged: print("[security] no staged files to check") return 0 print(f"[security] scanning {len(staged)} staged file(s)...") violations = 0 for path in staged: findings = scan_file(path) for line_no, category, message in findings: print(f" [{category}] {path}:{line_no} — {message}") violations += 1 py_files = [f for f in staged if f.suffix == ".py"] bandit_ok, bandit_out = run_bandit(py_files) if not bandit_ok: print("\n[security] bandit found issues:") print(bandit_out) violations += 1 if violations: print(f"\n[security] BLOCKED — {violations} issue(s) found. Fix them or use git commit --no-verify to override.") return 1 print("[security] all checks passed.") return 0 if __name__ == "__main__": sys.exit(main())