#!/usr/bin/env python3
"""secrets-audit сканер: ищет утёкшие секреты и слабости конфига в проекте.

Детерминированно, без сети, без LLM. Даёт список кандидатов file:line + тип +
severity — дальше их adversarial-проверяет агент (ложные срабатывания неизбежны).

Использование:
  scan.py [PROJECT_DIR]            весь трекнутый гитом код (или всё дерево вне git)
  scan.py [PROJECT_DIR] --diff     только незакоммиченный дифф (staged+unstaged)
  scan.py [PROJECT_DIR] --staged   только staged (для pre-commit хука)
  scan.py [PROJECT_DIR] --json     машинный вывод

Выход: 0 — чисто; 1 — есть HIGH-находки; 2 — только MEDIUM/LOW.
"""
import json
import math
import os
import re
import subprocess
import sys

# --- Паттерны известных секретов. (name, severity, compiled_regex) -----------
# HIGH — почти всегда настоящий секрет; MEDIUM — вероятный; LOW — только с энтропией.
_PATTERNS = [
    ("aws-access-key-id", "HIGH", r"\b(?:AKIA|ASIA|AGPA|AIDA|AROA)[0-9A-Z]{16}\b"),
    ("aws-secret-key", "HIGH", r"(?i)aws.{0,20}?(?:secret|key).{0,4}[=:]\s*['\"]?([A-Za-z0-9/+]{40})['\"]?"),
    ("private-key-block", "HIGH", r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"),
    ("openai-key", "HIGH", r"\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}\b"),
    ("anthropic-key", "HIGH", r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"),
    ("github-token", "HIGH", r"\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b"),
    ("gitlab-token", "HIGH", r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
    ("slack-token", "HIGH", r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
    ("google-api-key", "HIGH", r"\bAIza[0-9A-Za-z_-]{35}\b"),
    ("telegram-bot-token", "HIGH", r"\b\d{8,10}:AA[A-Za-z0-9_-]{32,35}\b"),
    ("stripe-key", "HIGH", r"\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}\b"),
    ("robokassa-pass", "MEDIUM", r"(?i)(?:mrh_pass|robokassa[_-]?pass(?:word)?[12]?)\s*[=:]\s*['\"]?([^\s'\"]{6,})"),
    ("jwt", "MEDIUM", r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
    ("slack-webhook", "HIGH", r"https://hooks\.slack\.com/services/[A-Za-z0-9/]{20,}"),
    ("generic-secret-assign", "MEDIUM",
     r"(?i)(?:api[_-]?key|secret|token|passwd|password|access[_-]?token|client[_-]?secret|bot[_-]?token)"
     r"\s*[=:]\s*['\"]([^\s'\"$#{}<>]{12,})['\"]"),
]
_COMPILED = [(n, s, re.compile(r)) for n, s, r in _PATTERNS]

# Значения-заглушки, которые НЕ секрет (снижают шум generic-паттерна).
_PLACEHOLDER = re.compile(
    r"(?i)^(?:your[_-]?|my[_-]?|example|changeme|placeholder|xxx+|test|dummy|"
    r"none|null|true|false|localhost|sk-\.\.\.|<.*>|\$\{.*\}|%\(.*\)|process\.env)")

_SKIP_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__", "dist",
              "build", ".next", "vendor", ".mypy_cache", ".pytest_cache",
              "site-packages", ".terraform", "coverage", ".idea"}
_SKIP_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip",
             ".gz", ".tar", ".bin", ".so", ".dylib", ".woff", ".woff2", ".ttf",
             ".lock", ".map", ".min.js", ".mp4", ".mp3", ".svg"}
_MAX_BYTES = 2_000_000  # не читаем файлы крупнее


def _entropy(s):
    if not s:
        return 0.0
    counts = {c: s.count(c) for c in set(s)}
    n = len(s)
    return -sum((c / n) * math.log2(c / n) for c in counts.values())


def _git(args, cwd):
    try:
        out = subprocess.run(["git", *args], cwd=cwd, capture_output=True,
                             text=True, timeout=30)
        return out.stdout if out.returncode == 0 else None
    except Exception:
        return None


def _is_git(cwd):
    return _git(["rev-parse", "--is-inside-work-tree"], cwd) is not None


def _tracked_files(cwd):
    out = _git(["ls-files"], cwd)
    return [l for l in out.splitlines() if l] if out else None


def _walk_files(cwd):
    res = []
    for root, dirs, files in os.walk(cwd):
        dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
        for f in files:
            res.append(os.path.relpath(os.path.join(root, f), cwd))
    return res


def _diff_lines(cwd, staged):
    """Возвращает {relpath: set(добавленных номеров строк)} из диффа."""
    args = ["diff", "--unified=0", "--no-color"]
    if staged:
        args.append("--cached")
    out = _git(args, cwd)
    if not out:
        return {}
    result, cur, newln = {}, None, 0
    for line in out.splitlines():
        if line.startswith("+++ b/"):
            cur = line[6:]
            result.setdefault(cur, set())
        elif line.startswith("@@"):
            m = re.search(r"\+(\d+)", line)
            newln = int(m.group(1)) if m else 0
        elif line.startswith("+") and not line.startswith("+++") and cur:
            result[cur].add(newln)
            newln += 1
    return result


def _scan_line(text):
    hits = []
    for name, sev, rx in _COMPILED:
        for m in rx.finditer(text):
            val = m.group(m.lastindex) if m.lastindex else m.group(0)
            if _PLACEHOLDER.match(val):
                continue
            # generic-паттерн требует хоть какой-то энтропии, чтобы не ловить слова
            if name == "generic-secret-assign" and _entropy(val) < 3.0:
                continue
            hits.append({"type": name, "severity": sev,
                         "match": val[:12] + "…" if len(val) > 12 else val})
    return hits


def _skip_file(rel):
    low = rel.lower()
    if any(low.endswith(e) for e in _SKIP_EXT):
        return True
    return any(p in _SKIP_DIRS for p in rel.split(os.sep))


def scan(cwd, mode):
    findings = []
    diff_map = None
    if mode in ("diff", "staged"):
        diff_map = _diff_lines(cwd, staged=(mode == "staged"))
        targets = list(diff_map.keys())
    elif _is_git(cwd):
        targets = _tracked_files(cwd) or []
    else:
        targets = _walk_files(cwd)

    for rel in targets:
        if _skip_file(rel):
            continue
        path = os.path.join(cwd, rel)
        try:
            if os.path.getsize(path) > _MAX_BYTES:
                continue
            with open(path, "r", encoding="utf-8", errors="ignore") as fh:
                lines = fh.readlines()
        except (OSError, IsADirectoryError):
            continue
        allow = diff_map.get(rel) if diff_map is not None else None
        for i, text in enumerate(lines, 1):
            if allow is not None and i not in allow:
                continue
            for h in _scan_line(text):
                h.update(file=rel, line=i)
                findings.append(h)
    return findings


def config_checks(cwd):
    """Слабости конфига: .env в трекинге, .env без .gitignore."""
    notes = []
    if not _is_git(cwd):
        return notes
    tracked = _tracked_files(cwd) or []
    for t in tracked:
        base = os.path.basename(t)
        if base == ".env" or (base.startswith(".env") and not base.endswith((".example", ".sample", ".template", ".dist"))):
            notes.append({"severity": "HIGH", "type": "env-file-tracked",
                          "file": t, "line": 0,
                          "match": "секрет-файл закоммичен в git"})
    gi = os.path.join(cwd, ".gitignore")
    gi_txt = open(gi, encoding="utf-8", errors="ignore").read() if os.path.exists(gi) else ""
    env_here = any(os.path.basename(t).startswith(".env") for t in _walk_files(cwd))
    if env_here and ".env" not in gi_txt:
        notes.append({"severity": "MEDIUM", "type": "env-not-ignored",
                      "file": ".gitignore", "line": 0,
                      "match": ".env не покрыт .gitignore"})
    return notes


def main():
    argv = [a for a in sys.argv[1:]]
    as_json = "--json" in argv
    mode = "staged" if "--staged" in argv else "diff" if "--diff" in argv else "full"
    pos = [a for a in argv if not a.startswith("--")]
    cwd = os.path.abspath(pos[0]) if pos else os.getcwd()

    findings = scan(cwd, mode) + config_checks(cwd)
    rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
    findings.sort(key=lambda f: (rank.get(f["severity"], 3), f["file"], f["line"]))

    if as_json:
        print(json.dumps({"dir": cwd, "mode": mode, "findings": findings},
                         ensure_ascii=False, indent=2))
    else:
        print(f"secrets-audit: {cwd}  [{mode}]")
        if not findings:
            print("  ✓ кандидатов не найдено")
        for f in findings:
            loc = f["file"] + (f":{f['line']}" if f["line"] else "")
            print(f"  [{f['severity']:6}] {f['type']:24} {loc}  → {f['match']}")
        highs = sum(1 for f in findings if f["severity"] == "HIGH")
        print(f"\n  итого: {len(findings)} кандидатов ({highs} HIGH). "
              f"Проверь adversarial-агентом — ложные срабатывания возможны.")

    high = any(f["severity"] == "HIGH" for f in findings)
    return 1 if high else (2 if findings else 0)


if __name__ == "__main__":
    sys.exit(main())
