#!/usr/bin/env python3
"""debug_prescan.py — deterministic debug-mode / information-disclosure
pre-scan.

ARION service artifact. Scans source + config files for the line shapes
where a deployment's debug machinery is left on or wired to the client:
framework debug flags (Flask/Django/Rails/ASP.NET/Spring/Laravel/WP),
exception stack traces and environment dumps sent into HTTP responses,
interactive debug consoles and profiler endpoints (Werkzeug
DebuggedApplication, django-debug-toolbar, phpinfo, net/http/pprof,
Spring actuator exposure, BetterErrors/web-console/Whoops/Ignition),
PHP display_errors / verbose error-page config, request-controlled
debug switches (?debug= gating output), stale breakpoints
(pdb/binding.pry/debugger;) and dev dump calls left in PHP app code.
Line-wise, fully offline, no inference.

Findings (base severity):
  DBG-DEBUG-ON       HIGH    debug mode explicitly enabled at the app
                             entrypoint or in a settings/prod config —
                             app.run(debug=True), app.debug = True,
                             DEBUG=True / APP_DEBUG=true in
                             settings/config/env files, Rails
                             consider_all_requests_local=true /
                             show_exceptions=false, ASP.NET
                             <compilation debug="true">,
                             WP_DEBUG_DISPLAY — full error pages,
                             reloaders, and in some frameworks an
                             interactive console ship to users
                     MEDIUM  a DEBUG=true-style assignment in ordinary
                             code, WP_DEBUG, APP_DEBUG in a generic
                             .env — probably dev intent, review where
                             it lands in prod
  DBG-STACK-TO-CLIENT HIGH   exception internals written into the HTTP
                             response — .stack / getTraceAsString /
                             traceback.format_exc / printStackTrace /
                             stackTraceToString inside send/json/end/
                             write/render/HttpResponse/echo, or a
                             return of the formatted traceback — the
                             client receives file paths, queries, and
                             framework internals
  DBG-ENV-DUMP       HIGH    environment structures sent to the client
                             — process.env / os.environ / $_SERVER /
                             $_ENV / ENV.to_h / System.getenv inside a
                             response sink; env usually carries
                             credentials
  DBG-DEV-CONSOLE    HIGH    interactive debug console or full
                             environment disclosure reachable —
                             DebuggedApplication, use_debugger=True,
                             phpinfo()
                     MEDIUM  toolbar/console/profiler wiring —
                             django-debug-toolbar /
                             DebugToolbarExtension / flask_debugtoolbar,
                             BetterErrors, web-console, Whoops
                             PrettyPageHandler, Ignition,
                             rack-mini-profiler / MiniProfiler
  DBG-PPROF          MEDIUM  Go net/http/pprof import (side-effect
                             registers /debug/pprof/* on DefaultServeMux)
                             or explicit pprof route registration —
                             goroutine/heap/CPU profiles unauthenticated
  DBG-ACTUATOR       MEDIUM  Spring actuator exposure widened —
                             management.endpoints.web.exposure.include
                             carrying * or heapdump/env/beans/threaddump/
                             configprops/metrics/shutdown, or
                             endpoints.<sensitive>.enabled=true —
                             heap dumps leak secrets, env leaks config
  DBG-DEBUG-PARAM    MEDIUM  request-controlled debug switch — a
                             'debug' key read from query/args/params/
                             $_GET/$_REQUEST; verify what it unlocks
  DBG-VERBOSE-ERR    MEDIUM  verbose error display configured —
                             ini_set('display_errors', '1'|'On'),
                             php_flag/php_value display_errors,
                             display_errors=On in ini, Spring
                             server.error.include-stacktrace/message=
                             always|on_param, ASP.NET customErrors
                             Off, express errorhandler(),
                             DEBUG_PROPAGATE_EXCEPTIONS /
                             PROPAGATE_EXCEPTIONS / TRAP_HTTP_EXCEPTIONS
  DBG-BREAKPOINT     LOW     stale breakpoint in committed code —
                             pdb/ipdb/pudb/rpdb.set_trace(),
                             breakpoint(), binding.pry/irb, byebug,
                             JS `debugger;` — hangs a request thread
                             or drops an interactive console
  DBG-DEV-DUMP       LOW     dev dump call left in PHP app code —
                             var_dump/print_r/var_export/dd()/dump()/
                             ray() — leaks internals to the page

Context rules:
  * comment lines and block-comment bodies are stripped first
  * import/require lines never flag (except DBG-PPROF /
    DBG-DEV-CONSOLE import findings, which are imports by nature)
  * test/spec/fixture paths downgrade one tier
  * only explicit enabling / explicit sinks flag — a file that never
    mentions debug is silent (absence is not evidence; the framework
    default may already be safe)
  * good values stay silent: debug=False, DEBUG=False,
    display_errors=Off, X-Debug headers used for logging
  * logging levels named DEBUG (logging.DEBUG, setLevel(DEBUG),
    level: 'debug') are NOT debug-mode findings — pattern requires an
    assignment of a truthy literal to a debug switch, not a log level

Scope: deterministic pre-scan for human review — flags the line shapes
where debug machinery reaches users; it cannot see reverse-proxy
stripping, runtime env injection, or config from other files — clean
output != safe deployment.

  debug_prescan.py <file-or-dir> [--json]
  debug_prescan.py --selftest
Stdlib only. Exit 0 = no HIGH/MEDIUM findings, 1 = findings, 2 = usage.
"""
import json
import os
import re
import sys

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

TARGET_EXT = (
    ".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
    ".php", ".rb", ".go", ".java", ".kt", ".cs", ".groovy",
    ".scala", ".dart", ".vue", ".svelte", ".ini", ".conf",
    ".properties", ".xml", ".env",
)

TARGET_NAMES = {
    ".env", ".env.production", ".env.prod", ".env.development",
    ".env.local", "php.ini", ".user.ini", ".htaccess",
    "web.config", "Web.config", "application.properties",
    "application.yml", "application.yaml", "wp-config.php",
}

SKIP_DIRS = {
    "node_modules", ".git", "vendor", "dist", "build", "target",
    "__pycache__", ".venv", "venv", "coverage", ".next", "out",
}

IMPORT_LINE = re.compile(
    r"^\s*(?:from\s+[\w.]+\s+import\b|import\s+[\w.{*]|"
    r"(?:const|let|var)\s+[\w${},\s]*=\s*require\s*\(|"
    r"\buse\s+[\w:]+|require\s*\(['\"][\w./-]+['\"]\s*\)?\s*;?\s*$|"
    r"#include|package\s+[\w.]+;?\s*$|using\s+[\w.]+;)")

TEST_PATH = re.compile(
    r"(^|/)(test|tests|spec|specs|fixtures?|mocks?|__tests__|__mocks__|"
    r"e2e|cypress|playwright)(/|$)|(_test|_spec|\.test|\.spec)\.",
    re.I)

# filenames where a debug flag is a production-relevant decision
SETTINGS_NAME = re.compile(
    r"settings|config|\.env|env\.|prod|production|application\.|"
    r"web\.config|wp-config|local_settings|\.ini$|\.properties$",
    re.I)

# --- per-line vocabulary ---------------------------------------------------

# debug switched on at the app entrypoint — always HIGH
DEBUG_CALL_HI = re.compile(
    r"\.\s*(?:run|serve|listen|start|run_app|run_until_complete)\s*\("
    r"[^)\n]{0,160}?\bdebug\s*=\s*True\b|"
    r"\bapp\.debug\s*=\s*True\b|"
    r"\buse_debugger\s*=\s*True\b|"
    r"\buse_reloader\s*=\s*True\b[^\n]{0,80}?debug|"
    r"consider_all_requests_local\s*=\s*true|"
    r"show_exceptions\s*=\s*false|"
    r"<compilation\b[^>]{0,200}?\bdebug\s*=\s*[\"']?true|"
    r"define\s*\(\s*['\"]WP_DEBUG_DISPLAY['\"]\s*,\s*true|"
    r"\bFLASK_RUN\s*=\s*[^\n]*--debug\b", re.I)

# truthy literal assigned to a named debug switch
DEBUG_ASSIGN = re.compile(
    r"\b(?:DEBUG|APP_DEBUG|FLASK_DEBUG|DJANGO_DEBUG|REACT_APP_DEBUG|"
    r"GATSBY_DEBUG|WP_DEBUG|APP_DEBUG|DEBUG_MODE|ENABLE_DEBUG)\b"
    r"\s*=\s*(?:True|true|1|['\"]1['\"]|['\"]true['\"]|['\"]on['\"]|On)\b|"
    r"define\s*\(\s*['\"](?:WP_DEBUG|WP_DEBUG_DISPLAY|SCRIPT_DEBUG|"
    r"SAVEQUERIES)['\"]\s*,\s*true\b")

# a debug=true flag inside an env-style line (KEY=value, no code)
ENV_DEBUG = re.compile(
    r"^\s*(?:export\s+)?(?:DEBUG|APP_DEBUG|FLASK_DEBUG|DJANGO_DEBUG|"
    r"WP_DEBUG_DISPLAY)\s*=\s*(?:true|1|on|yes)\b", re.I)

PROD_NAME = re.compile(r"prod|production|\.env\.prod", re.I)

# exception internals into a response sink
RESP_OBJ = r"(?:res|resp|response|reply|ctx|self\.response)"

# exception internals into an HTTP-response-shaped sink only — a bare
# send()/print() carrying the word 'traceback' is IPC or a docstring,
# not a response write
STACK_TO_RESP = re.compile(
    r"\b" + RESP_OBJ + r"\s*\.\s*"
    r"(?:\w+\s*\([^()\n]{0,60}?\)\s*\.\s*)?"
    r"(?:send|json|jsonp|write|end|render|"
    r"sendStatus|send_file|html|text|body|error|set_data)\s*\("
    r"[^)\n]{0,160}?"
    r"(?:\.\s*stack\b|getTraceAsString|format_exc|format_exception|"
    r"\btraceback\b|printStackTrace|stackTraceToString)|"
    r"\b(?:HttpResponse|JsonResponse|StreamingHttpResponse)\s*\("
    r"[^)\n]{0,160}?"
    r"(?:\.\s*stack\b|getTraceAsString|format_exc|format_exception|"
    r"\btraceback\b)|"
    r"\b(?:echo|print_r|die|exit)\s*[\(\s][^;\n]{0,120}?"
    r"(?:getTraceAsString|->getTrace\b|traceback|\.stack\b)", re.I)

RETURN_TRACEBACK = re.compile(
    r"\breturn\b[^\n]{0,100}?(?:traceback\.format_exc|format_exc\s*\(|"
    r"getTraceAsString|\.stack\b)")

PRINTSTACK_RESP = re.compile(
    r"printStackTrace\s*\(\s*(?!System\.(?:out|err)\b)[^)\s]")

# environment structures into a response sink / page
ENV_DUMP = re.compile(
    r"\b(?:send|json|jsonp|end|render|write|sendStatus|HttpResponse|"
    r"echo|print_r|var_dump|json_encode|response\.write)\s*[\(\s.]"
    r"[^)\n]{0,100}?"
    r"(?:process\.env\b|os\.environ|os\.getenv|ENV\.to_h|"
    r"\$_SERVER\b|\$_ENV\b|System\.getenv)", re.I)

# interactive debug consoles / profilers
DEV_CONSOLE_HI = re.compile(
    r"DebuggedApplication\s*\(|"
    r"\buse_debugger\s*=\s*True\b|"
    r"\bphpinfo\s*\(|"
    r"\bwhoops.*?PrettyPageHandler|PrettyPageHandler\s*\(", re.I)

DEV_CONSOLE_MED = re.compile(
    r"\bdebug_toolbar\b|django-debug-toolbar|"
    r"DebugToolbarExtension\s*\(|flask_debugtoolbar|"
    r"\bBetterErrors\b|\bweb[-_]?console\b|"
    r"Whoops\\\\Run|facade[\\\\/]ignition|\bIgnition\b|"
    r"rack-mini-profiler|MiniProfiler\s*\.", re.I)

# Go pprof — side-effect import or explicit route
PPROF = re.compile(
    r"""_\s+["']net/http/pprof["']|"""
    r"/debug/pprof|"
    r"\bpprof\.(?:Index|Handler|Profile|Cmdline|Symbol|Trace)\s*\(")

# Spring actuator exposure widened
ACTUATOR = re.compile(
    r"endpoints\.web\.exposure\.include\s*=\s*[^;\n]{0,140}?"
    r"(?:\*|heapdump|env\b|beans|threaddump|configprops|metrics|"
    r"mappings|shutdown|loggers)|"
    r"endpoint\.(?:heapdump|env|threaddump|beans|shutdown|dump|"
    r"configprops)\.enabled\s*=\s*true|"
    r"exposure\.include\s*=\s*['\"]\*['\"]", re.I)

# request-controlled debug switch
DEBUG_PARAM = re.compile(
    r"\breq\.query\.debug\b|\bctx\.query\.debug\b|"
    r"request\.args\.get\s*\(\s*['\"]debug|"
    r"request\.GET\.get\s*\(\s*['\"]debug|"
    r"request\.GET\s*\[\s*['\"]debug|"
    r"\$_(?:GET|REQUEST)\s*\[\s*['\"]debug|"
    r"params\s*\[\s*:debug\s*\]|"
    r"getParameter\s*\(\s*['\"]debug|"
    r"(?:req\.query|ctx\.query|request\.args|request\.GET|params)"
    r"\s*\[\s*['\"]debug['\"]|"
    r"[?&]debug=(?:true|1)\b")

# verbose error display configuration
VERBOSE_ERR = re.compile(
    r"ini_set\s*\(\s*['\"]display_(?:startup_)?errors['\"]\s*,\s*"
    r"['\"]?(?:1|On|on|true|yes)|"
    r"php_(?:flag|value)\s+display_errors\s+(?:on|1)\b|"
    r"\bdisplay_errors\s*=\s*(?:On|1|STDOUT)\b|"
    r"\bdisplay_startup_errors\s*=\s*(?:On|1)\b|"
    r"server\.error\.include-(?:stacktrace|message|binding-errors)"
    r"\s*=\s*(?:always|on_param)\b|"
    r"<customErrors\b[^>]{0,160}?\bmode\s*=\s*[\"']?Off\b|"
    r"DEBUG_PROPAGATE_EXCEPTIONS['\"\]]*\s*=\s*True|"
    r"PROPAGATE_EXCEPTIONS['\"\]]*\s*=\s*True|"
    r"TRAP_HTTP_EXCEPTIONS['\"\]]*\s*=\s*True|"
    r"\b(?:app|router|server)\s*\.\s*use\s*\(\s*errorhandler\s*\(|"
    r"\buse\s*\(\s*express\.errorHandler|"
    r"express\.errorHandler\s*\(", re.I)

# silent explicit-good values — never flag
DEBUG_OFF = re.compile(
    r"debug\s*=\s*False|debug\s*=\s*false|DEBUG\s*=\s*False|"
    r"display_errors\s*=\s*Off|consider_all_requests_local\s*=\s*false|"
    r"show_exceptions\s*=\s*true|include-stacktrace\s*=\s*never|"
    r"mode\s*=\s*[\"']?(?:On|RemoteOnly)", re.I)

# logging-level DEBUG is not debug mode — exclude the whole line
LOG_LEVEL = re.compile(
    r"\b(?:level|loglevel|log_level|setLevel|setLevel\(|logger|"
    r"logging\.|basicConfig|getLogger|log\.)\b[^\n]{0,40}DEBUG\b|"
    r"\bDEBUG\b[^\n]{0,20}\b(?:level|log)", re.I)

# stale breakpoints
BREAKPOINT = re.compile(
    r"\b(?:pdb|ipdb|pudb|rpdb|wdb)\.set_trace\s*\(|"
    r"\bbreakpoint\s*\(\s*\)|"
    r"\bbinding\.(?:pry|irb)\b|"
    r"(?:^|[^.\w])byebug\b|"
    r"(?:^|[;{])\s*debugger\s*;")

# dev dump calls — PHP files only
DEV_DUMP = re.compile(
    r"\b(?:var_dump|print_r|var_export|debug_zval_dump)\s*\(|"
    r"(?:^|[^\w>])(?:dd|dump|ray)\s*\(", re.I)

PER_CODE_CAP = 40


class Finding:
    def __init__(self, sev, code, where, line, msg):
        self.sev, self.code, self.where = sev, code, where
        self.line, self.msg = line, msg

    def as_dict(self):
        return {"severity": self.sev, "code": self.code,
                "file": self.where, "line": self.line, "msg": self.msg}


def strip_comments(lines):
    """Remove full-line comments and /* */ block bodies. Keeps count."""
    out, in_block = [], False
    for ln in lines:
        s = ln
        if in_block:
            if "*/" in s:
                s = s.split("*/", 1)[1]
                in_block = False
            else:
                out.append("")
                continue
        while "/*" in s:
            if "*/" in s.split("/*", 1)[1]:
                s = s.replace(s[s.index("/*"):s.index("*/") + 2], " ", 1)
            else:
                s = s.split("/*", 1)[0]
                in_block = True
        st = s.strip()
        if st.startswith(("//", "#", "*", "<!--")):
            out.append("")
            continue
        out.append(s)
    return out


def downgrade(sev):
    return "MEDIUM" if sev == "HIGH" else "LOW"


def scan_file(path, rel):
    try:
        raw = open(path, encoding="utf-8", errors="replace").read()
    except OSError:
        return []
    is_test = bool(TEST_PATH.search(rel))
    is_php = rel.lower().endswith(".php")
    is_settings = bool(SETTINGS_NAME.search(os.path.basename(rel)))
    is_prod = bool(PROD_NAME.search(os.path.basename(rel)))
    lines = strip_comments(raw.splitlines())
    findings = []
    counts = {}

    def emit(sev, code, ln, msg):
        if is_test:
            sev = downgrade(sev)
        if counts.get(code, 0) >= PER_CODE_CAP:
            return
        counts[code] = counts.get(code, 0) + 1
        findings.append(Finding(sev, code, rel, ln, msg))

    for i, ln in enumerate(lines, 1):
        s = ln.strip()
        if not s:
            continue

        # import-gated findings run even on import lines
        if PPROF.search(ln):
            emit("MEDIUM", "DBG-PPROF", i,
                 "Go pprof registered — /debug/pprof/* serves "
                 "goroutine/heap/CPU profiles without auth")
        if DEV_CONSOLE_MED.search(ln) and IMPORT_LINE.match(ln):
            emit("MEDIUM", "DBG-DEV-CONSOLE", i,
                 "debug toolbar/console/profiler package wired in — "
                 "verify it never reaches a production build")

        if IMPORT_LINE.match(ln):
            continue
        if DEBUG_OFF.search(ln):
            continue

        if DEBUG_CALL_HI.search(ln):
            emit("HIGH", "DBG-DEBUG-ON", i,
                 "debug mode enabled at the app entrypoint / prod "
                 "config — full error pages, reloaders, or an "
                 "interactive console ship to users")

        if DEBUG_ASSIGN.search(ln) and not LOG_LEVEL.search(ln):
            if is_prod or is_settings or is_php is False and \
                    rel.endswith((".ini", ".properties", ".env")):
                emit("HIGH", "DBG-DEBUG-ON", i,
                     "debug flag set truthy in a settings/config/env "
                     "file — the deploy shape this file feeds runs "
                     "with debug on")
            else:
                emit("MEDIUM", "DBG-DEBUG-ON", i,
                     "debug flag assigned truthy — verify this line "
                     "never ships in a production build")
        elif (is_settings or rel.endswith((".env", ".ini", ".properties",
                                          ".conf"))) and \
                ENV_DEBUG.search(ln):
            emit("HIGH" if is_prod else "MEDIUM", "DBG-DEBUG-ON", i,
                 "env-style debug switch set on%s" %
                 (" in a production-named env file" if is_prod else ""))

        if STACK_TO_RESP.search(ln) or RETURN_TRACEBACK.search(ln):
            emit("HIGH", "DBG-STACK-TO-CLIENT", i,
                 "exception internals written into the HTTP response "
                 "— client receives file paths, queries, framework "
                 "internals")

        if PRINTSTACK_RESP.search(ln):
            emit("HIGH", "DBG-STACK-TO-CLIENT", i,
                 "printStackTrace() into a non-System stream — "
                 "response writer / socket gets the full trace")

        if ENV_DUMP.search(ln):
            emit("HIGH", "DBG-ENV-DUMP", i,
                 "environment structure sent to the client — env "
                 "usually carries credentials and connection strings")

        if DEV_CONSOLE_HI.search(ln):
            emit("HIGH", "DBG-DEV-CONSOLE", i,
                 "interactive debug console or full-environment "
                 "disclosure reachable (DebuggedApplication / "
                 "phpinfo / Whoops pretty page)")
        elif DEV_CONSOLE_MED.search(ln):
            emit("MEDIUM", "DBG-DEV-CONSOLE", i,
                 "debug toolbar/console/profiler wired in — verify "
                 "it never reaches a production build")

        if ACTUATOR.search(ln):
            emit("MEDIUM", "DBG-ACTUATOR", i,
                 "Spring actuator exposure widened — heapdump/env/"
                 "beans/threaddump endpoints leak internals "
                 "unauthenticated")

        if DEBUG_PARAM.search(ln):
            emit("MEDIUM", "DBG-DEBUG-PARAM", i,
                 "request-controlled 'debug' switch — an attacker "
                 "can flip whatever this gates; verify the output "
                 "it unlocks")

        if VERBOSE_ERR.search(ln):
            emit("MEDIUM", "DBG-VERBOSE-ERR", i,
                 "verbose error display configured — stack traces / "
                 "error detail rendered to the response")

        if BREAKPOINT.search(ln):
            emit("LOW", "DBG-BREAKPOINT", i,
                 "stale breakpoint — hangs the request thread or "
                 "drops an interactive console")

        if is_php and DEV_DUMP.search(ln):
            emit("LOW", "DBG-DEV-DUMP", i,
                 "dev dump call left in PHP app code — internals "
                 "leak to the page")

    return findings


def find_targets(root):
    if os.path.isfile(root):
        return [root]
    out = []
    for dp, dns, fns in os.walk(root):
        dns[:] = [d for d in dns if d not in SKIP_DIRS]
        for fn in fns:
            if fn.endswith(TARGET_EXT) or fn in TARGET_NAMES:
                out.append(os.path.join(dp, fn))
    return sorted(out)


def render(findings, targets):
    findings.sort(key=lambda f: (SEV_ORDER[f.sev], f.where, f.line))
    rows = ["# debug_prescan report", "",
            "files scanned: %d — findings: %d" % (len(targets),
                                                  len(findings)), ""]
    cur = None
    for f in findings:
        if f.sev != cur:
            cur = f.sev
            rows.append("## %s" % cur)
        rows.append("- `%s` %s:%d — %s" % (f.code, f.where, f.line,
                                           f.msg))
    if not findings:
        rows.append("no HIGH/MEDIUM/LOW signatures found — this is a "
                    "pre-scan; clean output != a safe deployment")
    return "\n".join(rows)


# --- selftest -------------------------------------------------------------
SELFTEST = [
    # --- DBG-DEBUG-ON ---
    ("app.py", "if __name__ == '__main__':\n    app.run(debug=True)\n",
     {"DBG-DEBUG-ON"}),
    ("app.py", "app.run(host='0.0.0.0', port=8000, debug=True)\n",
     {"DBG-DEBUG-ON"}),
    ("app.py", "app.run(host='0.0.0.0', port=8000)\n", set()),
    ("settings.py", "DEBUG = True\n", {"DBG-DEBUG-ON"}),
    ("settings.py", "DEBUG = False\n", set()),
    ("settings.py", "DEBUG = os.getenv('DEBUG') == '1'\n", set()),
    ("util.py", "DEBUG = True\n", {"DBG-DEBUG-ON"}),          # MEDIUM
    (".env.production", "APP_DEBUG=true\n", {"DBG-DEBUG-ON"}),
    (".env", "APP_DEBUG=true\n", {"DBG-DEBUG-ON"}),           # MEDIUM
    (".env", "APP_DEBUG=false\n", set()),
    ("application.properties",
     "server.error.include-stacktrace=always\n",
     {"DBG-VERBOSE-ERR"}),
    ("application.properties",
     "management.endpoints.web.exposure.include=*\n",
     {"DBG-ACTUATOR"}),
    ("application.properties",
     "management.endpoints.web.exposure.include=health,info\n", set()),
    ("application.properties",
     "management.endpoint.heapdump.enabled=true\n", {"DBG-ACTUATOR"}),
    ("env.rb", "config.consider_all_requests_local = true\n",
     {"DBG-DEBUG-ON"}),
    ("env.rb", "config.consider_all_requests_local = false\n", set()),
    ("web.config", '<compilation debug="true" />\n', {"DBG-DEBUG-ON"}),
    ("web.config", '<compilation debug="false" />\n', set()),
    ("wp-config.php", "define('WP_DEBUG', true);\n", {"DBG-DEBUG-ON"}),
    ("wp-config.php", "define('WP_DEBUG_DISPLAY', true);\n",
     {"DBG-DEBUG-ON"}),
    ("log.py", "logging.basicConfig(level=logging.DEBUG)\n", set()),
    ("log.js", "logger.setLevel('DEBUG')\n", set()),
    # --- DBG-STACK-TO-CLIENT ---
    ("h.js", "app.use((err,req,res,next)=>{ res.send(err.stack); });\n",
     {"DBG-STACK-TO-CLIENT"}),
    ("h.js", "app.use((err,req,res,next)=>{ res.status(500).send"
     "({error: err.stack}); });\n", {"DBG-STACK-TO-CLIENT"}),
    ("h.js", "app.use((err,req,res,next)=>{ res.status(500).send"
     "({error: 'internal'}); });\n", set()),
    ("h.py", "except Exception:\n    return traceback.format_exc()\n",
     {"DBG-STACK-TO-CLIENT"}),
    ("h.php", "echo $e->getTraceAsString();\n", {"DBG-STACK-TO-CLIENT"}),
    ("H.java", "catch (Exception e) { e.printStackTrace"
     "(response.getWriter()); }\n", {"DBG-STACK-TO-CLIENT"}),
    ("H.java", "catch (Exception e) { e.printStackTrace(); }\n", set()),
    ("h.js", "console.error(err.stack);\n", set()),            # server log
    # --- DBG-ENV-DUMP ---
    ("d.js", "app.get('/env', (req,res)=>{ res.json(process.env); });\n",
     {"DBG-ENV-DUMP"}),
    ("d.js", "const cfg = { key: process.env.KEY };\n", set()),
    ("d.php", "print_r($_SERVER);\n",
     {"DBG-ENV-DUMP", "DBG-DEV-DUMP"}),
    ("d.py", "return HttpResponse(str(os.environ))\n",
     {"DBG-ENV-DUMP"}),
    ("d.py", "cfg = {'k': os.environ.get('K')}\n", set()),
    # --- DBG-DEV-CONSOLE ---
    ("w.py", "app = DebuggedApplication(app, evalex=True)\n",
     {"DBG-DEV-CONSOLE"}),
    ("w.py", "run_simple('0.0.0.0', 5000, app, use_debugger=True)\n",
     {"DBG-DEV-CONSOLE", "DBG-DEBUG-ON"}),
    ("i.php", "<?php phpinfo();\n", {"DBG-DEV-CONSOLE"}),
    ("settings.py", "INSTALLED_APPS = ['debug_toolbar', 'core']\n",
     {"DBG-DEV-CONSOLE"}),
    ("app.rb", "use BetterErrors::Middleware\n", {"DBG-DEV-CONSOLE"}),
    ("Gemfile.rb", "gem 'web-console'\n", {"DBG-DEV-CONSOLE"}),
    # --- DBG-PPROF ---
    ("main.go", "import _ \"net/http/pprof\"\n", {"DBG-PPROF"}),
    ("main.go", "r.HandleFunc(\"/debug/pprof/heap\", pprof.Index)\n",
     {"DBG-PPROF"}),
    ("main.go", "import \"net/http\"\n", set()),
    # --- DBG-DEBUG-PARAM ---
    ("v.js", "if (req.query.debug) showInternals(res);\n",
     {"DBG-DEBUG-PARAM"}),
    ("v.py", "if request.args.get('debug'): dump_state()\n",
     {"DBG-DEBUG-PARAM"}),
    ("v.php", "if ($_GET['debug']) { print_r($cfg); }\n",
     {"DBG-DEBUG-PARAM", "DBG-DEV-DUMP"}),
    ("v.js", "const dbg = config.debug_enabled;\n", set()),
    ("u.js", "res.redirect('/x?debug=true');\n", {"DBG-DEBUG-PARAM"}),
    # --- DBG-VERBOSE-ERR ---
    ("index.php", "ini_set('display_errors', '1');\n", {"DBG-VERBOSE-ERR"}),
    (".htaccess", "php_flag display_errors on\n", {"DBG-VERBOSE-ERR"}),
    ("php.ini", "display_errors = Off\n", set()),
    ("php.ini", "display_errors = On\n", {"DBG-VERBOSE-ERR"}),
    ("web.config", '<customErrors mode="Off" />\n', {"DBG-VERBOSE-ERR"}),
    ("web.config", '<customErrors mode="RemoteOnly" />\n', set()),
    ("app.py", "app.config['PROPAGATE_EXCEPTIONS'] = True\n",
     {"DBG-VERBOSE-ERR"}),
    ("d.js", "app.use(errorhandler());\n", {"DBG-VERBOSE-ERR"}),
    # --- DBG-BREAKPOINT ---
    ("v.py", "def f():\n    pdb.set_trace()\n", {"DBG-BREAKPOINT"}),
    ("v.py", "breakpoint()\n", {"DBG-BREAKPOINT"}),
    ("v.rb", "def go\n  binding.pry\nend\n", {"DBG-BREAKPOINT"}),
    ("v.js", "if (x) { debugger; }\n", {"DBG-BREAKPOINT"}),
    ("v.js", "const bp = 'debugger statement';\n", set()),
    # --- DBG-DEV-DUMP ---
    ("c.php", "var_dump($row);\n", {"DBG-DEV-DUMP"}),
    ("c.php", "dd($user);\n", {"DBG-DEV-DUMP"}),
    ("c.php", "ray($cart)->green();\n", {"DBG-DEV-DUMP"}),
    ("c.py", "print_r(x)\n", set()),                            # py only
    # --- interplay / FP guards ---
    ("test/test_app.py", "app.run(debug=True)\n", {"DBG-DEBUG-ON"}),
    ("app.py", "# app.run(debug=True)\n", set()),
    ("app.py", "/* app.run(debug=True) */\n", set()),
    ("app.py", "import debug_toolbar\n", {"DBG-DEV-CONSOLE"}),
    ("app.js", "const DEBUG = false;\n", set()),
    ("clean.php", "echo 'hello';\n", set()),
]


def run_selftest():
    import tempfile
    fails = []
    for i, (name, src, expect) in enumerate(SELFTEST):
        with tempfile.TemporaryDirectory() as td:
            p = os.path.join(td, name)
            os.makedirs(os.path.dirname(p) or td, exist_ok=True)
            open(p, "w").write(src)
            got = {f.code for f in scan_file(p, name)}
            missing = expect - got
            extra = got - expect
            if missing or extra:
                fails.append(("v%d %s" % (i, name),
                              "missing=%s extra=%s" % (missing, extra)))
    if fails:
        for f_ in fails:
            print("SELFTEST FAIL", f_)
        return 1
    print("SELFTEST PASS (%d vectors)" % len(SELFTEST))
    return 0


def main():
    args = [a for a in sys.argv[1:] if a != "--json"]
    want_json = "--json" in sys.argv
    if "--selftest" in sys.argv:
        return run_selftest()
    if not args:
        print(__doc__)
        return 2
    root = args[0]
    targets = find_targets(root)
    findings = []
    for p in targets:
        rel = os.path.relpath(p, root) if os.path.isdir(root) \
            else os.path.basename(p)
        findings += scan_file(p, rel)
    if want_json:
        print(json.dumps({"findings": [f.as_dict() for f in findings],
                          "scanned": len(targets)}, indent=1))
    else:
        print(render(findings, targets))
    return 1 if any(f.sev in ("HIGH", "MEDIUM") for f in findings) else 0


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