#!/usr/bin/env python3
"""repo_prescan_bundle.py — run every deterministic pre-scan over a repo
and merge the findings into ONE severity-tiered markdown report.

v23 — wraps ALL 42 sibling repo pre-scan tools (each individually tested):

  tool                    covers
  ---------------------   ---------------------------------------------
  secret_prescan.py       committed keys/seed phrases/provider tokens
  dep_prescan.py          manifest typosquats, unpinned/VCS deps, scripts
  ci_prescan.py           workflow injection, pwn-request, permissions
  docker_prescan.py       Dockerfile/compose misconfig
  crypto_prescan.py       weak ciphers, non-CSPRNG secrets, TLS-off, JWT
  iac_prescan.py          Terraform + Kubernetes exposure/secrets
  auth_prescan.py         JWT/session/cookie/auth-disable misconfig
  cors_prescan.py         reflected origin+credentials, bad allowlists
  csrf_prescan.py         explicit opt-outs, mutating GET, SameSite=None
  sqli_prescan.py         interpolated SQL into execute/query sinks
  nosql_prescan.py        operator injection, mass-assignment, $where
  xss_prescan.py          raw-HTML DOM sinks, string-eval, raw templates
  cmdi_prescan.py         request->exec sinks, shell wrappers, pipe-to-sh
  path_prescan.py         traversal, Zip Slip, PHP LFI, upload paths
  ssti_prescan.py         request->template source/name args
  redirect_prescan.py     open redirect, header injection, Host-trust
  xxe_prescan.py          unsafe XML parsers, external entities
  deser_prescan.py        pickle/yaml.load/ObjectInputStream family
  upload_prescan.py       upload validation gaps, exec-ext allow lists
  injection_prescan.py    prompt-injection in agent instruction files
  redos_prescan.py        catastrophic-backtracking regex shapes
  authz_prescan.py        broken access control / BOLA signatures
  jwt_prescan.py          JWT verify-off, alg confusion, weak keys, kid
  oauth_prescan.py        OAuth flow: state, redirect_uri, PKCE, implicit
  headers_prescan.py      response-hardening opt-outs: frame, CSP, HSTS,
                          header strip, powered-by, perm-policy, cache
  debug_prescan.py        debug-mode/info-disclosure: debug flags, stack/
                          env to client, consoles, pprof, actuator
  clarity_prescan.py      Clarity/Stacks contract signatures
  solidity_prescan.py     Solidity/EVM contract signatures
  llm_prescan.py          LLM app integration: output->exec/SQL/HTTP/
                          HTML sinks, prompt concat, secrets in context,
                          role-from-req, unbounded agent loops, dispatch
  graphql_prescan.py      GraphQL API: introspection/console/batching
                          on, resolver input->exec/SQL/HTTP, mutation
                          auth gaps, missing depth/complexity limits
  ws_prescan.py           WebSocket/realtime: unchecked origins, CSWSH
                          bypasses, message->exec/SQL/fetch, reflects,
                          no-auth/no-limit acceptors, plaintext ws://
  session_prescan.py      session/cookie: HttpOnly/Secure/SameSite off,
                          fixation, trans-sid, tokens in storage/URL,
                          long lifetimes, literal secrets, wide domains
  tls_prescan.py          transport security: cert/hostname verify off,
                          weak versions+ciphers, plaintext endpoints,
                          db TLS off, PM bypass, IaC skip-verify, legacy
                          wrap contexts
  timing_prescan.py       timing-attack shapes: early-exit ==/.equals/
                          strcmp compares on secret-named operands,
                          non-CT library compares, prefix/membership
                          probes, hand-rolled comparator loops,
                          length oracles, raw token DB lookups
  memsafe_prescan.py      C/C++/ObjC memory safety: gets/scanf-nowidth/
                          unbounded strcpy-sprintf, literal overruns,
                          non-literal formats, strncpy truncation,
                          stack returns, unchecked alloc, double free,
                          mktemp, alloca, realloc-self, banned calls
  log_prescan.py          sensitive data into logs: secrets/creds/PII/
                          env/req-object/body operands into logger +
                          stdout sinks, presence-check + metadata-suffix
                          suppression (CWE-532/117)
  proto_prescan.py        JS/TS prototype pollution: __proto__ writes,
                          attacker paths in deep-setters, request-fed
                          merges, unguarded deep merges + path setters,
                          merge lib sinks, assign/spread of request
                          sources (CWE-1321/915)
  race_prescan.py         TOCTOU / check-then-act races: exists->open,
                          symlink swap, lockfile acquisition, dir-create,
                          non-atomic DB uniqueness checks (CWE-367)
  webconf_prescan.py      nginx + Apache config: alias-slash traversal,
                          satisfy-any bypass, open proxy, unguarded
                          DAV/status/CGI/indexes, weak TLS directives,
                          header smuggle/spoof, wildcard CORS, htaccess
                          PHP binds (CWE-16)
  mcp_prescan.py          MCP configs + tool registration: poisoned
                          descriptions, secret passthrough, unpinned
                          remote packages, auto-approve, scope, binds
  ssrfsink_prescan.py     SSRF dataflow: request-taint into HTTP/scheme-
                          capable fetch sinks, weak substring guards,
                          redirect-follow, DNS-rebinding, decode, url-param
  reslimit_prescan.py     unbounded resources (CWE-770/400): request-
                          derived size into alloc/read/loop/page/sleep
                          sinks across 11 languages; VAR-at-alloc tier;
                          bound-guard + literal-reassign suppression
  md_prescan.py           *.html inline-pass injection (opt-in via --html)

Not wrapped (different target type): ssrf_prescan.py scans a live URL,
the token-security tools scan live chains — see the single-tool listings.

Usage:
  python3 repo_prescan_bundle.py <repo-dir> [-o report.md] [--json]
                                  [--tools-dir DIR] [--html]
                                  [--tools name,name,...]
Exit 0 = no HIGH/MEDIUM findings, 1 = findings, 2 = usage error.
Stdlib only. Each wrapped tool keeps its own exit contract; a tool that
errors is reported as TOOL-ERROR in its section, never silently dropped.
"""
import json
import os
import re
import subprocess
import sys

SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}

# (name, script, json_mode)
TOOLS = [
    ("secret-prescan",     "secret_prescan.py",     False),
    ("dep-prescan",        "dep_prescan.py",        True),
    ("ci-prescan",         "ci_prescan.py",         True),
    ("docker-prescan",     "docker_prescan.py",     True),
    ("crypto-prescan",     "crypto_prescan.py",     True),
    ("iac-prescan",        "iac_prescan.py",        True),
    ("auth-prescan",       "auth_prescan.py",       True),
    ("cors-prescan",       "cors_prescan.py",       True),
    ("csrf-prescan",       "csrf_prescan.py",       True),
    ("sqli-prescan",       "sqli_prescan.py",       True),
    ("nosql-prescan",      "nosql_prescan.py",      True),
    ("xss-prescan",        "xss_prescan.py",        True),
    ("cmdi-prescan",       "cmdi_prescan.py",       True),
    ("path-prescan",       "path_prescan.py",       True),
    ("ssti-prescan",       "ssti_prescan.py",       True),
    ("redirect-prescan",   "redirect_prescan.py",   True),
    ("xxe-prescan",        "xxe_prescan.py",        True),
    ("deser-prescan",      "deser_prescan.py",      True),
    ("upload-prescan",     "upload_prescan.py",     True),
    ("injection-prescan",  "injection_prescan.py",  True),
    ("redos-prescan",      "redos_prescan.py",      True),
    ("authz-prescan",      "authz_prescan.py",      True),
    ("jwt-prescan",        "jwt_prescan.py",        True),
    ("oauth-prescan",      "oauth_prescan.py",      True),
    ("headers-prescan",    "headers_prescan.py",    True),
    ("debug-prescan",      "debug_prescan.py",      True),
    ("clarity-prescan",    "clarity_prescan.py",    True),
    ("solidity-prescan",   "solidity_prescan.py",   True),
    ("llm-prescan",        "llm_prescan.py",        True),
    ("graphql-prescan",    "graphql_prescan.py",    True),
    ("ws-prescan",         "ws_prescan.py",         True),
    ("session-prescan",    "session_prescan.py",    True),
    ("tls-prescan",        "tls_prescan.py",        True),
    ("timing-prescan",     "timing_prescan.py",     True),
    ("memsafe-prescan",    "memsafe_prescan.py",    True),
    ("log-prescan",        "log_prescan.py",        True),
    ("proto-prescan",      "proto_prescan.py",      True),
    ("race-prescan",       "race_prescan.py",       True),
    ("webconf-prescan",    "webconf_prescan.py",    True),
    ("mcp-prescan",        "mcp_prescan.py",        True),
    ("ssrfsink-prescan",   "ssrfsink_prescan.py",   True),
    ("reslimit-prescan",   "reslimit_prescan.py",   True),
]

HERE = os.path.dirname(os.path.abspath(__file__))


def default_tools_dir():
    # bundle lives in deliverables/repo-prescan-bundle/, tools in siblings
    return os.path.normpath(os.path.join(HERE, ".."))


def find_script(tools_dir, script):
    # allow <tools_dir>/<name>-prescan/<script> or <tools_dir>/<script>
    for root, _dirs, files in os.walk(tools_dir):
        if script in files:
            return os.path.join(root, script)
    return None


def norm_findings(name, payload):
    """Normalize heterogeneous tool JSON into {severity,code,where,msg}."""
    out = []
    if isinstance(payload, dict):
        items = payload.get("findings", [])
        scanned = payload.get("scanned") or payload.get("files") or payload.get("files_scanned")
    else:
        items, scanned = payload, None
    for it in items or []:
        if not isinstance(it, dict):
            continue
        where = it.get("file") or it.get("where") or ""
        if it.get("line"):
            where = "%s:%s" % (where, it["line"])
        out.append({
            "severity": str(it.get("severity", "INFO")).upper(),
            "code": it.get("code") or it.get("rule") or "?",
            "where": where,
            "msg": (it.get("msg") or it.get("detail") or it.get("note")
                    or it.get("text") or ""),
        })
    return out, scanned


def secret_md_counts(text):
    m = re.search(r"(\d+)\s+HIGH\s*/\s*(\d+)\s+MEDIUM", text)
    counts = {"HIGH": 0, "MEDIUM": 0}
    if m:
        counts["HIGH"], counts["MEDIUM"] = int(m.group(1)), int(m.group(2))
    lows = len(re.findall(r"^- \*\*LOW\*\*", text, re.M))
    if lows:
        counts["LOW"] = lows
    return counts


def run_tool(name, script_path, target, html_files=None):
    """Returns dict: {name, ok, findings, scanned, raw, error}."""
    res = {"name": name, "ok": False, "findings": [], "scanned": None,
           "raw": "", "error": None}
    json_mode = {n: j for n, _s, j in TOOLS}.get(name, True) if name != "md-prescan" else False
    cmd = [sys.executable, script_path]
    if name == "md-prescan":
        cmd += html_files or []
    else:
        cmd.append(target)
        if json_mode:
            cmd.append("--json")
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
    except Exception as e:
        res["error"] = "spawn failed: %s" % e
        return res
    res["raw"] = p.stdout
    if p.returncode == 2 or (p.returncode != 0 and not p.stdout.strip()):
        res["error"] = (p.stderr or p.stdout or "exit %d" % p.returncode).strip()[:400]
        return res
    res["ok"] = True
    if name == "secret-prescan":
        counts = secret_md_counts(p.stdout)
        for m in re.finditer(
                r"- \*\*(HIGH|MEDIUM|LOW)\*\* `([^`]+)` — (.+?) — match `([^`]*)`",
                p.stdout):
            sev, code, where, matchstr = m.groups()
            res["findings"].append({
                "severity": sev, "code": code, "where": where.strip(),
                "msg": "match `%s`" % matchstr})
        if not res["findings"] and any(counts.values()):
            res["findings"].append({"severity": "INFO", "code": "see-report",
                                    "where": "", "msg": "counts parsed but rows not"})
    elif name == "md-prescan":
        for m in re.finditer(r"(HIGH|MEDIUM|LOW)\b.*", p.stdout):
            res["findings"].append({"severity": m.group(1), "code": "md-inject",
                                    "where": "", "msg": m.group(0)[:200]})
        if not res["findings"] and p.returncode == 1:
            res["findings"].append({"severity": "INFO", "code": "findings",
                                    "where": "", "msg": "tool flagged; see raw"})
    else:
        try:
            res["findings"], res["scanned"] = norm_findings(name, json.loads(p.stdout))
        except Exception as e:
            res["error"] = "json parse failed: %s" % e
            res["ok"] = False
    return res


def emit_md(target, results, generated):
    lines = ["# Repo pre-scan bundle report", "",
             "Target: `%s` · generated %s · deterministic line/manifest scans only" %
             (target, generated), "",
             "**This is a pre-scan, not an audit.** It catches known-bad "
             "patterns cheaply; it cannot prove absence of vulnerabilities.", "",
             "| tool | HIGH | MED | LOW | status |", "|---|---|---|---|---|"]
    totals = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
    for r in results:
        c = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
        for f in r["findings"]:
            if f["severity"] in c:
                c[f["severity"]] += 1
                totals[f["severity"]] += 1
        status = "TOOL-ERROR: %s" % r["error"] if r["error"] else "ok"
        lines.append("| %s | %d | %d | %d | %s |" %
                     (r["name"], c["HIGH"], c["MEDIUM"], c["LOW"], status))
    lines += ["", "**Totals: %d HIGH / %d MEDIUM / %d LOW**" %
              (totals["HIGH"], totals["MEDIUM"], totals["LOW"]), ""]
    for r in results:
        lines.append("## %s" % r["name"])
        if r["error"]:
            lines.append("Tool error: `%s`" % r["error"])
        elif not r["findings"]:
            lines.append("No findings.")
        else:
            for f in sorted(r["findings"],
                            key=lambda x: SEV_ORDER.get(x["severity"], 9)):
                where = " `%s`" % f["where"] if f["where"] else ""
                msg = " — %s" % f["msg"] if f["msg"] else ""
                lines.append("- **%s** `%s`%s%s" %
                             (f["severity"], f["code"], where, msg))
        lines.append("")
    return "\n".join(lines)


def main(argv):
    args = [a for a in argv if not a.startswith("-")]
    if not args:
        print(__doc__)
        return 2
    target = os.path.abspath(args[0])
    if not os.path.isdir(target):
        print("target not a directory: %s" % target)
        return 2
    tools_dir = default_tools_dir()
    out_path, want_json, want_html = None, "--json" in argv, "--html" in argv
    only = None
    i = 0
    while i < len(argv):
        a = argv[i]
        if a == "-o" and i + 1 < len(argv):
            out_path = argv[i + 1]
        elif a == "--tools-dir" and i + 1 < len(argv):
            tools_dir = argv[i + 1]
        elif a == "--tools" and i + 1 < len(argv):
            only = set(argv[i + 1].split(","))
        i += 1
    selected = [(n, s, j) for n, s, j in TOOLS if only is None or n in only]
    if want_html:
        selected.append(("md-prescan", "md_prescan.py", False))

    html_files = []
    if want_html:
        for root, dirs, files in os.walk(target):
            dirs[:] = [d for d in dirs if d not in (".git", "node_modules")]
            for f in files:
                if f.endswith(".html"):
                    html_files.append(os.path.join(root, f))

    results = []
    for name, script, _jm in selected:
        path = find_script(tools_dir, script)
        if not path:
            results.append({"name": name, "ok": False, "findings": [],
                            "scanned": None, "raw": "",
                            "error": "script not found under %s" % tools_dir})
            continue
        results.append(run_tool(name, path, target, html_files))

    import datetime
    generated = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
    if want_json:
        doc = {"target": target, "generated": generated,
               "scope": "deterministic pre-scan bundle, not an audit",
               "tools": [{"name": r["name"], "ok": r["ok"], "error": r["error"],
                          "findings": r["findings"]} for r in results]}
        text = json.dumps(doc, indent=2)
    else:
        text = emit_md(target, results, generated)
    if out_path:
        with open(out_path, "w") as fh:
            fh.write(text)
    else:
        print(text)
    bad = any(f["severity"] in ("HIGH", "MEDIUM", "CRITICAL")
              for r in results for f in r["findings"])
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
