#!/usr/bin/env python3
"""ssrfsink_prescan.py — deterministic SSRF-sink pre-scan (CWE-918).

ARION service artifact. Scans source files for server-side request
forgery shapes — attacker-influenced data reaching an outbound HTTP /
scheme-capable fetch — line-wise, fully offline, no inference.

Findings (base severity):
  SF-REQ-SINK      HIGH    a request-derived value (request.args /
                           req.query / $_GET / r.URL.Query / event
                           body, ...) reaches an outbound HTTP client
                           call, inline or via a variable assigned
                           within the scan window
  SF-SCHEME-SINK   HIGH    same flow into a multi-scheme sink —
                           curl_exec / file_get_contents / fopen /
                           URI.open / shell curl|wget — where file://,
                           gopher://, dict://, php:// wrappers turn an
                           http-only guard into local-file / internal-
                           protocol read
  SF-VAR-SINK      MEDIUM  a url-named variable of unknown provenance
                           reaches a fetch sink with no allowlist token
                           visible — the flag is "verify this arg"
  SF-SUBSTR-GUARD  MEDIUM  the URL "validation" itself is a substring /
                           prefix-domain test — 'api.example.com' in u,
                           u.includes(dom), u.startswith(dom),
                           strpos/strstr/preg_match — all bypassable by
                           api.example.com.evil.tld / evilapi.example.com
  SF-URL-PARAM     LOW     request-derived value stored into a
                           url/webhook/callback/feed/avatar/image/
                           import-named variable — blind-SSRF surface
                           even if the fetch happens on another path
  SF-REDIRECT-FOLLOW LOW   fetch with redirect-follow enabled on a
                           guarded/url-ish arg — a first-hop allowlist
                           check does not re-validate the redirect target
  SF-DNS-THEN-FETCH LOW    resolve-and-check the hostname, then connect
                           BY NAME within ~12 lines — classic DNS-
                           rebinding TOCTOU (fix: connect to the checked
                           IP, or pin at connect time)
  SF-DECODE-FETCH  LOW     a decode step (base64 / urldecode / hex /
                           decodeURIComponent) feeds a fetch arg —
                           validation applied to the encoded form is
                           bypassed; decode THEN validate
  SF-FILE-SCHEME   LOW     file://|gopher://|dict://|php://|jar://|ftp://
                           literal used as a fetch arg — usually a debug
                           leftover that also documents a scheme-capable
                           sink
  SF-META-LITERAL  INFO    fetch of a literal private / loopback /
                           link-local address or cloud metadata host —
                           expected in dev tooling, worth a human look

Context rules:
  * request-taint is tracked through simple assignments
    (var = <expr containing a request marker>); a reassignment of the
    variable before the sink breaks the taint
  * allowlist markers on the sink line or within two lines above
    (allowlist / ALLOWED_HOSTS / is_safe_url / validate_url / ssrf /
    safelist / url_guard / check_url / is_allowed) suppress REQ-SINK /
    VAR-SINK / SCHEME-SINK rows — the fix is claimed applied. With
    redirect-follow on, a LOW redirect row is still emitted (the hop
    target is unchecked). SUBSTR-GUARD is itself a weak guard and is
    never suppressed
  * literal public http(s) URLs as the only fetch arg are silent —
    a fixed destination is not SSRF. Literal private/metadata hosts
    still flag as META-LITERAL
  * 'params[' counts as a WEAK request marker (Rails params vs plain
    dicts) — it produces MEDIUM rows, never HIGH
  * a resolved-then-fetched hostname is reported once as
    DNS-THEN-FETCH, not duplicated as VAR-SINK
  * test/spec/fixture paths downgrade one tier
  * comment lines and block-comment bodies are skipped (quote-aware:
    '//' inside a string literal is not a comment)

Scope: deterministic pre-scan for human review — flags dataflow shapes;
it cannot prove reachability from the internet, and a clean report is
not an SSRF-free guarantee. Companion to ssrf_prescan.py, which checks
one concrete URL at fetch time.

  ssrfsink_prescan.py <file-or-dir> [--json]
  ssrfsink_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", ".sh",
)

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

REQ_SRC = re.compile(r"""(?ix)
    request\.(?:args|form|GET|POST|data|json|values|files|body|params|
        query|cookies|headers|get_json|getParameter|getQueryString)    |
    req\.(?:query|params|body|headers|ip|url|hostname|cookies)|req\[   |
    ctx\.(?:query|request|params|param)                              |
    c\.(?:Query|QueryParam|FormValue|Param|PostForm|BodyParser)        |
    \$_GET|\$_POST|\$_REQUEST|\$_COOKIE|\$_FILES                     |
    r\.(?:URL\.Query|FormValue|PostForm|Form\[|PostFormValue)          |
    getParameterMap|event\.(?:queryStringParameters|body|
        pathParameters|multiValue)                                   |
    self\.request|flask\.request|query_params
""")

REQ_WEAK = re.compile(r"\bparams\s*\[|\bparam\s*\(")

DECODE = re.compile(
    r"(?i)b64decode|base64\.(?:b64decode|decode)|atob\s*\(|unquote|"
    r"urldecode\s*\(|decodeURIComponent|hex2bin|fromHex|"
    r"pack\s*\(\s*['\"]H|URLDecoder\.decode|Base64\.decode|"
    r"Base64\.getDecoder|::decode64|cgi\.unescape")

URL_NAME = re.compile(
    r"(?i)(?:^|_)(urls?|uris?|webhook|callback|endpoint|feed|avatar|"
    r"image|img|icon|import|remote|dest|redirect|href|link|fetch|ping|"
    r"host|hostname|target|src|source|download|proxy|page|site|domain|"
    r"origin)(?:_|$|s\b)")

# per-language sink sets — an outbound-fetch call is only a sink in the
# language where it actually fetches (JS `new URL(` is parsing, not a
# request; `open(` is network only in ruby open-uri, etc.)
SINKS = {
".py": re.compile(r"""(?x)
    requests\.(?:get|post|put|delete|head|patch|options|request)\s*\(|
    httpx\.(?:get|post|put|delete|head|patch|options|request|stream)\s*\(|
    urlopen\s*\(|urlretrieve\s*\(|wget\.download\s*\(|
    session\.(?:get|post|put|delete|head|patch|options|request)\s*\(|
    urllib\d*\.\w+\.(?:urlopen|urlretrieve)\s*\(|
    \bcurl\s+|\bwget\s+"""),
".js": re.compile(r"""(?x)
    \bfetch\s*\(|\baxios\s*\(|\baxios\.(?:get|post|put|delete|head|
        options|request)\s*\(|\bgot\s*\(|\brequest\s*\(|
    superagent\.(?:get|post|request)\s*\(|https?\.(?:get|request)\s*\(|
    undici\.(?:request|fetch)\s*\(|needle\.(?:get|post|request)\s*\(|
    \bky\.(?:get|post)\s*\(|\$\.(?:get|ajax|getJSON)\s*\(|
    \bcurl\s+|\bwget\s+"""),
".php": re.compile(r"""(?x)
    file_get_contents\s*\(|\bfopen\s*\(|\breadfile\s*\(|
    readgzfile\s*\(|\bcopy\s*\(\s*\$|
    curl_exec\s*\(|curl_multi_exec\s*\(|curl_setopt\s*\(|
    shell_exec\s*\([^)]*\bcurl\b|\bexec\s*\([^)]*\bcurl\b|
    \bsystem\s*\([^)]*\bcurl\b|popen\s*\([^)]*\bcurl\b|
    wp_remote_(?:get|post|head|request|retrieve)\s*\(|
    get_headers\s*\(|\w+->(?:get|post|request|send|execute)\s*\(|
    new\s+(?:SoapClient|GuzzleHttp\\\\Client)\s*\(|
    \bcurl\s+|\bwget\s+"""),
".rb": re.compile(r"""(?x)
    URI\.open\s*\(|\bopen\s*\(|
    Net::HTTP\.(?:get|post|get_response|start|new)\s*\(?|
    HTTParty\.(?:get|post|put|delete|head)\s*\(|
    RestClient\.(?:get|post|put|delete|execute|head)|
    Faraday\.(?:get|post|new|put)\s*\(|Typhoeus\.(?:get|post|Request)|
    Excon\.(?:get|post|new)\s*\(|\bHTTP\.(?:get|post|follow|put)\s*\(|
    \bcurl\s+|\bwget\s+"""),
".go": re.compile(r"""(?x)
    http\.(?:Get|Post|Head|PostForm|Do)\s*\(|\w+\.Do\s*\(|
    NewRequest(?:WithContext)?\s*\(|\w+\.R\(\)\.\w+\s*\(|
    \bcurl\s+|\bwget\s+"""),
".java": re.compile(r"""(?x)
    openConnection\s*\(|new\s+URL\s*\(|Jsoup\.connect\s*\(|
    getForObject\s*\(|getForEntity\s*\(|postForObject\s*\(|
    exchange\s*\(|HttpGet\s*\(|HttpPost\s*\(|
    \w+\.execute\s*\(|WebClient\.|new\s+Socket\s*\(|
    \bcurl\s+|\bwget\s+"""),
".cs": re.compile(r"""(?x)
    GetAsync\s*\(|PostAsync\s*\(|SendAsync\s*\(|PutAsync\s*\(|
    DownloadString\s*\(|GetStringAsync\s*\(|GetByteArrayAsync\s*\(|
    WebRequest\.Create(?:Http)?\s*\(|\.Execute(?:Async)?\s*\(|
    new\s+WebClient\s*\(|
    \bcurl\s+|\bwget\s+"""),
".sh": re.compile(r"\bcurl\s+|\bwget\s+"),
}
for _e in (".jsx", ".ts", ".tsx", ".mjs", ".cjs"):
    SINKS[_e] = SINKS[".js"]
for _e in (".kt", ".groovy"):
    SINKS[_e] = SINKS[".java"]
SINK_GENERIC = re.compile(r"\bfetch\s*\(|\bcurl\s+|\bwget\s+")

SCHEME_TOK = re.compile(
    r"(?i)curl|file_get_contents|fopen|readfile|readgzfile|copy|"
    r"URI\.open|\bopen\s*\(|wget|popen|shell_exec|\bexec\b|\bsystem\b")

FOLLOW = re.compile(
    r"(?i)allow_redirects\s*=\s*True|follow_redirects\s*=\s*True|"
    r"max_redirects\s*=\s*[1-9]|CURLOPT_FOLLOWLOCATION|"
    r"redirect\s*:\s*['\"]follow|setInstanceFollowRedirects\(\s*true|"
    r"followAllRedirects|AutoRedirect|->follow\s*\(|\.follow\s*\(\s*true|"
    r"Location\s*,\s*(?:true|1)|FOLLOWLOCATION\s*=\s*(?:true|1)")

GUARD_VERB = re.compile(
    r"(?i)\b(?:includes|contains|indexOf|startsWith|endsWith|startswith|"
    r"endswith|hasPrefix|hasSuffix|HasPrefix|HasSuffix|Contains|"
    r"strstr|strpos|stripos|mb_strpos|str_contains|preg_match|fnmatch|"
    r"in_array)\b")

DOM_IN = re.compile(r"\s+in\s+\w")

DOM_LIT = re.compile(
    r"(?<![\w.-])[\w-]+(?:\.[\w-]+)*\.(?:com|net|org|io|dev|ai|"
    r"app|gg|xyz|info|biz|cc|cloud|site|online|store|tech|fun|lol|"
    r"network|host|run|zone|world|team|tools|money|digital|agency)"
    r"(?![\w.-])")

RESOLVE = re.compile(
    r"(?i)(?:gethostbyname|getaddrinfo|dns\.lookup|dns\.resolve\d*|"
    r"InetAddress\.getByName|net\.Lookup(?:Host|IP)|Resolv\.\w+|"
    r"socket\.gethostbyname|resolve4|resolve6|lookup)\s*\(\s*"
    r"['\"]?([\w.-]{2,60})['\"]?")

GUARD = re.compile(
    r"(?i)allow[-_]?list|allowed[-_]?(?:hosts?|domains?|urls?|origins?)|"
    r"safe[-_]?url|is_safe|is_allowed|validate_url|valid_url|ssrf|"
    r"sanitiz|safelist|url_guard|host_guard|check_url|verify_url|"
    r"safe_fetch|allow_url|UrlValidator|allowlist|validateHost")

META_HOST = re.compile(
    r"(?i)169\.254\.169\.254|metadata\.google\.internal|"
    r"169\.254\.170\.2|100\.100\.100\.200|metadata\.aws|"
    r"instance-data|169\.254\.")

PRIVATE_URL = re.compile(
    r"(?i)['\"]https?://(?:127\.|0\.0\.0\.0|localhost|10\.|192\.168\.|"
    r"172\.(?:1[6-9]|2\d|3[01])\.|169\.254\.|\[::1\]|\[fc|\[fd|\[fe8)")

SCHEME_LIT = re.compile(
    r"(?i)['\"]?(?:file|gopher|dict|php|jar|ftp|tftp|ldap|ldaps|expect|"
    r"netdoc|sftp)://")

ASSIGN = re.compile(
    r"(?<![\w.])(?:const\s+|let\s+|var\s+)?(?:final\s+|static\s+|"
    r"(?:String|long|int|var|auto|string|val|public|private)\s+)?"
    r"([A-Za-z_@$][\w$]*(?:\.\w+)?)\s*(?::[^=\n]*)?=\s*(?!=|>)(.+)$")

IDENT = re.compile(r"(?<![\w.'$])([A-Za-z_$][\w$]{0,40})(?![\w'])")

KWARG = re.compile(r"\b[A-Za-z_][\w$]*\s*=")

SKIP_IDENT = {
    "self", "this", "cls", "true", "false", "True", "False", "None",
    "null", "nil", "undefined", "GET", "POST", "PUT", "HEAD", "DELETE",
    "PATCH", "OPTIONS", "timeout", "headers", "params", "data", "json",
    "verify", "auth", "proxies", "URI", "URL", "CURLOPT_URL",
    "curl", "http", "https", "new", "args", "kwargs", "res", "resp",
    "import", "meta", "await", "async", "return", "const",
    "err", "cb", "callback2", "_GET", "_POST", "_REQUEST", "_COOKIE",
    "_FILES", "GET2", "method", "options",
}

WINDOW = 15


def _strip_line_comment(code, ext):
    """strip // or # tail comments, quote-aware: a '//' or '#' inside a
    string literal is kept (http:// literals are not comments)."""
    hashes = ext in (".py", ".rb", ".sh", ".pl", ".r")
    q = None
    for k, ch in enumerate(code):
        if q:
            if ch == q and code[k - 1] != "\\":
                q = None
            continue
        if ch in "\"'`":
            q = ch
            continue
        if hashes and ch == "#":
            return code[:k]
        if not hashes and ch == "/" and k + 1 < len(code) \
                and code[k + 1] == "/":
            return code[:k]
    return code


def _extract_args(code, open_idx):
    """top-level arguments of the call whose '(' sits at open_idx"""
    depth = 0
    q = None
    args = []
    cur = []
    for k in range(open_idx, len(code)):
        ch = code[k]
        if q:
            cur.append(ch)
            if ch == q and code[k - 1] != "\\":
                q = None
            continue
        if ch in "\"'`":
            q = ch
            cur.append(ch)
            continue
        if ch == "(":
            depth += 1
            if depth == 1:
                continue
        elif ch == ")":
            depth -= 1
            if depth == 0:
                args.append("".join(cur))
                return args
        elif ch == "," and depth == 1:
            args.append("".join(cur))
            cur = []
            continue
        if depth >= 1:
            cur.append(ch)
    if cur:
        args.append("".join(cur))
    return args


def _is_test_path(rel):
    return bool(TEST_PATH.search("/" + rel))


def _guard_near(codes, i):
    lo = max(0, i - 2)
    return bool(GUARD.search("\n".join(codes[lo:i + 1])))


def _reassigned(codes, var, lo, hi):
    pat = re.compile(
        r"(?<![\w.])" + re.escape(var) +
        r"\s*(?::?=|<<=|>>=|\+=|-=|\*=|/=)\s*(?!=)")
    for j in range(lo, hi):
        if pat.search(codes[j]):
            return True
    return False


def _substr_guard(code):
    """domain-literal membership/prefix test = weak URL allowlist"""
    if not DOM_LIT.search(code):
        return False
    if GUARD_VERB.search(code):
        return True
    return bool(DOM_IN.search(code))


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 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 []
    findings = []
    seen = set()
    in_block = False
    codes = []
    for raw in lines:
        line = raw.rstrip("\n")
        j = 0
        out = []
        while j < len(line):
            if in_block:
                hit = False
                for closer in ("*/", "-->"):
                    k = line.find(closer, j)
                    if k >= 0:
                        j = k + len(closer)
                        in_block = False
                        hit = True
                        break
                if not hit:
                    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
            hit = False
            for closer in ("*/", "-->"):
                k = line.find(closer, j + 2)
                if k >= 0:
                    out.append(" ")
                    j = k + len(closer)
                    in_block = False
                    hit = True
                    break
        code = _strip_line_comment("".join(out), ext)
        codes.append(code)

    test = _is_test_path(rel)

    def add(code_, sev, i, note):
        key = (code_, i)
        if key in seen:
            return
        seen.add(key)
        if test and SEV_ORDER[sev] < SEV_ORDER["LOW"]:
            sev = "MEDIUM" if sev == "HIGH" else "LOW"
        findings.append(Finding(code_, sev, rel, i + 1,
                                lines[i].rstrip("\n"), note))

    # pass 1 — assignments: taint + urlish names + resolves
    taint = {}      # var -> [("req"|"weak"|"dec"|"url", idx), ...]
    resolves = []   # (idx, hostvar)
    urlparams = []  # (idx, var)
    for i, code in enumerate(codes):
        if not code.strip() or IMPORT_LINE.search(code):
            continue
        rm = RESOLVE.search(code)
        if rm:
            resolves.append((i, rm.group(1)))
        am = ASSIGN.search(code)
        if not am:
            continue
        var = am.group(1).lstrip("$@").split(".")[-1]
        expr = am.group(2)
        kind = None
        if REQ_SRC.search(expr) or (ext in (".rb", ".php")
                                    and REQ_WEAK.search(expr)):
            kind = "req"
        elif REQ_WEAK.search(expr):
            kind = "weak"
        elif DECODE.search(expr):
            kind = "dec"
        if kind:
            taint.setdefault(var, []).append((kind, i))
            if URL_NAME.search(var) and kind in ("req", "weak"):
                urlparams.append((i, var))
        elif URL_NAME.search(var):
            taint.setdefault(var, []).append(("url", i))

    fetched_vars = set()
    resolved_names = {h for _, h in resolves}

    # pass 2 — sinks + weak guards
    for i, code in enumerate(codes):
        if not code.strip() or IMPORT_LINE.search(code):
            continue

        if _substr_guard(code):
            add("SF-SUBSTR-GUARD", "MEDIUM", i,
                "URL guard is a substring/prefix-domain test — "
                "bypassable (api.example.com.evil.tld, "
                "evilapi.example.com); compare the parsed hostname "
                "against an exact allowlist")

        sink_re = SINKS.get(ext, SINK_GENERIC)
        for m in sink_re.finditer(code):
            mtok = m.group(0)
            scheme_hit = bool(SCHEME_TOK.search(mtok))

            # arg extraction: call args in parens; shell curl/wget take
            # the rest of the line
            rest = code[m.end():]
            if mtok.rstrip().endswith(("curl", "wget")) or \
                    (mtok in ("curl ", "wget ")):
                argline = rest
            else:
                args = _extract_args(code, m.end() - 1)
                argline = " ".join(args)
            if not argline.strip():
                continue
            guarded = _guard_near(codes, i)
            follow = bool(FOLLOW.search(code))

            if REQ_SRC.search(argline):
                if not guarded:
                    add("SF-SCHEME-SINK" if scheme_hit else "SF-REQ-SINK",
                        "HIGH", i,
                        "request-derived value into an outbound fetch" +
                        (" (multi-scheme sink: file:// / gopher:// "
                         "wrappers apply)" if scheme_hit else "") +
                        ("; redirects followed — first-hop checks do "
                         "not re-validate" if follow else ""))
                elif follow:
                    add("SF-REDIRECT-FOLLOW", "LOW", i,
                        "allowlisted first hop but redirects followed "
                        "— the redirect target is unchecked")
                continue

            if SCHEME_LIT.search(argline):
                add("SF-FILE-SCHEME", "LOW", i,
                    "non-http scheme literal reaches a fetch sink — "
                    "documents a scheme-capable client")
            if META_HOST.search(argline) or PRIVATE_URL.search(argline):
                add("SF-META-LITERAL", "INFO", i,
                    "fetch of a literal private/loopback/metadata "
                    "host — expected in dev tooling, worth a look")

            httplit = bool(re.search(r"https?://[\w-]+\.", argline))
            scan_arg = re.sub(
                r"'[^']*'|\"[^\"]*\"|`[^`]*`", " ", argline)
            scan_arg = KWARG.sub(" ", scan_arg)
            best = None
            has_arg = False
            for ident in IDENT.findall(scan_arg):
                ident = ident.lstrip("$@")
                if ident in SKIP_IDENT or re.fullmatch(r"[A-Z_]{3,}",
                                                       ident):
                    continue
                has_arg = True
                if ident in resolved_names:
                    continue     # DNS-THEN-FETCH covers it below
                tks = [t for t in taint.get(ident, ()) if t[1] < i]
                tk = tks[-1] if tks else None
                if tk and not _reassigned(codes, ident, tk[1] + 1, i):
                    kind = tk[0]
                    fetched_vars.add(ident)
                    if kind == "req":
                        cand = (0, "SF-SCHEME-SINK" if scheme_hit
                                else "SF-REQ-SINK", "HIGH",
                                "request-derived '%s' (line %d) into "
                                "an outbound fetch%s"
                                % (ident, tk[1] + 1,
                                   " (multi-scheme sink)" if scheme_hit
                                   else ""))
                    elif kind == "weak":
                        cand = (1, "SF-REQ-SINK", "MEDIUM",
                                "params-style request value '%s' "
                                "(line %d) into an outbound fetch — "
                                "verify it is not a plain dict"
                                % (ident, tk[1] + 1))
                    elif kind == "dec":
                        cand = (2, "SF-DECODE-FETCH", "LOW",
                                "'%s' is decode output (line %d) — "
                                "validation on the encoded form is "
                                "bypassed; decode THEN validate"
                                % (ident, tk[1] + 1))
                    else:
                        cand = (3, "SF-VAR-SINK", "MEDIUM",
                                "url-ish '%s' into an outbound fetch "
                                "— provenance not visible here; verify "
                                "an exact-hostname allowlist" % ident)
                elif URL_NAME.search(ident):
                    cand = (3, "SF-VAR-SINK", "MEDIUM",
                            "url-ish '%s' into an outbound fetch — "
                            "provenance not visible here; verify an "
                            "exact-hostname allowlist" % ident)
                else:
                    continue
                if best is None or cand[0] < best[0]:
                    best = cand
            if best and httplit and best[1] in (
                    "SF-REQ-SINK", "SF-SCHEME-SINK", "SF-VAR-SINK"):
                sev2 = "MEDIUM" if best[2] == "HIGH" else "LOW"
                best = (best[0], best[1], sev2,
                        best[3] + " — literal host prefix pins the "
                        "destination; attacker controls path/query "
                        "only")
            if best:
                if best[2] in ("HIGH", "MEDIUM") and guarded:
                    if follow:
                        add("SF-REDIRECT-FOLLOW", "LOW", i,
                            "allowlisted first hop but redirects "
                            "followed — the redirect target is "
                            "unchecked")
                else:
                    add(best[1], best[2], i,
                        best[3] + ("; redirects followed — first-hop "
                                   "checks do not re-validate"
                                   if follow else ""))
            elif guarded and follow and has_arg:
                add("SF-REDIRECT-FOLLOW", "LOW", i,
                    "allowlisted first hop but redirects followed — "
                    "the redirect target is unchecked")

            for ridx, hostvar in resolves:
                if ridx < i and i - ridx <= 12 and \
                        re.search(r"(?<![\w.])" + re.escape(hostvar) +
                                  r"(?![\w])", argline):
                    add("SF-DNS-THEN-FETCH", "LOW", i,
                        "hostname '%s' resolved/checked at line %d "
                        "then connected BY NAME here — DNS-rebinding "
                        "TOCTOU; connect to the checked IP or pin at "
                        "connect time" % (hostvar, ridx + 1))

    for aidx, var in urlparams:
        if var in fetched_vars:
            continue
        add("SF-URL-PARAM", "LOW", aidx,
            "request value stored in url-named '%s' — blind-SSRF "
            "surface if any path later fetches it; enforce an "
            "allowlist at the fetch site" % var)

    return findings


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 = ["ssrfsink_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 SSRF dataflow "
                 "shapes for review; not an audit, clean != SSRF-free")
    return "\n".join(lines)


SELFTEST = [
    ("a.py", "import requests\n"
             "requests.get(request.args.get('url'))\n",
     {"SF-REQ-SINK"}),
    ("a.js", "app.get('/x', (req,res)=>{ fetch(req.query.url); });\n",
     {"SF-REQ-SINK"}),
    ("a.php", "<?php\nfile_get_contents($_GET['u']);\n",
     {"SF-SCHEME-SINK"}),
    ("a.php", "<?php\n$c=curl_init();"
             "curl_setopt($c,CURLOPT_URL,$_GET['u']);"
             "\ncurl_exec($c);\n", {"SF-SCHEME-SINK"}),
    ("a.go", "func h(w http.ResponseWriter, r *http.Request) {\n"
             "  http.Get(r.URL.Query().Get(\"u\"))\n}\n",
     {"SF-REQ-SINK"}),
    ("a.java", "String u = request.getParameter(\"url\");\n"
               "new URL(u).openConnection();\n", {"SF-REQ-SINK"}),
    ("a.rb", "u = params[:url]\nURI.open(u)\n", {"SF-SCHEME-SINK"}),
    ("a.py", "import requests\nu = request.args.get('u')\n"
             "requests.get(u)\n", {"SF-REQ-SINK"}),
    ("a.js", "const u = req.body.callbackUrl;\naxios.get(u);\n",
     {"SF-REQ-SINK"}),
    ("a.py", "import requests\nu = request.args.get('u')\n"
             "u = 'https://fixed.example.com'\nrequests.get(u)\n",
     set()),
    ("a.py", "import requests\nu = params['u']\nrequests.get(u)\n",
     {"SF-REQ-SINK"}),
    ("a.rb", "u = params[:u]\nNet::HTTP.get(URI(u))\n",
     {"SF-REQ-SINK"}),
    ("a.py", "import requests\nrequests.get(webhook_url)\n",
     {"SF-VAR-SINK"}),
    ("a.js", "fetch(endpoint);\n", {"SF-VAR-SINK"}),
    ("a.js", "fetch(config.homepage);\n", set()),
    ("a.py", "import requests\nif host in ALLOWED_HOSTS:\n"
             "    requests.get(u)\n", set()),
    ("a.py", "import requests\n# allowlist checked above\n"
             "u = request.args.get('u')\nrequests.get(u)\n",
     {"SF-REQ-SINK"}),
    ("a.js", "if (u.includes('api.example.com')) { fetch(u); }\n",
     {"SF-SUBSTR-GUARD"}),
    ("a.py", "if 'example.com' in url:\n    requests.get(url)\n",
     {"SF-SUBSTR-GUARD", "SF-VAR-SINK"}),
    ("a.py", "if url.startswith('https://api.example.com'):\n"
             "    requests.get(url)\n",
     {"SF-SUBSTR-GUARD", "SF-VAR-SINK"}),
    ("a.php", "<?php\nif (strpos($u, 'example.com') !== false) {\n"
              "  file_get_contents($u);\n}\n", {"SF-SUBSTR-GUARD"}),
    ("a.py", "import requests\nif is_allowed(u):\n"
             "    requests.get(u, allow_redirects=True)\n",
     {"SF-REDIRECT-FOLLOW"}),
    ("a.py", "import requests\nu = request.args.get('u')\n"
             "requests.get(u, allow_redirects=True)\n",
     {"SF-REQ-SINK"}),
    ("a.py", "import socket, requests\n"
             "ip = socket.gethostbyname(host)\ncheck_ip(ip)\n"
             "requests.get('http://' + host)\n", {"SF-DNS-THEN-FETCH"}),
    ("a.js", "const ip = await dns.lookup(host);\ncheck(ip);\n"
             "fetch('http://' + host);\n", {"SF-DNS-THEN-FETCH"}),
    ("a.py", "import base64, requests\n"
             "u = base64.b64decode(raw)\nrequests.get(u)\n",
     {"SF-DECODE-FETCH"}),
    ("a.js", "const u = decodeURIComponent(raw);\nfetch(u);\n",
     {"SF-DECODE-FETCH"}),
    ("a.js", "const webhook_url = req.body.url;\nsave(webhook_url);\n",
     {"SF-URL-PARAM"}),
    ("a.py", "import requests\n"
             "requests.get('https://api.example.com/v1')\n", set()),
    ("a.py", "import requests\n"
             "requests.get('http://169.254.169.254/latest')\n",
     {"SF-META-LITERAL"}),
    ("a.py", "import requests\nrequests.get('file:///etc/passwd')\n",
     {"SF-FILE-SCHEME"}),
    ("a.js", "// fetch(req.query.url)\n", set()),
    ("a.java", "/* fetch(u); */\n", set()),
    ("a.py", "import requests\n", set()),
    ("a.sh", "curl -s $endpoint\n", {"SF-VAR-SINK"}),
    ("a.sh", "curl -s $1\n", set()),
]


def run_selftest():
    import tempfile
    fails = []
    for idx, (name, src, want) in enumerate(SELFTEST):
        d = tempfile.mkdtemp()
        p = os.path.join(d, name)
        with open(p, "w") as fh:
            fh.write(src)
        got = {f.code for f in scan_file(p, name)}
        if want != got:
            fails.append((idx, name, sorted(want), sorted(got)))
    return fails


def main():
    args = [a for a in sys.argv[1:] if a != "--json"]
    as_json = "--json" in sys.argv
    if "--selftest" in sys.argv:
        fails = run_selftest()
        if fails:
            for idx, name, exp, got in fails:
                print("FAIL %d %s want=%s got=%s" % (idx, name, exp, got))
            print("selftest: %d/%d failed" % (len(fails), len(SELFTEST)))
            sys.exit(2)
        print("selftest: %d/%d passed" % (len(SELFTEST), len(SELFTEST)))
        sys.exit(0)
    if not args:
        sys.stderr.write(__doc__)
        sys.exit(2)
    targets = find_targets(args[0])
    findings = []
    for t in targets:
        findings.extend(scan_file(t, t))
    if as_json:
        print(json.dumps([f.as_dict() for f in findings], indent=1))
    else:
        print(render(findings, targets))
    sys.exit(1 if any(f.sev in ("HIGH", "MEDIUM") for f in findings)
             else 0)


if __name__ == "__main__":
    main()
