#!/usr/bin/env python3
"""redirect_prescan.py — deterministic open-redirect / response-header
injection pre-scan.

ARION service artifact. Scans source files for the signatures of
response-manipulation bugs — user-influenced redirect targets, header
names/values, host-header trust and DOM-based redirects — line-wise,
fully offline, no inference.

Findings (base severity):
  OR-REQ-URL        HIGH    request-controlled value inside the URL/target
                            argument of a redirect sink — res.redirect /
                            response.redirect / ctx.redirect / reply.
                            redirect / c.Redirect (arg-after-status) /
                            django+flask redirect() / HttpResponseRedirect /
                            RedirectResponse (url= kwarg) / redirect_to /
                            sendRedirect / RedirectView / spring
                            "redirect:"+concat / Response.Redirect /
                            RedirectResult / http.Redirect(w,r,url,code) /
                            header("Location: ...) / writeHead(3xx,
                            {Location:...}) / wp_redirect
  OR-NONLIT-URL     MEDIUM  redirect target is non-literal / interpolated
                            without a visible request marker
  HDR-REQ-HEADER    HIGH    request-controlled value as a response-header
                            NAME or VALUE — res.setHeader/res.set/res.
                            header/response.setHeader/w.Header().Set/
                            c.Header/ctx.set/set_header/add_header/
                            response.headers[..]/Response.AddHeader/
                            Headers.Add/header(...) — CRLF response-
                            splitting + arbitrary header forgery
  HOST-HEADER-TRUST MEDIUM  Host / X-Forwarded-Host value used inside a
                            string-built URL/link/redirect (password-
                            reset poisoning, canonical-URL forgery)
  DOM-OPEN-REDIRECT HIGH    client-side location assign / assign() /
                            replace() / window.open() fed from
                            location.search|location.hash|
                            URLSearchParams/searchParams.get(...)

Context rules:
  * safe variants stay silent — wp_safe_redirect, LocalRedirect,
    RedirectToAction/Route (internal named routes), literal targets
  * guard markers on the same line (allowlist/whitelist/IsLocalUrl/
    isInternal/sanitize/validat/encodeURI/startsWith('/')/origin|hostname
    equality checks) downgrade one tier -> LOW for human review, not
    suppression
  * test/spec/fixture paths downgrade one tier
  * comment lines and block-comment bodies are skipped
  * bare redirect( is only scanned when the file imports django/flask
    redirect or next/navigation; bare header( response-header checks are
    PHP/general; .redirect( is gated on res|response|ctx|reply|resp
    receivers; http.Redirect and c.Redirect arg positions are
    signature-aware
  * reading the Host header alone is silent — it only flags when the
    same line builds a string/URL (concat/interp/url|link|redirect
    context)

Scope: deterministic pre-scan for human review — flags patterns;
no taint/reachability proof, clean output != redirect-safe.

  redirect_prescan.py <file-or-dir> [--json]
  redirect_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",
)

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

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

GUARD = re.compile(
    r"allowlist|whitelist|IsLocalUrl|isInternal|isLocalUrl|"
    r"sanitiz|validat|encodeURI|escape|"
    r"startsWith\s*\(\s*['\"]/|\.origin\s*[=!]=|\.hostname\s*[=!]=|"
    r"wp_safe_redirect|LocalRedirect|safe_join|basename",
    re.I)

REQ_SRC = re.compile(
    r"\b(?:req|request|ctx|context)\s*\.\s*(?:query|params?|body|"
    r"headers?|args|form|GET|POST|files|values|json|input|data|"
    r"query_params|path_params|cookies|url|GET\.|POST\.)|"
    r"\bparams\s*\[|"
    r"\$_(?:GET|POST|REQUEST|COOKIE|FILES|SERVER)|"
    r"\bgetParameter\s*\(|\bgetQueryString|@RequestParam|@PathVariable|"
    r"\bc\s*\.\s*(?:Param|Query|QueryParam|FormValue|PathParam|URLParam)|"
    r"\bRequest\s*\.\s*(?:Query|Form|Params|Headers|Get)|"
    r"\bInput::get|\$request->|request\s*\(\s*\)\s*->|"
    r"\bsys\.argv|\bARGV\b|process\.argv|"
    r"\bevent\s*\.\s*(?:body|queryStringParameters|pathParameters)|"
    r"\bself\.get_argument|\bweb\.input|"
    r"\b(?:req|request|ctx)\s*\[\s*['\"]|"
    r"\breq\s*\.\s*nextUrl|\.URL\s*\.\s*Query\s*\(|"
    r"\br\s*\.\s*(?:FormValue|PostFormValue|Form\s*\.\s*Get|"
    r"MultipartForm)|"
    r"\bRequest\s*\.\s*URL\s*\.\s*Query",
    re.I)

INTERP_LIT = re.compile(
    r"[fFbB]['\"][^'\"]*(?<!\{)\{(?!\{)[^}]*\}[^'\"]*['\"]|"
    r"`[^`]*\$\{[^`]*`|\"[^\"]*[#$@]\{[^\"]*\"|\"[^\"]*\$[\w{]")

# safe redirect variants — matched before the dangerous sinks
SAFE_REDIRECT = re.compile(
    r"wp_safe_redirect|LocalRedirect|RedirectToAction|RedirectToRoute|"
    r"RedirectToPage")

# redirect sinks: (regex, arg-mode)
#   'first'  -> args[0] is the URL
#   'last'   -> last arg is the URL (res.redirect([status,] url),
#               c.Redirect(code,url), res.location(url) style)
#   'arg2'   -> http.Redirect(w, r, url, code)
REDIRECT_SINKS = [
    # js/ts
    (re.compile(r"\b(?:res|response|ctx|reply|resp)\s*\.\s*redirect\s*\("),
     "last"),
    (re.compile(r"\b(?:res|response)\s*\.\s*location\s*\("), "last"),
    (re.compile(r"\bNextResponse\s*\.\s*redirect\s*\("), "first"),
    (re.compile(r"\bpermanentRedirect\s*\("), "first"),
    (re.compile(r"\bc\s*\.\s*Redirect\s*\("), "last"),
    (re.compile(r"\bResponse\s*\.\s*Redirect\s*\("), "first"),
    (re.compile(r"\bResponse\s*\.\s*RedirectPermanent\s*\("), "first"),
    (re.compile(r"\bRedirectPermanent\s*\("), "first"),
    (re.compile(r"\bRedirectResult\s*\("), "first"),
    # python — gated variants handled separately
    (re.compile(r"\bHttpResponse(?:Permanent)?Redirect\s*\("), "first"),
    (re.compile(r"\bRedirectResponse\s*\("), "first"),
    (re.compile(r"\bself\s*\.\s*redirect\s*\("), "first"),
    # ruby / php
    (re.compile(r"\bredirect_to\s*\("), "first"),
    (re.compile(r"\bredirect_to\s+(?=[^\s(])"), "rest"),
    (re.compile(r"\bredirect\s*\(\s*\)\s*->\s*"
                r"(?:to|away|guest|intended)\s*\("), "first"),
    (re.compile(r"\bwp_redirect\s*\("), "first"),
    (re.compile(r"->\s*redirect\s*\("), "first"),
    (re.compile(r"\bRedirect::to\s*\("), "first"),
    (re.compile(r"\bRedirectResponseFoundation\s*\("), "first"),
    (re.compile(r"\bnew\s+RedirectResponse\s*\("), "first"),
    # java / spring
    (re.compile(r"\bsendRedirect\s*\("), "first"),
    (re.compile(r"\bRedirectView\s*\("), "first"),
    (re.compile(r"\bnew\s+ModelAndView\s*\("), "first"),
    # go
    (re.compile(r"\bhttp\s*\.\s*Redirect\s*\("), "arg2"),
    (re.compile(r"\bc\s*\.\s*Redirect\s*\("), "last"),
]

# spring-style "redirect:" string view — the concat tail carries the URL
SPRING_REDIRECT = re.compile(
    r"['\"]redirect:\s*['\"]?\s*(?:\+|\.\s*concat|\$\{)|"
    r"['\"]redirect:[^'\"]*\$\{[^'\"]*['\"]|"
    r"\+[^;]*['\"]redirect:")

# php header("Location: X") — X is everything after the literal prefix
PHP_LOCATION = re.compile(r"\bheader\s*\(\s*['\"]Location\s*:")

# res.writeHead(3xx, { 'Location': X }) / res.setHeader('Location', X)
WRITEHEAD_3XX = re.compile(
    r"\bwriteHead\s*\(\s*30[12378]|"
    r"\b(?:res|response)\s*\.\s*(?:setHeader|header|set|append)\s*"
    r"\(\s*['\"]Location['\"]")

# response-header sinks: (regex, name-arg?, value-arg)
#   'name_first' -> args[0] is header name, args[1] is value
HEADER_SINKS = [
    (re.compile(r"\b(?:res|response)\s*\.\s*setHeader\s*\("), "nv"),
    (re.compile(r"\b(?:res|response)\s*\.\s*set\s*\("), "nv"),
    (re.compile(r"\b(?:res|response)\s*\.\s*header\s*\("), "nv"),
    (re.compile(r"\b(?:res|response)\s*\.\s*append\s*\("), "nv"),
    (re.compile(r"\bresponse\s*\.\s*headers\s*\["), "bracket"),
    (re.compile(r"\bresp\s*\.\s*headers\s*\["), "bracket"),
    (re.compile(r"\bResponse\s*\.\s*AddHeader\s*\("), "nv"),
    (re.compile(r"\bResponse\s*\.\s*Headers\s*\.\s*Add\s*\("), "nv"),
    (re.compile(r"\bResponse\s*\.\s*Headers\s*\["), "bracket"),
    (re.compile(r"\bHeaders\s*\.\s*Add\s*\("), "nv"),
    (re.compile(r"\bw\s*\.\s*Header\s*\(\s*\)\s*\.\s*Set\s*\("), "nv"),
    (re.compile(r"\bw\s*\.\s*Header\s*\(\s*\)\s*\.\s*Add\s*\("), "nv"),
    (re.compile(r"\bc\s*\.\s*Header\s*\("), "nv"),
    (re.compile(r"\bctx\s*\.\s*[sS]et\s*\("), "nv"),
    (re.compile(r"\bself\s*\.\s*set_header\s*\("), "nv"),
    (re.compile(r"\badd_header\s*\("), "nv"),
    (re.compile(r"\bset_header\s*\("), "nv"),
    (re.compile(r"->\s*header\s*\("), "nv"),
    (re.compile(r"->\s*headers\s*\("), "nv"),
    (re.compile(r"\bheaders\s*\[\s*['\"]?"), "bracket"),
]

# whole-call request-controlled header( — php style
PHP_HEADER_WHOLE = re.compile(r"\bheader\s*\(")

# host-header value markers
HOST_MARKER = re.compile(
    r"req\s*\.\s*headers?\s*(?:\.\s*host|\[\s*['\"](?:x-forwarded-)?host)|"
    r"request\s*\.\s*headers?\s*(?:\.\s*host|\[\s*['\"](?:x-forwarded-)?host|"
    r"\.\s*get\s*\(\s*['\"](?:x-forwarded-)?host['\"])|"
    r"HTTP_HOST|X_FORWARDED_HOST|HTTP_X_FORWARDED_HOST|"
    r"\bRequest\s*\.\s*Host\b|\br\s*\.\s*Host\b|"
    r"\bctx\s*\.\s*host\b|\bc\s*\.\s*Request\s*\.\s*Host|"
    r"\brequest\s*\.\s*host\b|getServerName\s*\(|"
    r"\.\s*getHeader\s*\(\s*['\"](?:x-forwarded-)?host|"
    r"\$_SERVER\s*\[\s*['\"]HTTP_(?:X_FORWARDED_)?HOST",
    re.I)

# same-line string/URL construction context for host-trust
HOST_BUILD = re.compile(
    r"['\"][^'\"]*['\"]\s*\+|\+\s*['\"]|f['\"][^'\"]*\{|"
    r"`[^`]*\$\{|\$\{[^}]*\}|sprintf|format\s*\(|concat\s*\(|"
    r"\burl\b|\bURL\b|\blink\b|redirect|location|origin|"
    r"base_url|baseUrl|absolute|canonical|reset",
    re.I)

# DOM open-redirect sinks and their client-side sources
DOM_SINK = re.compile(
    r"(?:window\s*\.\s*)?location\s*(?:\.\s*href|\.\s*pathname|"
    r"\.\s*search)?\s*=[^=]|"
    r"location\s*\.\s*(?:assign|replace)\s*\(|"
    r"window\s*\.\s*open\s*\(")

DOM_SRC = re.compile(
    r"location\s*\.\s*search|location\s*\.\s*hash|"
    r"URLSearchParams|searchParams\s*\.\s*get\s*\(|"
    r"document\s*\.\s*(?:URL|location)|"
    r"\.get\s*\(\s*['\"](?:next|url|redirect|return|ret|target|"
    r"continue|to|dest(?:ination)?|goto|redir|callback)['\"]")

# kwarg URL args — url=/location=/next= inside a redirect call
URL_KWARG = re.compile(r"(?:^|[\s,(])(?:url|location|next|target|to)\s*=")

REDIRECT_IMPORT = re.compile(
    r"from\s+django|import\s+django|from\s+flask|import\s+flask|"
    r"next/navigation|from\s+['\"]next|require\s*\(\s*['\"]next")

BARE_REDIRECT = re.compile(r"(?<![\w.:>])[Rr]edirect\s*\(")


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"):
        return None
    if s.startswith("{#"):
        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 _strip_literals(e):
    e = re.sub(r"'[^'\"]*'", "", e)
    e = re.sub(r'"(?:[^"\\`]|\\.)*"',
               lambda m: m.group(0)
               if re.search(r"[$#@]|(?<!\{)\{(?!\{)", m.group(0)) else "",
               e)
    e = re.sub(r"`[^`]*`",
               lambda m: m.group(0) if "$" in m.group(0) else "", e)
    return e


def _nonliteral(expr):
    if INTERP_LIT.search(expr):
        return True
    e = _strip_literals(expr).strip(" \t,;)]}'\"`")
    if not e:
        return False
    return bool(re.search(r"[A-Za-z_$][\w$.\[\]]*|\+|%|\$\(|#\{", e))


def _split_args(text, open_idx):
    args, depth, i, cur, q = [], 0, open_idx + 1, [], None
    while i < len(text):
        c = text[i]
        if q:
            cur.append(c)
            if c == "\\" and i + 1 < len(text):
                i += 1
                cur.append(text[i])
            elif c == q:
                q = None
        else:
            if c in "'\"`":
                q = c
            elif c in "([{":
                depth += 1
            elif c in ")]}":
                if depth == 0 and c == ")":
                    args.append("".join(cur))
                    return args
                depth = max(0, depth - 1)
            if not q and c == "," and depth == 0:
                args.append("".join(cur))
                cur = []
                i += 1
                continue
            if not (not q and c == "," and depth == 0):
                cur.append(c)
        i += 1
    if cur or not args:
        args.append("".join(cur))
    return args


def _call_window(lines, start_i, code):
    depth = code.count("(") - code.count(")")
    window = code
    j = start_i
    while depth > 0 and j + 1 < len(lines) and j + 1 - start_i <= 4:
        j += 1
        nxt = lines[j].rstrip("\n").strip()
        window += " " + nxt
        depth += nxt.count("(") - nxt.count(")")
    return window


def _pick_url_arg(args, mode):
    if not args:
        return ""
    if mode == "first":
        return args[0]
    if mode == "last":
        return args[-1]
    if mode == "arg2":
        return args[2] if len(args) > 2 else ""
    if mode == "rest":
        return args[0] if args else ""
    return args[0]


def _url_tier(arg):
    """Return (code, sev, note) for a redirect-URL argument."""
    if not arg:
        return None
    if REQ_SRC.search(arg):
        return ("OR-REQ-URL", "HIGH",
                "request-controlled redirect target — open redirect "
                "(phishing / oauth-code theft)")
    if _nonliteral(arg):
        return ("OR-NONLIT-URL", "MEDIUM",
                "non-literal/interpolated redirect target — trace its "
                "source for user influence")
    return None


def scan_file(path, rel):
    ext = os.path.splitext(path)[1].lower()
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
    except OSError:
        return []
    whole = "".join(lines)
    has_redirect_import = bool(REDIRECT_IMPORT.search(whole))
    findings = []
    seen = set()
    in_block = False
    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)
        if code is None or not code.strip():
            continue

        def add(cls, sev, note):
            if (cls, i + 1) in seen:
                return
            seen.add((cls, i + 1))
            if GUARD.search(code) or _is_test_path(rel):
                sev = "LOW" if sev != "LOW" else sev
            findings.append(Finding(cls, sev, rel, i + 1, line, note))

        is_import = IMPORT_LINE.search(code)

        if not is_import:
            _scan_redirects(lines, i, code, ext, has_redirect_import, add)
            _scan_headers(lines, i, code, ext, add)
            _scan_host_trust(code, add)
            _scan_dom(code, add)
    return findings


def _scan_redirects(lines, i, code, ext, has_redirect_import, add):
    window = _call_window(lines, i, code)
    base = window.find(code) if code else 0
    if SAFE_REDIRECT.search(code):
        return
    for rx, mode in REDIRECT_SINKS:
        for m in rx.finditer(code):
            if mode == "rest":
                arg = code[m.end():].strip().rstrip(";")
            else:
                pos = base + m.end() - 1
                args = _split_args(window, pos)
                arg = _pick_url_arg(args, mode)
                # kwargs — RedirectResponse(url=req.x)
                if arg and not REQ_SRC.search(arg):
                    joined = " ".join(a for a in args if a)
                    km = URL_KWARG.search(joined)
                    if km:
                        arg = joined[km.end():]
            tier = _url_tier(arg or "")
            if tier:
                add(tier[0], tier[1], tier[2])
    # bare redirect( — django/flask/next; bare Redirect( is the
    # ASP.NET Controller.Redirect(url) method in C# files
    if has_redirect_import or ext == ".cs":
        for m in BARE_REDIRECT.finditer(code):
            pos = base + m.end() - 1
            args = _split_args(window, pos)
            tier = _url_tier(_pick_url_arg(args, "first") or "")
            if tier:
                add(tier[0], tier[1], tier[2])
    # spring "redirect:" view concat
    for m in SPRING_REDIRECT.finditer(code):
        tail = code[m.end():]
        tier = _url_tier(tail)
        if tier:
            add(tier[0], tier[1], tier[2])
        elif REQ_SRC.search(code):
            add("OR-REQ-URL", "HIGH",
                "request value inside a redirect: view — open redirect")
    # php header("Location: ...) — remove the Location: label inside
    # the literal first so "'Location: /dash'" still reads as literal
    for m in PHP_LOCATION.finditer(code):
        hpos = code.rfind("(", 0, m.end())
        args = _split_args(window, base + hpos)
        arg = args[0] if args else ""
        if REQ_SRC.search(arg):
            add("OR-REQ-URL", "HIGH",
                "request-controlled Location header — open redirect")
        else:
            probe = re.sub(r"Location\s*:", "", arg, count=1)
            if _nonliteral(probe):
                add("OR-NONLIT-URL", "MEDIUM",
                    "non-literal/interpolated Location header target")
    # writeHead(3xx, {Location: X}) / setHeader('Location', X)
    for m in WRITEHEAD_3XX.finditer(code):
        pos = base + m.end() - 1
        args = _split_args(window, pos)
        if "Location" in code[:m.end()] or "Location" in code:
            cand = ""
            if len(args) > 1:
                cand = args[1]          # setHeader('Location', X)
            elif args:
                cand = args[0]          # writeHead(302, {Location: X})
            if REQ_SRC.search(cand) or (
                    args and REQ_SRC.search(" ".join(args))):
                add("OR-REQ-URL", "HIGH",
                    "request-controlled value in a 3xx/Location write")
            elif cand and _nonliteral(cand):
                add("OR-NONLIT-URL", "MEDIUM",
                    "non-literal redirect target in Location write")


def _scan_headers(lines, i, code, ext, add):
    window = _call_window(lines, i, code)
    base = window.find(code) if code else 0
    if SAFE_REDIRECT.search(code):
        return
    for rx, mode in HEADER_SINKS:
        for m in rx.finditer(code):
            if mode == "bracket":
                # response.headers[name] = value
                seg = code[m.end():]
                if "=" in seg:
                    name, _, val = seg.partition("=")
                else:
                    name, val = seg, ""
                for piece in (name, val):
                    if REQ_SRC.search(piece):
                        add("HDR-REQ-HEADER", "HIGH",
                            "request-controlled value in response-header "
                            "set — CRLF splitting / header forgery")
                        break
                continue
            pos = base + m.end() - 1
            args = _split_args(window, pos)
            if not args:
                continue
            name_arg = args[0]
            val_arg = args[1] if len(args) > 1 else ""
            for piece in (name_arg, val_arg):
                if REQ_SRC.search(piece):
                    add("HDR-REQ-HEADER", "HIGH",
                        "request-controlled value in response-header "
                        "set — CRLF splitting / header forgery")
                    break
    # whole header( call with request data inside (php/perl-style)
    if ext in (".php",):
        for m in PHP_HEADER_WHOLE.finditer(code):
            pos = base + m.end() - 1
            args = _split_args(window, pos)
            joined = " ".join(a for a in args if a)
            if REQ_SRC.search(joined) and not PHP_LOCATION.match(code):
                add("HDR-REQ-HEADER", "HIGH",
                    "request-controlled value inside header() — "
                    "response splitting / header forgery")


def _scan_host_trust(code, add):
    if not HOST_MARKER.search(code):
        return
    if HOST_BUILD.search(code):
        add("HOST-HEADER-TRUST", "MEDIUM",
            "Host/X-Forwarded-Host used in URL construction — "
            "host-header poisoning (reset links, canonical URLs)")


def _scan_dom(code, add):
    for m in DOM_SINK.finditer(code):
        seg = code[m.end():] if code[m.end():] else code
        if DOM_SRC.search(seg) or DOM_SRC.search(code):
            add("DOM-OPEN-REDIRECT", "HIGH",
                "client-side location assignment fed from "
                "URL/search params — DOM open redirect")
            break


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 os.path.splitext(fn)[1].lower() 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 = ["redirect_prescan report — %d file(s), %d finding(s)"
             % (len(targets), len(findings)), ""]
    for f in findings:
        lines.append("%-6s %-18s %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 patterns for "
                 "review; not an audit, clean != redirect-safe")
    return "\n".join(lines)


SELFTEST = [
    # --- request value -> redirect target (HIGH) ---
    ("a.js", "res.redirect(req.query.next)\n", {"OR-REQ-URL"}),
    ("a.js", "res.redirect(302, req.query.u)\n", {"OR-REQ-URL"}),
    ("a.js", "response.redirect(req.params['dest'])\n", {"OR-REQ-URL"}),
    ("a.js", "ctx.redirect(ctx.query.to)\n", {"OR-REQ-URL"}),
    ("a.js", "reply.redirect(req.headers['x-next'])\n", {"OR-REQ-URL"}),
    ("a.js", "NextResponse.redirect(new URL(req.nextUrl))\n",
     {"OR-REQ-URL"}),
    ("a.ts", "return c.Redirect(302, c.QueryParam('u'))\n",
     {"OR-REQ-URL"}),
    # go c.Redirect — signature is (code, url)
    ("a.go", "c.Redirect(302, c.QueryParam(\"u\"))\n", {"OR-REQ-URL"}),
    ("a.go", "http.Redirect(w, r, r.URL.Query().Get(\"next\"), 302)\n",
     {"OR-REQ-URL"}),   # r.URL.Query().Get reads the request query
    ("a.go", "http.Redirect(w, r, req.FormValue(\"next\"), 302)\n",
     {"OR-REQ-URL"}),
    ("a.py", "from django.shortcuts import redirect\n"
             "return redirect(request.GET['next'])\n", {"OR-REQ-URL"}),
    ("a.py", "from flask import redirect\n"
             "return redirect(request.args.get('u'))\n", {"OR-REQ-URL"}),
    ("a.py", "return HttpResponseRedirect(request.GET['to'])\n",
     {"OR-REQ-URL"}),
    ("a.py", "return RedirectResponse(url=request.query_params['n'])\n",
     {"OR-REQ-URL"}),
    ("a.py", "self.redirect(self.get_argument('next'))\n",
     {"OR-REQ-URL"}),
    ("a.rb", "redirect_to params[:url]\n", {"OR-REQ-URL"}),
    ("a.rb", "redirect_to(params[:back])\n", {"OR-REQ-URL"}),
    ("a.php", "header('Location: ' . $_GET['u']);\n", {"OR-REQ-URL"}),
    ("a.php", "header(\"Location: $loc\");\n", {"OR-NONLIT-URL"}),
    ("a.php", "wp_redirect($_REQUEST['next']);\n", {"OR-REQ-URL"}),
    ("a.php", "return redirect()->to($request->input('u'));\n",
     {"OR-REQ-URL"}),
    ("a.php", "return Redirect::to(Input::get('u'));\n", {"OR-REQ-URL"}),
    ("a.java", "response.sendRedirect(request.getParameter(\"next\"));\n",
     {"OR-REQ-URL"}),
    ("a.java", "return new RedirectView(request.getParameter(\"u\"));\n",
     {"OR-REQ-URL"}),
    ("a.java", "return \"redirect:\" + request.getParameter(\"path\");\n",
     {"OR-REQ-URL"}),
    ("a.cs", "Response.Redirect(Request.Query[\"returnUrl\"]);\n",
     {"OR-REQ-URL"}),
    ("a.cs", "return Redirect(Request.Query[\"u\"]);\n", {"OR-REQ-URL"}),
    # --- non-literal redirect target (MEDIUM) ---
    ("a.js", "res.redirect(next)\n", {"OR-NONLIT-URL"}),
    ("a.js", "res.redirect('/app/' + slug)\n", {"OR-NONLIT-URL"}),
    ("a.py", "from flask import redirect\nreturn redirect(target)\n",
     {"OR-NONLIT-URL"}),
    ("a.java", "response.sendRedirect(dest);\n", {"OR-NONLIT-URL"}),
    ("a.rb", "redirect_to url\n", {"OR-NONLIT-URL"}),
    # --- literal / safe redirects -> silent ---
    ("a.js", "res.redirect('/login')\n", set()),
    ("a.js", "res.redirect(302, '/home')\n", set()),
    ("a.py", "from flask import redirect\nreturn redirect('/dashboard')\n",
     set()),
    ("a.py", "def f():\n    return redirect(x)\n", set()),  # no import
    ("a.rb", "redirect_to root_path\n", {"OR-NONLIT-URL"}),
    ("a.php", "wp_safe_redirect($_GET['u']);\n", set()),
    ("a.cs", "return LocalRedirect(Request.Query[\"u\"]);\n", set()),
    ("a.cs", "return RedirectToAction(\"Home\");\n", set()),
    ("a.php", "header('Location: /dashboard');\n", set()),
    # --- guards downgrade to LOW ---
    ("a.js", "res.redirect(isInternal(req.query.next) ? req.query.next "
             ": '/')\n", {"OR-REQ-URL"}),                     # -> LOW
    ("a.js", "res.redirect(validateRedirect(req.query.u))\n",
     {"OR-REQ-URL"}),                                          # -> LOW
    ("a.rb", "redirect_to URI.parse(params[:u]).path\n", {"OR-REQ-URL"}),
    # --- response-header injection (HIGH) ---
    ("a.js", "res.setHeader('X-Ref', req.query.r)\n",
     {"HDR-REQ-HEADER"}),
    ("a.js", "res.set(req.query.name, 'v')\n", {"HDR-REQ-HEADER"}),
    ("a.js", "response.header('Location', req.query.u)\n",
     {"HDR-REQ-HEADER", "OR-REQ-URL"}),   # both rules may fire
    ("a.js", "response.headers[req.query.k] = v\n", {"HDR-REQ-HEADER"}),
    ("a.php", "header($_GET['h']);\n", {"HDR-REQ-HEADER"}),
    ("a.go", "w.Header().Set(\"X-Next\", r.URL.Query().Get(\"n\"))\n",
     {"HDR-REQ-HEADER"}),  # query param -> response header = CRLF shape
    ("a.go", "w.Header().Set(\"X-Next\", c.Param(\"n\"))\n",
     {"HDR-REQ-HEADER"}),
    ("a.py", "self.set_header('X-U', self.get_argument('u'))\n",
     {"HDR-REQ-HEADER"}),
    ("a.cs", "Response.AddHeader(\"X-R\", Request.Query[\"r\"]);\n",
     {"HDR-REQ-HEADER"}),
    # literal headers -> silent
    ("a.js", "res.setHeader('Content-Type', 'application/json')\n",
     set()),
    ("a.js", "res.set('X-Request-Id', id)\n", set()),
    ("a.php", "header('Content-Type: text/plain');\n", set()),
    # --- host-header trust (MEDIUM) ---
    ("a.js", "const link = 'https://' + req.headers.host + '/reset'\n",
     {"HOST-HEADER-TRUST"}),
    ("a.js", "const base = `https://${req.headers.host}/p`\n",
     {"HOST-HEADER-TRUST"}),
    ("a.php", "$url = 'https://' . $_SERVER['HTTP_HOST'] . '/r';\n",
     {"HOST-HEADER-TRUST"}),
    ("a.py", "base = f\"https://{request.headers['host']}/reset\"\n",
     {"HOST-HEADER-TRUST"}),
    ("a.java",
     "String u = \"https://\" + request.getHeader(\"Host\") + \"/r\";\n",
     {"HOST-HEADER-TRUST"}),
    ("a.js", "if (req.headers.host !== 'example.com') return 403\n",
     set()),   # bare host check — no URL construction
    ("a.js", "const h = req.headers.host\n", set()),  # read alone
    # --- DOM open redirect (HIGH) ---
    ("a.js", "location.href = new URLSearchParams(location.search)"
             ".get('next')\n", {"DOM-OPEN-REDIRECT"}),
    ("a.js", "window.location = params.get('redirect')\n",
     {"DOM-OPEN-REDIRECT"}),
    ("a.js", "location.replace(document.location.hash.slice(1))\n",
     {"DOM-OPEN-REDIRECT"}),
    ("a.js", "window.open(new URLSearchParams(location.search)"
             ".get('target'))\n", {"DOM-OPEN-REDIRECT"}),
    ("a.js", "location.href = '/dashboard'\n", set()),
    ("a.js", "location.assign('/home')\n", set()),
    # --- comments / imports / clean ---
    ("a.js", "// res.redirect(req.query.next)\n", set()),
    ("a.js", "/* res.redirect(req.query.u) */\n", set()),
    ("a.py", "# return redirect(request.GET['next'])\n", set()),
    ("a.py", "from django.shortcuts import redirect\n", set()),
    ("a.js", "const re = /redirect\\((.*?)\\)/;\n", set()),
    # multi-line lookahead
    ("a.js", "res.redirect(\n    req.query.next)\n", {"OR-REQ-URL"}),
    ("a.py", "from flask import redirect\n"
             "return redirect(\n    request.args['u'])\n",
     {"OR-REQ-URL"}),
    # object-literal config still carries the request value
    ("a.js", "res.redirect({ url: req.query.next })\n",
     {"OR-REQ-URL"}),
    # --- test-path downgrade ---
    ("test/a.js", "res.redirect(req.query.next)\n", {"OR-REQ-URL"}),
]


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())
