#!/usr/bin/env python3
"""csrf_prescan.py — deterministic CSRF pre-scan.

ARION service artifact. Scans source and template files for the
signatures of cross-site-request-forgery exposure — explicitly disabled
framework protection, mutating verbs bound to GET routes, SameSite=None
cookies on state-changing surfaces, POST forms without a token field,
and route files that mutate state over cookie/session auth with no CSRF
marker anywhere in the file — line-wise, fully offline, no inference.

Findings (base severity):
  CSRF-PROTECTION-DISABLED  HIGH    explicit opt-out of the framework's
                                    CSRF defense — @csrf_exempt /
                                    csrf_exempt(...) incl. decorator-arg
                                    forms, @csrf.exempt / csrf.exempt(,
                                    @CsrfExempt, skip_before_action /
                                    skip_before_filter on
                                    verify_authenticity_token,
                                    skip_forgery_protection,
                                    .csrf().disable() / csrf(Customizer|
                                    AbstractHttpConfigurer)::disable /
                                    csrf(x -> x.disable()) /
                                    http.csrf...disable,
                                    WTF_CSRF_ENABLED falsy,
                                    [IgnoreAntiforgeryToken],
                                    allow_forgery_protection = false
  CSRF-EXCEPTION-LIST       MEDIUM  partial exemptions — csrf()
                                    ignoringAntMatchers /
                                    ignoringRequestMatchers, Laravel
                                    $except inside VerifyCsrfToken /
                                    Middleware csrf files,
                                    protect_from_forgery with
                                    only:/except:, non-empty
                                    CSRF_TRUSTED_ORIGINS
  CSRF-NULL-SESSION         MEDIUM  protect_from_forgery with:
                                    :null_session / :reset_session —
                                    forgery silently resets instead of
                                    raising; fine for pure APIs, weak
                                    for browser-facing controllers
  CSRF-MUTATING-GET         MEDIUM  GET route whose literal path carries
                                    a mutating verb — express/koa/
                                    fastify .get(, gin .GET(, Rails
                                    get 'path', Laravel Route::get(,
                                    Spring @GetMapping, Flask/FastAPI
                                    @x.route/@x.get, Django path() in a
                                    urls module — GET is followable by
                                    <img>/<a>/prefetch and carries no
                                    CSRF check anywhere
  CSRF-SAMESITE-NONE        MEDIUM  SameSite=None on a cookie — the
                                    cookie is sent on every cross-site
                                    request; needs an explicit token or
                                    Origin/Fetch-Site check behind it
  CSRF-TOKEN-ABSENT-FORM    MEDIUM  <form method=post|put|delete|patch>
                                    whose form block has no csrf/xsrf/
                                    authenticity_token/antiforgery
                                    marker — token never submitted
  CSRF-NO-TOKEN-CHECK       MEDIUM  file registers mutating handlers
                                    (post/put/delete/patch routes or
                                    methods=[POST...]) over cookie/
                                    session auth and has NO csrf/xsrf/
                                    antiforgery marker anywhere — the
                                    classic "routes + session cookies,
                                    token forgotten" shape. Bearer/
                                    Authorization markers downgrade to
                                    LOW (header-auth APIs don't need it)
  CSRF-TOKEN-IN-URL         LOW     csrf/authenticity token carried in a
                                    query string — leaks via logs,
                                    Referer, browser history

Context rules:
  * CSRF-PROTECTION-DISABLED is an explicit opt-out — never downgraded
    by nearby markers; it subsumes softer findings on the same line.
    Test/spec/fixture paths still tier it LOW.
  * a file-wide CSRF marker (csrf|xsrf|csurf|antiforg|authenticity_token|
    RequestVerificationToken|synchronizer|SameSite Lax|Strict) silences
    CSRF-NO-TOKEN-CHECK entirely — the file enforces the token itself —
    and downgrades CSRF-SAMESITE-NONE to LOW (a defended companion)
  * Bearer/Authorization/JWT verification markers downgrade
    CSRF-NO-TOKEN-CHECK to LOW — header-auth APIs are not cookie-bound
  * <form> blocks are scanned to </form> or a 15-line bound; a form
    whose action is an absolute http(s):// URL posts off-origin and
    tiers LOW (the token, if any, belongs to the target site)
  * route decorators listing only POST/PUT/DELETE/PATCH methods are
    silent for CSRF-MUTATING-GET; a methods list containing GET flags
  * Django path()/re_path() verb check runs only in files that import
    django.urls or are named urls.py
  * test/spec/fixture paths downgrade one tier
  * comment lines and block-comment bodies are skipped

Scope: deterministic pre-scan for human review — flags protection
opt-outs and token-absent mutation surfaces; no dataflow proof that a
route is actually reachable or that a global middleware is missing —
clean output != CSRF-safe.

  csrf_prescan.py <file-or-dir> [--json]
  csrf_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",
    ".html", ".htm", ".erb", ".ejs", ".hbs", ".handlebars",
    ".twig", ".jinja", ".jinja2", ".j2", ".njk", ".mustache",
    ".phtml", ".tpl", ".vue", ".svelte",
)

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*$)")

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

# --- explicit opt-outs -> CSRF-PROTECTION-DISABLED (HIGH) --------------
DISABLED_RX = re.compile(
    r"@csrf_exempt\b|csrf_exempt\s*\(|csrf_exempt\s*[,)]|"
    r"@csrf\.exempt|csrf\.exempt\s*\(|"
    r"@CsrfExempt|CsrfExempt\s*\(|"
    r"skip_before_action[^\n]*verify_authenticity_token|"
    r"skip_before_filter[^\n]*verify_authenticity_token|"
    r"skip_forgery_protection\b|"
    r"\.csrf\s*\(\s*\)\s*\.disable|"
    r"\bcsrf\s*\(\s*(?:AbstractHttpConfigurer|Customizer)::disable|"
    r"csrf\s*\([^)]*->\s*\w+\s*\.\s*disable|"
    r"http\s*\.\s*csrf\b[^;\n]*\.disable|"
    r"WTF_CSRF_ENABLED['\"]?\]?\s*[:=,]\s*(?:False|false|0)\b|"
    r"IgnoreAntiforgeryToken|"
    r"allow_forgery_protection\s*=\s*false",
    re.I)

# --- partial exemptions -> CSRF-EXCEPTION-LIST (MEDIUM) ----------------
EXCEPT_RX = re.compile(
    r"ignoringAntMatchers|ignoringRequestMatchers|"
    r"\.csrf\s*\([^)]*ignoring|"
    r"protect_from_forgery[^\n]*\b(?:only|except)\s*:|"
    r"CSRF_TRUSTED_ORIGINS\s*=\s*\[?[^\]\n]*['\"]")

# protect_from_forgery with a forgiving strategy (MEDIUM)
NULL_SESSION_RX = re.compile(
    r"protect_from_forgery[^\n]*with\s*:\s*:(?:null_session|"
    r"reset_session)")

# --- mutating verbs bound to GET routes -> CSRF-MUTATING-GET (MEDIUM) --
VERB = (
    "destroy|delete|remove|update|edit|create|transfer|pay|purchase|"
    "withdraw|deposit|refund|charge|ban|unban|suspend|approve|reject|"
    "publish|unpublish|archive|unarchive|reset|revoke|grant|mute|"
    "unmute|kick|install|uninstall|logout|signout|disable|enable|"
    "upgrade|downgrade|rename|truncate|purge|empty|drop|verify|confirm|"
    "activate|deactivate|accept|decline|invite|cancel"
)
VERB_RX = re.compile(
    r"(?:^|[/_.\-{}])(?:" + VERB + r")(?:[/_.\-{}]|$)", re.I)
# a GET route ending in a file extension serves a static asset, not a
# mutation — /install.sh, /update.json
ROUTE_FILE_EXT = re.compile(
    r"\.(?:sh|js|mjs|css|png|jpe?g|gif|svg|ico|txt|json|xml|html?|pdf|"
    r"zip|tar|gz|woff2?|map|webmanifest|md|rss|atom)$", re.I)

# each pattern captures the path literal in group 1
GET_ROUTE_RXES = [
    # express / koa / fastify / flask-style decorators with .get(
    re.compile(r"\.\s*get\s*\(\s*['\"`]([^'\"`]+)['\"`]"),
    # gin / echo Go routers use .GET(
    re.compile(r"\.\s*GET\s*\(\s*['\"`]([^'\"`]+)['\"`]"),
    # Rails routes.rb:  get 'photos/:id/destroy'
    re.compile(r"(?<![\w.])get\s+['\"]([^'\"]+)['\"]"),
    # Laravel: Route::get('/x', ...)
    re.compile(r"Route::get\s*\(\s*['\"]([^'\"]+)['\"]"),
    # Spring
    re.compile(r"@GetMapping\s*\(\s*(?:value\s*=\s*)?['\"]([^'\"]+)['\"]"),
    # Flask / FastAPI / Sanic style: @app.get('/x') or @bp.route('/x')
    re.compile(r"@\w+\.(?:get|route)\s*\(\s*['\"]([^'\"]+)['\"]"),
    # Django urls: path('x/delete/', v) — gated on a django.urls marker
    re.compile(r"\b(?:path|re_path|url)\s*\(\s*r?['\"]([^'\"]+)['\"]"),
]
METHODS_RX = re.compile(
    r"methods\s*=\s*[\[(]([^)\]]*)", re.I)
ONLY_NONGET = re.compile(r"['\"](POST|PUT|DELETE|PATCH|HEAD|OPTIONS)['\"]",
                         re.I)
DJANGO_URLS = re.compile(r"django\.urls|from\s+django\s+import\s+urls")

# --- SameSite=None cookie -> CSRF-SAMESITE-NONE (MEDIUM) ----------------
SAMESITE_NONE = re.compile(
    r"samesite['\"\s:=]+['\"]?none\b|"
    r"SameSite\s*=\s*None|"
    r"SameSiteMode\.None|CookieSameSiteMode\.None|"
    r"setSameSite\s*\(\s*['\"]?none",
    re.I)

# --- <form method=post> w/o token -> CSRF-TOKEN-ABSENT-FORM (MEDIUM) ---
FORM_OPEN = re.compile(r"<form\b", re.I)
FORM_CLOSE = re.compile(r"</form\s*>", re.I)
FORM_METHOD = re.compile(
    r"method\s*=\s*['\"`]?\s*(post|put|delete|patch)\b", re.I)
FORM_EXTERNAL = re.compile(r"action\s*=\s*['\"`]https?://", re.I)
# token markers accepted inside a form block
FORM_TOKEN = re.compile(
    r"csrf|xsrf|authenticity_token|RequestVerificationToken|antiforg|"
    r"csrfmiddlewaretoken|CSRFTOKEN",
    re.I)

# --- routes + cookie auth, no CSRF marker -> CSRF-NO-TOKEN-CHECK -------
MUTATING_ROUTE = re.compile(
    r"\.\s*(?:post|put|delete|patch)\s*\(|"
    r"\.\s*(?:POST|PUT|DELETE|PATCH)\s*\(|"
    r"@\w+\.(?:post|put|delete|patch)\s*\(|"
    r"methods\s*=\s*[\[(]['\"]?\s*POST|"
    r"@(?:Post|Put|Delete|Patch)Mapping|"
    r"Route::(?:post|put|delete|patch)\s*\(|"
    r"def\s+(?:post|put|delete|patch)\s*\(|"
    r"\b(?:post|put|delete|patch)\s*\(\s*['\"`]/")
COOKIE_AUTH = re.compile(
    r"cookieParser|express-session|cookie-session|req\.session|"
    r"request\.session|request\.cookies|Set-Cookie|"
    r"flask\.session|HttpSession|session\s*\(|cookies\s*\[|"
    r"res\.cookie|response\.set_cookie|ctx\.cookies|c\.Cookie|"
    r"Response\.Cookies|Request\.Cookies")
BEARER_AUTH = re.compile(
    r"headers\[['\"]authorization|req\.headers\.authorization|"
    r"Authorization['\"]?\s*[:=][^\n]*Bearer|Bearer\s+|"
    r"bearerAuth|jwt\.verify|authenticateToken|"
    r"HttpAuthorization|getAuthorizationHeader|X-Api-Key|"
    r"api[-_]?key['\"]?\s*[:=]",
    re.I)
FILE_CSRF_MARKER = re.compile(
    r"csrf|xsrf|csurf|antiforg|authenticity_token|"
    r"RequestVerificationToken|synchronizer|"
    r"samesite['\"\s:=]+['\"]?(?:lax|strict)\b",
    re.I)

# --- token carried in the URL -> CSRF-TOKEN-IN-URL (LOW) ----------------
TOKEN_IN_URL = re.compile(
    r"[?&](?:_?csrf|csrf_token|authenticity_token|xsrf_token|"
    r"__RequestVerificationToken)[\w]*=",
    re.I)


def _strip_line_comment(line, ext):
    s = line.strip()
    if not s:
        return ""
    if s.startswith("//") or s.startswith("<!--") or s.startswith("*"):
        return None
    if s.startswith("#") and ext in (".py", ".rb", ".jinja", ".jinja2",
                                     ".j2", ".njk", ".twig"):
        return None
    if s.startswith("{#") or s.startswith("{%") and "comment" in s:
        return None
    for marker in ("//", "<!--"):
        i = s.find(marker)
        if i > 0 and s[i - 1] in " \t)]":
            s = s[:i]
            break
    return s


def _is_test_path(rel):
    return bool(TEST_PATH.search(rel.replace(os.sep, "/")))


def _ext_of(path):
    base = os.path.basename(path).lower()
    for e in (".blade.php", ".jinja2", ".handlebars"):
        if base.endswith(e):
            return e
    return os.path.splitext(path)[1].lower()


def scan_file(path, rel):
    ext = _ext_of(path)
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
    except OSError:
        return []
    findings = []
    seen = set()
    in_block = False
    codes = []
    for i, raw in enumerate(lines):
        line = raw.rstrip("\n")
        j = 0
        out = []
        while j < len(line):
            if in_block:
                for closer in ("*/", "-->"):
                    k = line.find(closer, j)
                    if k >= 0:
                        j = k + len(closer)
                        in_block = False
                        break
                else:
                    j = len(line)
                continue
            ko = -1
            for opener in ("/*", "<!--"):
                k = line.find(opener, j)
                if k >= 0 and (ko < 0 or k < ko):
                    ko = k
            if ko < 0:
                out.append(line[j:])
                break
            out.append(line[j:ko])
            j = ko
            in_block = True
            for closer in ("*/", "-->"):
                k = line.find(closer, j + 2)
                if k >= 0:
                    out.append(" ")
                    j = k + len(closer)
                    in_block = False
                    break
            else:
                j = len(line)
        code = "".join(out)
        code = _strip_line_comment(code, ext)
        codes.append(code or "")

    stripped = "\n".join(codes)
    base = os.path.basename(rel).lower()
    is_django_urls = bool(DJANGO_URLS.search(stripped)) or base == "urls.py"
    is_csrf_mw = "csrf" in base  # VerifyCsrfToken.php, csrf middleware
    file_marker = bool(FILE_CSRF_MARKER.search(stripped))
    bearer = bool(BEARER_AUTH.search(stripped))
    cookie = bool(COOKIE_AUTH.search(stripped))

    def add(cls, sev, i, note):
        if (cls, i + 1) in seen:
            return
        if cls != "CSRF-PROTECTION-DISABLED" and \
                ("CSRF-PROTECTION-DISABLED", i + 1) in seen:
            return
        seen.add((cls, i + 1))
        if _is_test_path(rel):
            sev = "LOW"
        findings.append(Finding(cls, sev, rel, i + 1, lines[i], note))

    # ---- per-line classes ----
    for i, code in enumerate(codes):
        if not code.strip():
            continue
        is_import = bool(IMPORT_LINE.search(code))

        if DISABLED_RX.search(code) and not is_import:
            add("CSRF-PROTECTION-DISABLED", "HIGH", i,
                "explicit CSRF-protection opt-out — cross-site POSTs "
                "reach this handler with only the ambient cookie")

        if EXCEPT_RX.search(code):
            add("CSRF-EXCEPTION-LIST", "MEDIUM", i,
                "partial CSRF exemption — listed routes/origins skip "
                "the token check")

        if is_csrf_mw and re.search(r"\$except\s*=", code):
            add("CSRF-EXCEPTION-LIST", "MEDIUM", i,
                "VerifyCsrfToken $except — listed URIs skip token "
                "verification")

        if NULL_SESSION_RX.search(code):
            add("CSRF-NULL-SESSION", "MEDIUM", i,
                "protect_from_forgery with :null_session/:reset_session "
                "— forgery fails soft instead of raising")

        if SAMESITE_NONE.search(code):
            sev = "LOW" if file_marker else "MEDIUM"
            add("CSRF-SAMESITE-NONE", sev, i,
                "SameSite=None — cookie rides every cross-site request; "
                "needs a token or Origin/Sec-Fetch-Site check")

        if TOKEN_IN_URL.search(code):
            add("CSRF-TOKEN-IN-URL", "LOW", i,
                "csrf/authenticity token in a query string — leaks via "
                "logs, Referer and history")

        # mutating-verb GET routes
        for rx in GET_ROUTE_RXES:
            m = rx.search(code)
            if not m:
                continue
            if rx is GET_ROUTE_RXES[6] and not is_django_urls:
                continue
            path_lit = m.group(1)
            if ROUTE_FILE_EXT.search(path_lit):
                continue
            if not VERB_RX.search("/" + path_lit):
                continue
            # flask/fastapi @x.route(..., methods=[...]) without GET is
            # not followable cross-site
            mm = METHODS_RX.search(code)
            if mm and "GET" not in mm.group(1).upper() and \
                    ONLY_NONGET.search(mm.group(1)):
                continue
            add("CSRF-MUTATING-GET", "MEDIUM", i,
                "mutating verb bound to a GET route — reachable via "
                "<img>/<a>/prefetch with no CSRF check")

    # ---- <form> blocks ----
    i = 0
    while i < len(codes):
        if not FORM_OPEN.search(codes[i]):
            i += 1
            continue
        # collect the tag (up to '>' or 6 lines)
        tag = []
        k = i
        while k < len(codes) and k < i + 6:
            tag.append(codes[k])
            if ">" in codes[k]:
                break
            k += 1
        tag_text = " ".join(tag)
        if not FORM_METHOD.search(tag_text):
            i += 1   # method absent => GET form; not a CSRF surface
            continue
        # scan the form body for a token marker
        end = min(len(codes), i + 15)
        body = "\n".join(codes[i:end])
        for j in range(i, end):
            if FORM_CLOSE.search(codes[j]):
                end = j + 1
                break
        body = "\n".join(codes[i:end])
        if not FORM_TOKEN.search(body):
            sev = "LOW" if FORM_EXTERNAL.search(tag_text) else "MEDIUM"
            add("CSRF-TOKEN-ABSENT-FORM", sev, i,
                "POST/PUT/DELETE/PATCH form with no csrf/xsrf/"
                "authenticity-token field in its block")
        i = end

    # ---- file-level: mutating routes + cookie auth, no CSRF marker ----
    if not file_marker and cookie:
        for i, code in enumerate(codes):
            if MUTATING_ROUTE.search(code):
                sev = "LOW" if bearer else "MEDIUM"
                add("CSRF-NO-TOKEN-CHECK", sev, i,
                    "mutating handlers over cookie/session auth and no "
                    "csrf/xsrf/antiforgery marker anywhere in the file")
                break

    return findings


class Finding:
    def __init__(self, code, sev, path, line_no, line, note):
        self.code = code
        self.sev = sev
        self.path = path
        self.line_no = line_no
        self.line = line.strip()[:200]
        self.note = note

    def as_dict(self):
        return {"code": self.code, "severity": self.sev,
                "file": self.path, "line": self.line_no,
                "text": self.line, "note": self.note}


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 fn in files:
            if _ext_of(fn) in TARGET_EXT:
                out.append(os.path.join(dirpath, fn))
    return sorted(out)


def render(findings, targets):
    findings.sort(key=lambda f: (SEV_ORDER[f.sev], f.path, f.line_no))
    lines = ["csrf_prescan report — %d file(s), %d finding(s)"
             % (len(targets), len(findings)), ""]
    for f in findings:
        lines.append("%-6s %-26s %s:%d  %s"
                     % (f.sev, f.code, f.path, f.line_no, f.note))
        lines.append("       | %s" % f.line)
    if not findings:
        lines.append("no HIGH/MEDIUM/LOW findings")
    lines.append("")
    lines.append("scope: deterministic pre-scan — flags protection "
                 "opt-outs and token-absent mutation surfaces; not an "
                 "audit, clean != CSRF-safe")
    return "\n".join(lines)


SELFTEST = [
    # --- CSRF-PROTECTION-DISABLED (HIGH) ---
    ("a.py", "@csrf_exempt\ndef view(r): pass\n",
     {"CSRF-PROTECTION-DISABLED"}),
    ("a.py", "from django.views.decorators.csrf import csrf_exempt\n",
     set()),  # import alone is not an opt-out
    ("a.py", "dispatch = method_decorator(csrf_exempt, name='dispatch')\n",
     {"CSRF-PROTECTION-DISABLED"}),
    ("a.rb", "skip_before_action :verify_authenticity_token\n",
     {"CSRF-PROTECTION-DISABLED"}),
    ("a.rb", "skip_before_filter :verify_authenticity_token, "
             "only: [:webhook]\n", {"CSRF-PROTECTION-DISABLED"}),
    ("a.rb", "skip_forgery_protection\n", {"CSRF-PROTECTION-DISABLED"}),
    ("a.java", "http.csrf().disable();\n", {"CSRF-PROTECTION-DISABLED"}),
    ("a.java", ".csrf(AbstractHttpConfigurer::disable)\n",
     {"CSRF-PROTECTION-DISABLED"}),
    ("a.java", "http.csrf(csrf -> csrf.disable());\n",
     {"CSRF-PROTECTION-DISABLED"}),
    ("a.py", "WTF_CSRF_ENABLED = False\n", {"CSRF-PROTECTION-DISABLED"}),
    ("a.py", "app.config['WTF_CSRF_ENABLED'] = False\n",
     {"CSRF-PROTECTION-DISABLED"}),
    ("a.py", "csrf.exempt(blueprint)\n", {"CSRF-PROTECTION-DISABLED"}),
    ("a.cs", "[IgnoreAntiforgeryToken]\npublic IActionResult Webhook()\n",
     {"CSRF-PROTECTION-DISABLED"}),
    ("a.rb", "config.action_controller.allow_forgery_protection = false\n",
     {"CSRF-PROTECTION-DISABLED"}),
    ("a.py", "# @csrf_exempt\n", set()),          # comment stripped
    ("a.js", "// http.csrf().disable()\n", set()),  # comment stripped
    # --- CSRF-EXCEPTION-LIST (MEDIUM) ---
    ("a.java", ".csrf().ignoringAntMatchers(\"/webhook/**\")\n",
     {"CSRF-EXCEPTION-LIST"}),
    ("a.java", "csrf.ignoringRequestMatchers(\"/api/hook\")\n",
     {"CSRF-EXCEPTION-LIST"}),
    ("VerifyCsrfToken.php", "protected $except = ['stripe/*'];\n",
     {"CSRF-EXCEPTION-LIST"}),
    ("a.py", "CSRF_TRUSTED_ORIGINS = ['https://app.example.com']\n",
     {"CSRF-EXCEPTION-LIST"}),
    ("a.rb", "protect_from_forgery except: :webhook\n",
     {"CSRF-EXCEPTION-LIST"}),
    ("other.php", "$except = ['x'];\n", set()),   # not a csrf middleware
    # --- CSRF-NULL-SESSION (MEDIUM) ---
    ("a.rb", "protect_from_forgery with: :null_session\n",
     {"CSRF-NULL-SESSION"}),
    ("a.rb", "protect_from_forgery with: :reset_session\n",
     {"CSRF-NULL-SESSION"}),
    ("a.rb", "protect_from_forgery with: :exception\n", set()),
    # --- CSRF-MUTATING-GET (MEDIUM) ---
    ("a.js", "app.get('/users/delete/:id', h);\n", {"CSRF-MUTATING-GET"}),
    ("a.js", "router.get('/transfer', h);\n", {"CSRF-MUTATING-GET"}),
    ("a.go", "r.GET(\"/admin/ban/:id\", h)\n", {"CSRF-MUTATING-GET"}),
    ("routes.rb", "get 'posts/:id/destroy'\n", {"CSRF-MUTATING-GET"}),
    ("routes.php", "Route::get('/post/{id}/delete', [C::class, 'del']);\n",
     {"CSRF-MUTATING-GET"}),
    ("a.java", "@GetMapping(\"/items/approve\")\n", {"CSRF-MUTATING-GET"}),
    ("a.py", "@app.route('/account/reset', methods=['GET'])\n",
     {"CSRF-MUTATING-GET"}),
    ("a.py", "@app.route('/pay', methods=['POST'])\n", set()),
    ("a.py", "@app.route('/pay', methods=['GET', 'POST'])\n",
     {"CSRF-MUTATING-GET"}),
    ("a.js", "app.get('/users/list', h);\n", set()),   # read verb
    ("a.js", "app.get('/users/update-log', h);\n", {"CSRF-MUTATING-GET"}),
    ("a.js", "routes.get('/install.sh', h);\n", set()),  # static asset
    ("a.js", "pages.get('/post-a-job', h);\n", set()),  # noun, not verb
    ("a.js", "pages.get('/post', h);\n", set()),
    ("a.js", "app.post('/users/delete/:id', h);\n", set()),
    ("urls.py", "from django.urls import path\n"
                "path('item/delete/<int:id>', v)\n", {"CSRF-MUTATING-GET"}),
    ("a.py", "path('item/delete/', v)\n", set()),  # no django marker
    # --- CSRF-SAMESITE-NONE ---
    ("a.py", "resp.set_cookie('sid', v, samesite='None', secure=True)\n",
     {"CSRF-SAMESITE-NONE"}),
    ("a.js", "res.cookie('sid', v, {sameSite: 'none', secure: true});\n",
     {"CSRF-SAMESITE-NONE"}),
    ("a.cs", "o.SameSite = SameSiteMode.None;\n", {"CSRF-SAMESITE-NONE"}),
    ("a.js", "res.cookie('sid', v, {sameSite: 'lax'});\n", set()),
    # csrf marker present -> LOW not MEDIUM
    ("a.js", "const csrf = require('csurf');\n"
             "app.get('/x', csrf(), h);\n"
             "res.cookie('sid', v, {sameSite: 'none'});\n",
     {"CSRF-SAMESITE-NONE"}),
    # --- CSRF-TOKEN-ABSENT-FORM ---
    ("a.html", "<form method=\"post\" action=\"/pay\">\n"
               "<input name=\"amt\">\n</form>\n",
     {"CSRF-TOKEN-ABSENT-FORM"}),
    ("a.html", "<form method=\"post\">\n{% csrf_token %}\n"
               "<input name=\"x\">\n</form>\n", set()),
    ("a.erb", "<form method=\"POST\" action=\"/x\">\n"
              "<input name=\"authenticity_token\">\n</form>\n", set()),
    ("a.html", "<form action=\"/subscribe\">\n<input name=\"e\">\n"
               "</form>\n", set()),  # no method => GET
    ("a.html", "<form method=\"post\"\n      action=\"/pay\">\n"
               "<input name=\"x\">\n</form>\n", {"CSRF-TOKEN-ABSENT-FORM"}),
    ("a.html", "<form method=\"post\" action=\"https://ext.example/x\">\n"
               "<input name=\"x\">\n</form>\n",
     {"CSRF-TOKEN-ABSENT-FORM"}),  # external action -> LOW
    ("a.blade.php", "<form method=\"POST\" action=\"/x\">\n@csrf\n"
                    "</form>\n", set()),
    ("a.html", "<!-- <form method=\"post\" action=\"/x\">\n"
               "<input name=\"y\">\n</form> -->\n", set()),
    # --- CSRF-NO-TOKEN-CHECK ---
    ("a.js", "const cookieParser = require('cookie-parser');\n"
             "app.use(cookieParser());\n"
             "app.post('/api/save', h);\n", {"CSRF-NO-TOKEN-CHECK"}),
    ("a.js", "const csrf = require('csurf');\n"
             "app.use(cookieParser());\napp.use(csrf());\n"
             "app.post('/api/save', h);\n", set()),  # marker present
    ("a.js", "app.post('/api/save', h);\n", set()),   # no cookie auth
    ("a.js", "const jwt = require('jsonwebtoken');\n"
             "const t = req.headers.authorization;\n"
             "app.use(cookieParser());\n"
             "app.post('/api/save', h);\n",
     {"CSRF-NO-TOKEN-CHECK"}),   # bearer -> LOW
    ("a.py", "def post(self, request):\n"
             "    request.session['x'] = 1\n", {"CSRF-NO-TOKEN-CHECK"}),
    # --- CSRF-TOKEN-IN-URL (LOW) ---
    ("a.js", "fetch('/x?_csrf=' + tok);\n", {"CSRF-TOKEN-IN-URL"}),
    ("a.py", "u = '/post?authenticity_token=' + t\n",
     {"CSRF-TOKEN-IN-URL"}),
    # --- interplay / paths ---
    ("test/a.js", "app.use(cookieParser());\napp.post('/x', h);\n",
     {"CSRF-NO-TOKEN-CHECK"}),   # test path -> LOW
    ("test/a.py", "@csrf_exempt\ndef v(r): pass\n",
     {"CSRF-PROTECTION-DISABLED"}),  # test path -> LOW
    # disabled subsumes same-line softer findings
    ("a.py", "@csrf_exempt  # method=['POST']\n", {"CSRF-PROTECTION-DISABLED"}),
    # clean file
    ("a.js", "app.get('/status', h);\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([f.as_dict() for f in findings], 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())
