#!/usr/bin/env python3
"""authz_prescan.py — deterministic broken-access-control pre-scan.

ARION service artifact. Scans source files for the signatures of
missing authorization — OWASP A01 territory: identity or role taken
straight from request parameters instead of the session/token,
ORM objects fetched by a client-supplied id with no ownership
check in the handler (BOLA / IDOR), mutating routes registered in
files with no auth vocabulary at all, /admin paths with no role
gate in the file, and debug/internal route paths left mounted.
Line-wise, fully offline, no inference.

Findings (base severity):
  AUTHZ-ROLE-FROM-REQ      HIGH    a role/permission key is read
                                   from request input — req.body.role,
                                   request.POST['is_staff'], params[:admin],
                                   getParameter("role") — privilege level
                                   supplied by the caller is never a check;
                                   MEDIUM when compared against a
                                   session-derived value (verify shape)
  AUTHZ-REQ-IDENTITY       MEDIUM  an identity/tenant key (user, owner,
                                   account, org, tenant, customer, member,
                                   merchant, seller, buyer, author,
                                   employee + _id/id suffix) is taken from
                                   request input — req.body.userId,
                                   $_GET['account_id'], request.args.get(
                                   'tenant') — acting-as chosen by client;
                                   HIGH when the captured variable flows
                                   into a data-access sink within the
                                   handler window or the key feeds a sink
                                   inline; silent when the line compares
                                   the request value to a session-derived
                                   identity (that is the check, not the bug)
  AUTHZ-OBJECT-NO-OWNER    HIGH    an ORM fetch/delete/update keyed on a
                                   client-supplied object id — findById(
                                   req.params.id), Model.find(params[:id]),
                                   get_object_or_404(M, pk=request.GET…),
                                   destroy({where:{id:req.params.oid}}) —
                                   and no ownership predicate in the call
                                   args or ±8-line window (owner field +
                                   session source together, e.g.
                                   {owner:req.user.id} or
                                   order.userId === req.user.id); MEDIUM in
                                   files that carry auth vocabulary
                                   (middleware may scope elsewhere) —
                                   this is the BOLA/IDOR shape, the most
                                   common real-world access-control break
  AUTHZ-ADMIN-NO-ROLE      HIGH    a mutating route whose path contains
                                   /admin (or an admin.* blueprint/mount)
                                   in a file with no admin/role vocabulary
                                   (isAdmin, requireAdmin, hasRole,
                                   @PreAuthorize, role === 'admin' …);
                                   MEDIUM for read methods (GET/HEAD)
  AUTHZ-MUTATING-NO-AUTH   MEDIUM  a mutating route registration —
                                   app.post/put/delete/patch, router.*,
                                   Route::post, r.POST(, .Methods("POST"),
                                   @PostMapping, @app.post — in a file
                                   with no auth vocabulary anywhere
                                   (requireAuth, req.user, login_required,
                                   jwt, passport, session.user …): the
                                   whole controller is unguarded; LOW in
                                   auth-aware files (parent middleware
                                   may still protect it)
  AUTHZ-SENSITIVE-ROUTE    LOW     a route path exposing debug/internal
                                   surfaces — /debug, /internal, /actuator,
                                   /env, /heapdump, /metrics, /pprof,
                                   /trace — which frequently leak state
                                   or allow operations outside auth

Context rules:
  * comment lines and block-comment bodies are stripped first
  * import/require lines never flag
  * test/spec/fixture paths downgrade one tier
  * public-route path vocab (login|register|signup|signin|logout|auth|
    oauth|sso|callback|public|health|status|ping|docs|swagger|webhook|
    hook|confirm|verify|reset|forgot|password|unsubscribe|feed|rss|
    sitemap|robots|favicon|share|embed|track|beacon|version) silences
    the route-based findings — those are public by design
  * a request value compared (==/===/assert/if) to a session-derived
    value is an ownership check, not a bug — it suppresses, not flags
  * identity keys inside an ORM fetch arg are reported as REQ-IDENTITY;
    OBJECT-NO-OWNER fires on non-identity object keys (id, slug,
    orderId, postId …) so one line never double-counts
  * per-file cap on route-noise codes keeps big route tables readable

Scope: deterministic pre-scan for human review — flags the line
shapes where authorization is absent or client-controlled; it cannot
see middleware applied in other files, framework-level guards, or
runtime policy — clean output != authorized.

  authz_prescan.py <file-or-dir> [--json]
  authz_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",
    ".rs", ".scala",
)

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)

# --- vocabulary ----------------------------------------------------------
# session/token-derived identity sources — the correct places to get "who"
SESSION_SRC = re.compile(
    r"req\.(?:user|auth|session|account|member)\b|"
    r"request\.(?:user|session|auth)\b|"
    r"\bsession\.|\bviewer\b|"
    r"current_?user|currentUser|currentAccount|"
    r"auth\.(?:uid|user|currentUser)|Auth::(?:user|id|check)|"
    r"auth\(\)->(?:user|id)|\$request->user\(|"
    r"getUserId\(|getSession\(|getServerSession|"
    r"ctx\.(?:user|state\.user)|c\.Get\(['\"]user|"
    r"flask_login|login_required|"
    r"jwt\.(?:verify|decode)|decoded\.|principal\b|"
    r"@AuthenticationPrincipal|SecurityContext|"
    r"req\.headers\.get\(['\"]x-user", re.I)

# identity-ish key names: the "who" a caller must not choose
IDENT_KEY = re.compile(
    r"(?:user|owner|tenant|org(?:anization)?|account|customer|member|"
    r"merchant|seller|buyer|author|employee|client|team|workspace|"
    r"company|household|profile)(?:_?[Ii]d)?s?\b")

ROLE_KEY = re.compile(
    r"(?:role|is_?admin|isAdmin|admin|permissions?|scopes?|is_?staff|"
    r"is_?superuser|is_?moderator|access_?level|privilege|grant)s?\b",
    re.I)

# request-input surfaces, any language family we cover
REQ_SRC = re.compile(
    r"req\.(?:body|query|params|param\b|headers)|"
    r"request\.(?:args|form|GET|POST|json|values|data|params|"
    r"query_params|FILES|body|input\b)|"
    r"\$_(?:GET|POST|REQUEST|COOKIE)|"
    r"params\[|ctx\.(?:params|query|request)|"
    r"c\.(?:Param|QueryParam|FormValue|Form)\(|"
    r"\.getParameter\(|r\.URL\.Query\(\)|r\.FormValue\(|"
    r"event\.(?:pathParameters|queryStringParameters|body)|"
    r"request\(\)->(?:input|query|route)|\$this->input->|"
    r"@RequestParam|@PathVariable|"
    r"\bInput\.(?:get|post)\(|Request::(?:input|query)")

# request-source -> key extraction: capture the field name actually read
REQ_KEY = [
    re.compile(r"req\.(?:body|query|params|headers)\s*(?:\.\s*|\[\s*)"
               r"['\"]?(\w+)"),
    re.compile(r"req\.param\(\s*['\"](\w+)"),
    re.compile(r"request\.(?:args|form|GET|POST|json|values|data|params|"
               r"query_params|body)\s*(?:\.getlist\(\s*|\.get\(\s*|"
               r"\[\s*|\.\s*)['\"]?(\w+)"),
    re.compile(r"\$_(?:GET|POST|REQUEST|COOKIE)\s*\[\s*['\"](\w+)"),
    re.compile(r"params\s*\[\s*:?['\"]?(\w+)"),
    re.compile(r"ctx\.(?:params|query)\s*(?:\.\s*|\[\s*)['\"]?(\w+)"),
    re.compile(r"c\.(?:Param|QueryParam|FormValue|Form)\(\s*['\"](\w+)"),
    re.compile(r"\.getParameter\(\s*['\"](\w+)"),
    re.compile(r"r\.FormValue\(\s*['\"](\w+)"),
    re.compile(r"event\.(?:pathParameters|queryStringParameters)\s*"
               r"(?:\.\s*|\[\s*)['\"]?(\w+)"),
    re.compile(r"request\(\)->(?:input|query|route)\(\s*['\"](\w+)"),
    re.compile(r"\$this->input->(?:post|get)\(\s*['\"](\w+)"),
    re.compile(r"\bInput\.(?:get|post)\(\s*['\"](\w+)"),
    re.compile(r"Request::(?:input|query)\(\s*['\"](\w+)"),
]
DESTRUCT_REQ = re.compile(
    r"{\s*([\w,\s]+)\s*}\s*=\s*"
    r"(?:req\.(?:body|query|params)|request\.(?:data|json|POST|GET))")

# ORM / data-access sinks keyed by id
DB_SINK = re.compile(
    r"\.(?:findById|findOne|findUnique|findFirst|findOneAndUpdate|"
    r"findOneAndDelete|findByIdAndUpdate|findByIdAndDelete|findByPk|"
    r"getById|find_by|find|get_or_404|get_object_or_404|updateOne|"
    r"deleteOne|replaceOne|destroy|updateMany|deleteMany|remove|"
    r"findOneOrFail|findOrFail|retrieve)\s*\(|"
    r"\.objects\.(?:get|filter)\s*\(|"
    r"(?<![\w.])(?:get_object_or_404|get_or_404)\s*\(|"
    r"repository\.findById\s*\(|"
    r"\.fetchOne\s*\(|db\.get\s*\(|knex\([^)]*\)\.where")

# owner-field names that, WITH a session source, form a real check
OWNER_FIELD = re.compile(
    r"user_?[Ii]d|owner(?:_?[Ii]d)?|account_?[Ii]d|tenant(?:_?[Ii]d)?|"
    r"org_?[Ii]d|customer_?[Ii]d|member_?[Ii]d|author_?[Ii]d|"
    r"created_?[Bb]y|createdBy|merchant_?[Ii]d|workspace_?[Ii]d")

# auth vocabulary — presence anywhere in a file => auth-aware file
AUTH_VOCAB = re.compile(
    r"requireAuth|isAuthenticated|ensureAuth|authenticate\b|"
    r"authenticateToken|authMiddleware|requireLogin|login_required|"
    r"LoginRequiredMixin|passport|verifyToken|checkAuth|authRequired|"
    r"requiresAuth|hasRole|requireRole|@PreAuthorize|@Secured|"
    r"authorize\(|req\.user|req\.auth|current_?user|currentUser|"
    r"session\.user|jwt\.verify|bearer|flask_login|permission_required|"
    r"IsAuthenticated|@permission_classes|middleware\.auth|"
    r"Auth::|\$request->user\(|auth\(\)->user|django\.contrib\.auth|"
    r"@UseGuards|AuthGuard|@Roles|withAuth|getSession|clerk|"
    r"supertokens|SecurityRequirement|principal\b|"
    r"auth\(\)->|ctx\.user|before_action.*authenticat|"
    r"private_route|protectedRoute|"
    # 401/403 semantics, middleware pipelines, auth-module imports,
    # viewer/current-account handler conventions
    r"\b401\b|\b403\b|unauthori|forbidden|unauthenticated|"
    r"\.use\s*\(|from\s+['\"][^'\"]*(?:auth|session|permission|guard)|"
    r"\bviewer\b|\bGuard\b|guard\(", re.I)

ADMIN_VOCAB = re.compile(
    r"isAdmin|requireAdmin|adminOnly|hasRole\(['\"]ADMIN|"
    r"@PreAuthorize|@Roles|checkRole|assertAdmin|"
    r"role\s*={2,3}\s*['\"]admin|role\s*==\s*['\"]admin|"
    r"permission\(['\"]admin|staff_member_required|"
    r"user_passes_test|IsAdminUser|admin_required", re.I)

# route registration machinery across frameworks
ROUTE_DEF = re.compile(
    r"(?:\b(?:app|router|api|server|route|routes|r|e|group|v\d|"
    r"bp|blueprint|apiRouter)\s*\.\s*"
    r"(?:get|post|put|delete|patch|head|options|all|use)\s*\(|"
    r"@(?:app|bp|blueprint|router|api)\s*\.\s*"
    r"(?:route|get|post|put|delete|patch)\s*\(|"
    r"Route::(?:get|post|put|delete|patch|any|match)\s*\(|"
    r"\.Methods\s*\(|"
    r"@(?:Get|Post|Put|Delete|Patch|Request)Mapping\b|"
    r"\b(?:GET|POST|PUT|DELETE|PATCH|HEAD)\s*\(\s*[\"'`])")

MUTATING_DEF = re.compile(
    r"\.(?:post|put|delete|patch)\s*\(|"
    r"\.Methods\s*\([^)]*(?:POST|PUT|DELETE|PATCH)|"
    r"Route::(?:post|put|delete|patch)\s*\(|"
    r"@(?:Post|Put|Delete|Patch)Mapping|"
    r"@(?:app|bp|blueprint|router|api)\s*\.\s*"
    r"(?:post|put|delete|patch)\s*\(|"
    r"\b(?:POST|PUT|DELETE|PATCH)\s*\(\s*[\"'`]|"
    r"methods\s*=\s*\[[^\]]*(?:POST|PUT|DELETE|PATCH)")

PUBLIC_PATH = re.compile(
    r"login|sign_?in|sign_?up|register|logout|log-?out|auth/|/auth|"
    r"oauth|sso|callback|public|health|status|ping|docs|swagger|"
    r"openapi|webhook|hook|confirm|verify|reset|forgot|password|"
    r"unsubscribe|feed|rss|sitemap|robots|favicon|share|embed|"
    r"track|beacon|telemetry|version\b|invite|join|magic|otp|"
    r"recover|well-known|manifest", re.I)

SENSITIVE_PATH = re.compile(
    r"/(?:debug|internal|actuator|env|heapdump|heap-dump|dump|trace|"
    r"metrics|pprof|debug/vars|_debug|console|repl|status/full|"
    r"threaddump|thread-dump|configprops|beans|mappings)(?:/|'|\"|$)",
    re.I)

ADMIN_PATH = re.compile(r"/admin(?:/|'|\"|$)|['\"]admin\.", re.I)

ASSIGN_IDENT = re.compile(
    r"(?P<var>\b\w*(?:user|owner|tenant|org|account|customer|member|"
    r"merchant|seller|buyer|author|employee|client|team|workspace|"
    r"company|profile)\w*(?:_?[Ii]d)\b)\s*[:=]")

ASSIGN_ANY = re.compile(r"\b(\w+)\s*[:=]\s*[^=]")

ROUTE_NOISE_CAP = 25   # per file, per route-noise code

WINDOW = 8


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}


# --- helpers --------------------------------------------------------------
def strip_comments(lines):
    """Remove full-line comments and /* */ block bodies. Keeps line 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 line_has_session_vs_req(line):
    """True when a request value is compared to a session-derived value —
    that is the ownership check itself, not the bug."""
    return bool(REQ_SRC.search(line) and SESSION_SRC.search(line)
                and re.search(r"[!=]={1,2}|\bassert\b|\bif\b|\?.*:", line))


def request_keys(text):
    """Field names read from request input in this text."""
    keys = []
    for rx in REQ_KEY:
        keys += [m.group(1) for m in rx.finditer(text)]
    for m in DESTRUCT_REQ.finditer(text):
        keys += [k.strip() for k in m.group(1).split(",") if k.strip()]
    return keys


def kind_of(key):
    if ROLE_KEY.search(key):
        return "role"
    if IDENT_KEY.search(key):
        return "ident"
    return "obj"


VAR_SKIP = {
    "self", "cls", "this", "data", "None", "True", "False", "true",
    "false", "null", "key", "keys", "value", "values", "type", "name",
    "args", "kwargs", "result", "res", "req", "resp", "response",
    "body", "headers", "query", "form", "json", "text", "content",
    "url", "path", "return", "const", "let", "var", "new", "import",
}


def collect_req_vars(lines):
    """var -> 'ident'|'obj'|'role' for vars assigned from request input."""
    vs = {}
    for ln in lines:
        if not REQ_SRC.search(ln):
            continue
        m = ASSIGN_ANY.search(ln)
        if m and m.group(1) not in VAR_SKIP and not m.group(1).isdigit():
            vs.setdefault(m.group(1), kind_of(m.group(1)))
    return vs


def call_args(lines, i, sm):
    """Text of the sink call's argument list, paren-balanced (~6 lines max)."""
    buf, depth, j = [], 1, i
    seg = lines[i][sm.end():]
    while True:
        for ch in seg:
            if ch in "([{":
                depth += 1
            elif ch in ")]}":
                depth -= 1
                if depth == 0:
                    return "".join(buf)
            buf.append(ch)
        j += 1
        if j >= len(lines) or j > i + 6:
            return "".join(buf)
        buf.append("\n")
        seg = lines[j]


def has_owner_check(idx, lines):
    """Owner field + session source together, inside args block or ±WINDOW."""
    lo, hi = max(0, idx - WINDOW), min(len(lines), idx + WINDOW + 1)
    block = "\n".join(lines[lo:hi])
    return bool(OWNER_FIELD.search(block) and SESSION_SRC.search(block))


def flows_to_sink(lines, i, var):
    for j in range(i + 1, min(i + 15, len(lines))):
        if DB_SINK.search(lines[j]) and \
                re.search(r"\b%s\b" % re.escape(var), lines[j]):
            return True
    return False


def scan_file(path, rel):
    try:
        raw = open(path, encoding="utf-8", errors="replace").read()
    except OSError:
        return []
    lines = strip_comments(raw.splitlines())
    findings = []
    is_test = bool(TEST_PATH.search("/" + rel))
    auth_aware = bool(AUTH_VOCAB.search(raw))
    admin_vocab = bool(ADMIN_VOCAB.search(raw))
    req_vars = collect_req_vars(lines)
    ident_vars = {v for v, k in req_vars.items() if k == "ident"}
    obj_vars = {v for v, k in req_vars.items() if k == "obj"}
    route_noise = {"AUTHZ-MUTATING-NO-AUTH": 0, "AUTHZ-SENSITIVE-ROUTE": 0,
                   "AUTHZ-ADMIN-NO-ROLE": 0}

    def add(sev, code, i, msg):
        if is_test:
            order = ["HIGH", "MEDIUM", "LOW", "INFO"]
            sev = order[min(order.index(sev) + 1, 3)]
        findings.append(Finding(sev, code, rel, i + 1, msg))

    for i, ln in enumerate(lines):
        if not ln.strip() or IMPORT_LINE.match(ln):
            continue

        keys = request_keys(ln)
        kinds = {kind_of(k) for k in keys}
        is_check = line_has_session_vs_req(ln)
        sink_here = False
        sm = DB_SINK.search(ln)
        if sm:
            sink_here = True
            # bare .find(/.filter( with a lambda/comparison predicate is
            # an in-memory array search, not an id-keyed ORM fetch
            if re.search(r"\.(?:find|filter|remove)\s*\($", sm.group(0)):
                argtail = ln[sm.end():][:80]
                if re.match(r"\s*(?:\(|async\b|function\b|[\w$]+\s*=>)",
                            argtail) or re.search(r"[!=]==?", argtail):
                    sink_here = False

        # --- AUTHZ-ROLE-FROM-REQ -------------------------------------
        if "role" in kinds:
            sev = "MEDIUM" if is_check else "HIGH"
            add(sev, "AUTHZ-ROLE-FROM-REQ", i,
                "role/permission key read from request input — caller "
                "chooses own privilege level")

        # --- AUTHZ-REQ-IDENTITY --------------------------------------
        if "ident" in kinds and not is_check:
            m = ASSIGN_IDENT.search(ln)
            var = m.group("var") if m else None
            flows = bool(var and REQ_SRC.search(ln)
                         and flows_to_sink(lines, i, var))
            sev = "HIGH" if (flows or sink_here) else "MEDIUM"
            add(sev, "AUTHZ-REQ-IDENTITY", i,
                "identity/tenant key read from request input — acting-as "
                "is client-controlled%s" %
                ("; feeds a data sink" if (flows or sink_here) else ""))

        # --- AUTHZ-OBJECT-NO-OWNER ------------------------------------
        if sink_here:
            args_text = call_args(lines, i, sm)
            arg_keys = request_keys(args_text)
            obj_key = any(kind_of(k) == "obj" for k in arg_keys)
            # the tracked request-var must sit inside the sink call args
            obj_var = any(re.search(r"\b%s\b" % re.escape(v), args_text)
                          for v in obj_vars)
            if (obj_key or obj_var) and not has_owner_check(i, lines):
                sev = "MEDIUM" if auth_aware else "HIGH"
                add(sev, "AUTHZ-OBJECT-NO-OWNER", i,
                    "object fetched/mutated by client-supplied id with "
                    "no ownership predicate in the handler — "
                    "BOLA/IDOR shape")

        # --- route registrations --------------------------------------
        if ROUTE_DEF.search(ln):
            path = ln[ln.find("("):]
            is_admin = bool(ADMIN_PATH.search(path))
            is_mut = bool(MUTATING_DEF.search(ln))
            is_pub = bool(PUBLIC_PATH.search(path))
            if not is_pub:
                if is_admin and not admin_vocab and \
                        route_noise["AUTHZ-ADMIN-NO-ROLE"] < ROUTE_NOISE_CAP:
                    sev = "HIGH" if is_mut else "MEDIUM"
                    add(sev, "AUTHZ-ADMIN-NO-ROLE", i,
                        "/admin route registered in a file with no "
                        "admin/role gate vocabulary")
                    route_noise["AUTHZ-ADMIN-NO-ROLE"] += 1
                elif is_mut and not is_admin and \
                        route_noise["AUTHZ-MUTATING-NO-AUTH"] < ROUTE_NOISE_CAP:
                    sev = "LOW" if auth_aware else "MEDIUM"
                    add(sev, "AUTHZ-MUTATING-NO-AUTH", i,
                        "mutating route in a file with %s auth vocabulary" %
                        ("some" if auth_aware else "zero"))
                    route_noise["AUTHZ-MUTATING-NO-AUTH"] += 1
                if SENSITIVE_PATH.search(path) and \
                        route_noise["AUTHZ-SENSITIVE-ROUTE"] < ROUTE_NOISE_CAP:
                    add("LOW", "AUTHZ-SENSITIVE-ROUTE", i,
                        "debug/internal route path mounted — often leaks "
                        "state or skips auth")
                    route_noise["AUTHZ-SENSITIVE-ROUTE"] += 1

    return findings


# --- driver ---------------------------------------------------------------
def find_targets(root):
    if os.path.isfile(root):
        return [root]
    out = []
    for dirpath, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for f in files:
            if os.path.splitext(f)[1].lower() in TARGET_EXT:
                out.append(os.path.join(dirpath, f))
    return sorted(out)


def render(findings, targets):
    out = ["# authz pre-scan — broken access control / BOLA signatures", ""]
    counts = {"HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0}
    for f in sorted(findings,
                    key=lambda x: (SEV_ORDER[x.sev], x.where, x.line)):
        counts[f.sev] += 1
        out.append("- **%s** `%s` — %s:%d — %s"
                   % (f.sev, f.code, f.where, f.line, f.msg))
    if not findings:
        out.append("No findings.")
    out += ["", "Files scanned: %d — %d HIGH / %d MEDIUM / %d LOW / %d INFO"
            % (len(targets), counts["HIGH"], counts["MEDIUM"],
               counts["LOW"], counts["INFO"]),
            "", "Pre-scan only — middleware in other files, framework "
            "guards, and runtime policy are invisible to it."]
    return "\n".join(out)


# --- selftest -------------------------------------------------------------
SELFTEST = [
    # --- AUTHZ-ROLE-FROM-REQ ---
    ("a.js", "user.role = req.body.role;\n", {"AUTHZ-ROLE-FROM-REQ"}),
    ("a.js", "const isAdmin = req.query.isAdmin;\n",
     {"AUTHZ-ROLE-FROM-REQ"}),
    ("a.py", "role = request.POST.get('role')\n", {"AUTHZ-ROLE-FROM-REQ"}),
    ("a.rb", "u.role = params[:role]\n", {"AUTHZ-ROLE-FROM-REQ"}),
    ("a.php", "$role = $_POST['role'];\n", {"AUTHZ-ROLE-FROM-REQ"}),
    ("a.java", "String role = request.getParameter(\"role\");\n",
     {"AUTHZ-ROLE-FROM-REQ"}),
    ("a.js", "if (req.body.role === req.user.role) ok();\n",
     {"AUTHZ-ROLE-FROM-REQ"}),   # compare-to-session = verify shape, MEDIUM
    ("a.js", "const role = req.user.role;\n", set()),
    ("a.js", "const role = 'admin';\n", set()),

    # --- AUTHZ-REQ-IDENTITY ---
    ("a.js", "const userId = req.body.userId;\n", {"AUTHZ-REQ-IDENTITY"}),
    ("a.js", "const tenant = req.query.tenant_id;\n",
     {"AUTHZ-REQ-IDENTITY"}),
    ("a.py", "account_id = request.args.get('account_id')\n",
     {"AUTHZ-REQ-IDENTITY"}),
    ("a.php", "$uid = $_GET['user_id'];\n", {"AUTHZ-REQ-IDENTITY"}),
    ("a.rb", "org_id = params[:org_id]\n", {"AUTHZ-REQ-IDENTITY"}),
    # flows to sink -> HIGH
    ("a.js", "const userId = req.body.userId;\n"
             "const u = await User.findById(userId);\n",
     {"AUTHZ-REQ-IDENTITY"}),
    # inline into sink -> HIGH
    ("a.js", "await User.findById(req.params.userId);\n",
     {"AUTHZ-REQ-IDENTITY"}),
    # compare vs session = the check itself -> silent
    ("a.js", "if (req.body.userId !== req.user.id) return 403;\n", set()),
    ("a.js", "assert(req.query.org === session.org);\n", set()),
    ("a.js", "const userId = req.user.id;\n", set()),
    ("a.js", "const {userId} = req.body;\n", {"AUTHZ-REQ-IDENTITY"}),

    # --- AUTHZ-OBJECT-NO-OWNER ---
    ("a.js", "const o = await Order.findById(req.params.id);\n",
     {"AUTHZ-OBJECT-NO-OWNER"}),
    ("a.js", "db.documents.findOne({_id: req.params.docId});\n",
     {"AUTHZ-OBJECT-NO-OWNER"}),
    ("a.rb", "order = Order.find(params[:id])\n",
     {"AUTHZ-OBJECT-NO-OWNER"}),
    ("a.py", "o = get_object_or_404(Order, pk=request.GET['id'])\n",
     {"AUTHZ-OBJECT-NO-OWNER"}),
    # object id through a variable
    ("a.js", "const oid = req.params.oid;\n"
             "await Order.findById(oid);\n",
     {"AUTHZ-OBJECT-NO-OWNER"}),
    # suppressed: owner predicate in args
    ("a.js",
     "await Order.findOne({id: req.params.id, owner: req.user.id});\n",
     set()),
    # suppressed: check within window
    ("a.js", "const o = await Order.findById(req.params.id);\n"
             "if (o.userId !== req.user.id) return 403;\n",
     set()),
    # MEDIUM not HIGH in auth-aware file
    ("a.js", "const auth = requireAuth;\n"
             "const o = await Order.findById(req.params.id);\n",
     {"AUTHZ-OBJECT-NO-OWNER"}),
    ("a.js", "const o = await Order.findById(orderId);\n", set()),

    # --- AUTHZ-ADMIN-NO-ROLE ---
    ("a.js", "router.delete('/admin/users/:id', del);\n",
     {"AUTHZ-ADMIN-NO-ROLE"}),
    ("a.js", "app.get('/admin/stats', stats);\n", {"AUTHZ-ADMIN-NO-ROLE"}),
    ("a.py", "@app.post('/admin/flush')\ndef f():\n    pass\n",
     {"AUTHZ-ADMIN-NO-ROLE"}),
    ("a.php", "Route::post('/admin/impersonate', 'C@i');\n",
     {"AUTHZ-ADMIN-NO-ROLE"}),
    # admin vocab in file -> silent
    ("a.js", "const ok = requireAdmin(req);\n"
             "router.delete('/admin/users/:id', del);\n", set()),
    # public paths never flag
    ("a.js", "app.post('/api/login', login);\n", set()),
    ("a.js", "router.post('/webhook/stripe', wh);\n", set()),

    # --- AUTHZ-MUTATING-NO-AUTH ---
    ("a.js", "app.post('/api/orders', create);\n",
     {"AUTHZ-MUTATING-NO-AUTH"}),
    ("a.js", "router.delete('/api/items/:id', rm);\n",
     {"AUTHZ-MUTATING-NO-AUTH"}),
    ("a.py", "@app.delete('/items/<i>')\ndef d(i):\n    pass\n",
     {"AUTHZ-MUTATING-NO-AUTH"}),
    ("a.java", "@DeleteMapping(\"/items/{id}\")\nvoid d(){}\n",
     {"AUTHZ-MUTATING-NO-AUTH"}),
    ("a.php", "Route::put('/items/{id}', 'C@u');\n",
     {"AUTHZ-MUTATING-NO-AUTH"}),
    ("a.go", "r.PUT(\"/items/:id\", h)\n", {"AUTHZ-MUTATING-NO-AUTH"}),
    # auth-aware file -> LOW
    ("a.js", "const g = requireAuth;\napp.post('/api/x', h);\n",
     {"AUTHZ-MUTATING-NO-AUTH"}),
    # read methods silent
    ("a.js", "app.get('/api/items', list);\n", set()),
    ("a.py", "@app.route('/items', methods=['GET'])\ndef g():\n    pass\n",
     set()),

    # --- AUTHZ-SENSITIVE-ROUTE ---
    ("a.js", "app.get('/debug/vars', dump);\n", {"AUTHZ-SENSITIVE-ROUTE"}),
    ("a.js", "router.get('/internal/heapdump', h);\n",
     {"AUTHZ-SENSITIVE-ROUTE"}),
    ("a.js", "app.get('/metrics', prom);\n", {"AUTHZ-SENSITIVE-ROUTE"}),
    ("a.js", "app.get('/health', ok);\n", set()),

    # --- interplay / FP guards ---
    ("test/a.js", "const userId = req.body.userId;\n",
     {"AUTHZ-REQ-IDENTITY"}),   # downgraded, still present
    ("a.js", "import { requireAuth } from './auth';\n", set()),
    ("a.js", "// app.post('/api/x', h);\n", set()),
    ("a.js", "/* router.delete('/a', h); */\n", set()),
    ("a.js", "const o = await Order.findById(order.id); // internal\n",
     set()),
    ("a.py", "uid = session['user_id']\n", set()),
    ("a.js", "const r = await repo.findById(req.user.id);\n", set()),
    ("a.js", "const q = req.query.q;\nitems.filter(x => x.name === q);\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), 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())
