#!/usr/bin/env python3
"""Offline source-tree credential leak audit with redacted output."""

from __future__ import annotations

import argparse
import math
import re
import sys
from collections import Counter
from pathlib import Path

SKIP_DIRS = {
    ".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build",
    ".venv", "venv", "__pycache__",
}
MAX_BYTES = 2_000_000
RULES = [
    ("private-key", "critical", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")),
    ("aws-access-key", "high", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
    ("github-token", "high", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{30,255})\b")),
    ("stripe-live-key", "high", re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b")),
    ("slack-token", "high", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
    ("jwt", "medium", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b")),
    (
        "assigned-secret",
        "medium",
        re.compile(
            r"(?i)\b(?:api[_-]?key|secret|token|password|passwd)\b"
            r"\s*[:=]\s*['\"]([^'\"\s]{8,})['\"]"
        ),
    ),
]
ALLOW_MARKERS = ("example", "placeholder", "changeme", "your_", "dummy", "test")


def redact(value: str) -> str:
    if len(value) < 9:
        return "[REDACTED]"
    return value[:4] + "…" + value[-4:]


def entropy(value: str) -> float:
    if not value:
        return 0.0
    counts = Counter(value)
    return -sum((n / len(value)) * math.log2(n / len(value)) for n in counts.values())


def candidate_files(root: Path):
    for path in sorted(root.rglob("*")):
        if not path.is_file() or any(part in SKIP_DIRS for part in path.parts):
            continue
        try:
            if path.stat().st_size > MAX_BYTES:
                continue
            sample = path.read_bytes()
        except OSError:
            continue
        if b"\0" in sample[:4096]:
            continue
        yield path, sample.decode("utf-8", errors="replace")


def scan(root: Path):
    findings = []
    for path, text in candidate_files(root):
        rel = path.relative_to(root)
        for line_no, line in enumerate(text.splitlines(), 1):
            lowered = line.lower()
            for rule, severity, pattern in RULES:
                for match in pattern.finditer(line):
                    value = match.group(1) if match.lastindex else match.group(0)
                    if any(marker in value.lower() for marker in ALLOW_MARKERS):
                        continue
                    if rule == "assigned-secret" and entropy(value) < 2.5:
                        continue
                    findings.append((severity, rule, str(rel), line_no, redact(value)))
    return findings


def report(findings, root: Path) -> str:
    rank = {"critical": 0, "high": 1, "medium": 2}
    findings = sorted(findings, key=lambda item: (rank[item[0]], item[2], item[3]))
    counts = Counter(item[0] for item in findings)
    lines = [
        "# Credential Leak Audit",
        "",
        f"Scanned root: `{root.resolve()}`",
        f"Findings: **{len(findings)}** "
        f"(critical {counts['critical']}, high {counts['high']}, medium {counts['medium']})",
        "",
        "Secrets are redacted. Rotate and revoke confirmed credentials; do not merely delete them.",
        "",
        "| Severity | Rule | Location | Redacted match |",
        "|---|---|---|---|",
    ]
    for severity, rule, path, line, value in findings:
        safe_path = path.replace("|", "\\|")
        lines.append(f"| {severity} | {rule} | `{safe_path}:{line}` | `{value}` |")
    if not findings:
        lines.append("| — | — | No matches found | — |")
    lines.extend([
        "",
        "_Heuristic scan only: a clean report is not proof that no credential exists._",
    ])
    return "\n".join(lines) + "\n"


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("root", nargs="?", default=".", type=Path)
    parser.add_argument("-o", "--output", type=Path)
    parser.add_argument("--fail-on-findings", action="store_true")
    args = parser.parse_args()
    if not args.root.is_dir():
        parser.error(f"not a directory: {args.root}")
    findings = scan(args.root)
    rendered = report(findings, args.root)
    if args.output:
        args.output.write_text(rendered, encoding="utf-8")
    else:
        sys.stdout.write(rendered)
    return 2 if findings and args.fail_on_findings else 0


if __name__ == "__main__":
    raise SystemExit(main())
