#!/usr/bin/env python3
"""ssti_prescan.py — deterministic server-side template injection pre-scan.

ARION service artifact. Scans source files for the signatures of SSTI —
user-influenced template source or template names reaching render /
compile sinks — line-wise, fully offline, no inference.

Findings (base severity):
  TI-REQ-SRC        HIGH    request-controlled value inside the template
                            SOURCE argument of a render/compile sink —
                            render_template_string, env.from_string,
                            Jinja2/mako Template(), ERB.new, ejs.render/
                            compile, pug.render/compile,
                            nunjucks/env.renderString, _.template,
                            doT.template, swig.render/compile, eta.render,
                            Velocity.evaluate, freemarker new Template,
                            StringTemplateLoader.putTemplate content,
                            createTemplate (Twig/Groovy), Pebble
                            getLiteralTemplate, Razor.Parse/RunCompile/
                            CompileRenderStringAsync, Rails render
                            inline:, smarty "string:" resource, Go
                            template .Parse(nonliteral)
  TI-SRC-NONLIT     MEDIUM  template-source arg is non-literal /
                            interpolated (variable, concat, f-string)
                            without a visible request marker
  TI-REQ-NAME       HIGH    request-controlled value as the template
                            NAME/PATH argument — render_template,
                            render_to_string, get_template,
                            select_template, TemplateResponse (django/
                            starlette arg-after-request heuristic),
                            res.render / response.render, ejs/pug
                            renderFile, smarty display/fetch/render,
                            Velocity mergeTemplate, cfg.getTemplate,
                            template.ParseFiles/ParseGlob, Rails render
                            file:/template:/action:, Slim::Template.new —
                            template-file injection (LFI -> render-as-code)
  TI-NAME-NONLIT    MEDIUM  template name/path arg non-literal without a
                            visible request marker
  TI-ENV-TMPL       LOW     environment-controlled value reaches a
                            template source or name argument
  TI-AUTOESCAPE-OFF LOW     autoescaping disabled in engine config —
                            autoescape=False / autoescape:false /
                            {% autoescape off %} / setAutoescape(false)

Context rules:
  * logic-less engines (mustache / pystache / chevron / hogan /
    handlebars / liquid-family render+compile) cap HIGH -> MEDIUM —
    template injection there is content forgery, not code execution
  * request markers in the CONTEXT argument (ejs.render(tpl, {x:
    req.body})) are the CORRECT pattern and stay silent — only the
    source/name argument position is checked for request data
  * django-style render(request, name) / TemplateResponse(request, name)
    inspect the argument AFTER the request object
  * guard markers on the same line (escape/sanitize/bleach/
    DOMPurify/basename/secure_filename/normpath/abspath/resolve/
    allowlist/whitelist/int()/parseInt/strconv casts) downgrade one
    tier -> LOW for human review, not suppression
  * test/spec/fixture paths downgrade one tier
  * comment lines and block-comment bodies are skipped
  * Go .Parse() is only scanned when the file imports text/template or
    html/template; bare Template() is only scanned in Python files that
    import a template engine

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

  ssti_prescan.py <file-or-dir> [--json]
  ssti_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"escape|sanitize|Sanitize|bleach|DOMPurify|purify|"
    r"basename|secure_filename|safe_join|normpath|abspath|realpath|"
    r"resolve\s*\(|canonical|allowlist|whitelist|permit\s*\(|"
    r"\bint\s*\(|\bfloat\s*\(|parseInt|parseFloat|Number\s*\(|"
    r"strconv\.Atoi|Integer\.parseInt|"
    r"re\.(?:match|fullmatch)|Pattern\.matches",
    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->|"
    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*['\"]",
    re.I)

ENV_SRC = re.compile(
    r"process\.env|os\.environ|\bENV\s*\[|getenv\s*\(|\$_ENV|"
    r"System\.getenv|Environment\.GetEnvironmentVariable")

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

AUTOESCAPE_OFF = re.compile(
    r"autoescape\s*[=:]\s*(?:False|false|0)|"
    r"\{%\s*-?\s*autoescape\s+(?:off|false)|"
    r"\.\s*setAutoescape\s*\(\s*false|"
    r"autoescaping\s*[=:]\s*(?:false|0)",
    re.I)

REQUESTISH_ARG = re.compile(
    r"^\s*(?:request|req|self\.request|self|ctx|context|w|"
    r"this\.request|_request)\s*$")

# --- sink tables -------------------------------------------------------------
# (regex, kind, modeset)
#   kind: 'src' = arg is template source, 'name' = arg is template file/name
#   mode: which arg carries the template payload
#     'first'        -> args[0]
#     'after_req'    -> args[1] when args[0] is request-ish else args[0]
#     'any_req'      -> req anywhere in args -> HIGH; else check last arg
#     'content2'     -> putTemplate(name, content): args[1]
#     'smarty'       -> 'string:'-prefixed literal arg = source else name
#   cap 'MEDIUM'   -> logic-less engine, HIGH is impossible
SINKS = [
    # python — source
    (re.compile(r"\brender_template_string\s*\("), "src", "first", None),
    (re.compile(r"\bfrom_string\s*\("), "src", "first", None),
    (re.compile(r"\brender_to_string\s*\("), "name", "first", None),
    (re.compile(r"\bget_template\s*\("), "name", "first", None),
    (re.compile(r"\bselect_template\s*\("), "name", "first", None),
    (re.compile(r"\brender_template\s*\("), "name", "first", None),
    (re.compile(r"\bTemplateResponse\s*\("), "name", "after_req", None),
    (re.compile(r"\b(?:pystache|chevron)\s*\.\s*render\s*\("),
     "src", "first", "MEDIUM"),
    # js/ts — source
    (re.compile(r"\bejs\s*\.\s*(?:render|compile)\s*\("),
     "src", "first", None),
    (re.compile(r"\bpug\s*\.\s*(?:render|compile)\s*\("),
     "src", "first", None),
    (re.compile(r"\b(?:nunjucks|env)\s*\.\s*renderString\s*\("),
     "src", "first", None),
    (re.compile(r"\b(?:_\.|lodash\s*\.\s*)template\s*\("),
     "src", "first", None),
    (re.compile(r"\b(?:doT|dot)\s*\.\s*template\s*\("),
     "src", "first", None),
    (re.compile(r"\bswig\s*\.\s*(?:compile|render)\s*\("),
     "src", "first", None),
    (re.compile(r"\beta\s*\.\s*render\s*\("), "src", "first", None),
    (re.compile(r"\b(?:handlebars|Handlebars)\s*\.\s*compile\s*\("),
     "src", "first", "MEDIUM"),
    (re.compile(r"\bhogan\s*\.\s*compile\s*\("),
     "src", "first", "MEDIUM"),
    (re.compile(r"\b(?:mustache|Mustache)\s*\.\s*render\s*\("),
     "src", "first", "MEDIUM"),
    # js/ts — name
    (re.compile(r"\b(?:ejs|pug)\s*\.\s*renderFile\s*\("),
     "name", "first", None),
    (re.compile(r"\bres(?:ponse)?\s*\.\s*render\s*\("),
     "name", "first", None),
    # ruby — source
    (re.compile(r"\bERB\s*\.\s*new\s*\("), "src", "first", None),
    (re.compile(r"\bErubis\s*::\s*\w+\s*\.\s*new\s*\("),
     "src", "first", None),
    (re.compile(r"\bHaml\s*::\s*Engine\s*\.\s*new\s*\("),
     "src", "first", None),
    (re.compile(r"\bMustache\s*\.\s*render\s*\("),
     "src", "first", "MEDIUM"),
    (re.compile(r"\brender\s+inline\s*:"), "src", "first", None),
    # ruby — name
    (re.compile(r"\brender\s+(?:file|template|action)\s*:"),
     "name", "first", None),
    (re.compile(r"\bSlim\s*::\s*Template\s*\.\s*new\s*\("),
     "name", "first", None),
    # php
    (re.compile(r"->\s*createTemplate\s*\("), "src", "first", None),
    (re.compile(r"->\s*(?:display|fetch)\s*\("),
     "smarty", "smarty", None),
    (re.compile(r"->\s*compileString\s*\("), "src", "first", None),
    # java / kotlin / groovy / c#
    (re.compile(r"\bnew\s+Template\s*\("), "src", "content2", None),
    (re.compile(r"\bVelocity\s*\.\s*evaluate\s*\(|"
                r"\b\w*[Vv]elocity\w*\s*\.\s*evaluate\s*\("),
     "src", "any_req", None),
    (re.compile(r"\bmergeTemplate\s*\("), "name", "first", None),
    (re.compile(r"\bputTemplate\s*\("), "src", "content2", None),
    (re.compile(r"\bgetLiteralTemplate\s*\("), "src", "first", None),
    (re.compile(r"\bcreateTemplate\s*\("), "src", "first", None),
    (re.compile(r"\.\s*getTemplate\s*\("), "name", "first", None),
    (re.compile(r"\bRazor\s*\.\s*Parse\s*\("), "src", "first", None),
    (re.compile(r"\bRunCompile\s*\("), "src", "first", None),
    (re.compile(r"\bCompileRenderStringAsync\s*\("),
     "src", "first", None),
    (re.compile(r"\bHandlebars\s*\.\s*Compile\s*\("),
     "src", "first", "MEDIUM"),
]

# django-style bare render( — python only, never a method call
PY_RENDER = re.compile(r"(?<![\w.])render\s*\(")

# go-only sinks — applied only when the file imports a template package
GO_SINKS = [
    (re.compile(r"\.\s*Parse\s*\("), "src", "first", None),
    (re.compile(r"\.\s*ParseFiles\s*\(|\.\s*ParseGlob\s*\(|"
                r"\btemplate\s*\.\s*ParseFiles\s*\(|"
                r"\btemplate\s*\.\s*ParseGlob\s*\("),
     "name", "first", None),
]

GO_TMPL_IMPORT = re.compile(r'"(?:text|html)/template"')
PY_TMPL_IMPORT = re.compile(r"jinja2|mako|chevron|pystache|tornado")
BARE_TEMPLATE_PY = re.compile(r"\bTemplate\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):
    """Is the expression non-literal / interpolated?"""
    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):
    """Split a call's args. open_idx = index of the '(' char.
    Returns list of raw arg strings (may be fewer than the real count
    if the call runs off the line — bounded lookahead joins lines)."""
    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):
    """Join following raw lines while parens stay open (max 4)."""
    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_arg(args, mode):
    """Which arg index carries the template payload for this mode."""
    if not args:
        return None
    if mode == "first" or mode == "smarty":
        return 0
    if mode == "after_req":
        return 1 if len(args) > 1 and REQUESTISH_ARG.match(args[0]) else 0
    if mode == "content2":
        return 1 if len(args) > 1 else 0
    if mode == "any_req":
        return len(args) - 1
    return 0


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 _tier_for(kind, arg, code):
    """Return (finding-code, severity, note) or None for a sink's
    template argument."""
    if REQ_SRC.search(arg):
        return ("TI-REQ-SRC" if kind == "src" else "TI-REQ-NAME",
                "HIGH",
                "request-controlled %s reaches template %s"
                % ("source" if kind == "src" else "name/path",
                   "render/compile" if kind == "src" else "lookup"))
    if ENV_SRC.search(arg):
        return ("TI-ENV-TMPL", "LOW",
                "environment-controlled template %s"
                % ("source" if kind == "src" else "name"))
    if _nonliteral(arg):
        return ("TI-SRC-NONLIT" if kind == "src" else "TI-NAME-NONLIT",
                "MEDIUM",
                "non-literal/interpolated template %s"
                % ("source" if kind == "src" else "name/path"))
    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)
    go_tmpl = bool(GO_TMPL_IMPORT.search(whole)) if ext == ".go" else False
    py_tmpl = bool(PY_TMPL_IMPORT.search(whole)) if ext == ".py" else False
    findings = []
    seen = set()
    in_block = False
    for i, raw in enumerate(lines):
        line = raw.rstrip("\n")
        # block-comment tracking (/* */ and <!-- -->)
        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 AUTOESCAPE_OFF.search(code):
            add("TI-AUTOESCAPE-OFF", "LOW",
                "autoescaping disabled in engine config")

        for rx, kind, mode, cap in SINKS:
            for m in rx.finditer(code):
                if is_import:
                    continue
                # resolve kind for smarty 'string:' resource
                k2 = kind
                arg_text = None
                # call args — join lines while parens open
                window = _call_window(lines, i, code)
                rel_idx = window.find(code) if code else 0
                pos = rel_idx + m.end() - 1   # index of '(' in window
                args = _split_args(window, pos)
                if mode == "smarty":
                    a0 = args[0].strip() if args and args[0] else ""
                    if re.match(r"^['\"]string:", a0):
                        k2 = "src"
                    arg_text = args[0] if args else ""
                elif mode == "any_req":
                    joined = " ".join(a for a in args if a is not None)
                    if REQ_SRC.search(joined):
                        arg_text = joined
                    else:
                        idx = _pick_arg(args, mode)
                        arg_text = args[idx] if idx is not None else ""
                else:
                    idx = _pick_arg(args, mode)
                    arg_text = args[idx] if idx is not None else ""
                if arg_text is not None:
                    # freemarker-style content arg may be wrapped in a
                    # literal Reader — unwrap before literalness check
                    rw = re.match(
                        r"^\s*new\s+(?:StringReader|StringBufferReader|"
                        r"StringWriter|StringBuffer)\s*\((.*)\)\s*$",
                        arg_text.strip(), re.S)
                    if rw:
                        arg_text = rw.group(1)

                tier = _tier_for(k2, arg_text or "", code)
                if not tier:
                    continue
                cls, sev, note = tier
                if cap and sev == "HIGH":
                    sev = cap
                    note += " (logic-less engine — content forgery, " \
                            "not code exec)"
                add(cls, sev, note)

        # python bare Template( — only with a template-engine import
        if py_tmpl:
            for m in BARE_TEMPLATE_PY.finditer(code):
                if is_import:
                    continue
                window = _call_window(lines, i, code)
                pos = window.find(code) + m.end() - 1
                args = _split_args(window, pos)
                tier = _tier_for("src", args[0] if args else "", code)
                if tier:
                    add(tier[0], tier[1], tier[2])

        # django-style bare render( — python only; fires only when the
        # first arg IS the request object (django signature) — local
        # render() helpers and def render(...) stay silent
        if ext == ".py":
            for m in PY_RENDER.finditer(code):
                if is_import:
                    continue
                window = _call_window(lines, i, code)
                pos = window.find(code) + m.end() - 1
                args = _split_args(window, pos)
                if not args or not REQUESTISH_ARG.match(args[0]):
                    continue
                arg = args[1] if len(args) > 1 else ""
                tier = _tier_for("name", arg, code)
                if tier:
                    add(tier[0], tier[1], tier[2])

        # go template sinks — only with template import
        if go_tmpl:
            for rx, kind, mode, cap in GO_SINKS:
                for m in rx.finditer(code):
                    if is_import:
                        continue
                    window = _call_window(lines, i, code)
                    pos = window.find(code) + m.end() - 1
                    args = _split_args(window, pos)
                    tier = _tier_for(kind, args[0] if args else "", code)
                    if tier:
                        add(tier[0], tier[1], tier[2])
    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 = ["ssti_prescan report — %d file(s), %d finding(s)"
             % (len(targets), len(findings)), ""]
    for f in findings:
        lines.append("%-6s %-16s %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 != injection-safe")
    return "\n".join(lines)


SELFTEST = [
    # --- request value -> template source (HIGH) ---
    ("a.py", "from jinja2 import Template\n"
             "return Template(request.args['tpl']).render()\n",
     {"TI-REQ-SRC"}),
    ("a.py", "from flask import render_template_string\n"
             "return render_template_string(request.args['t'])\n",
     {"TI-REQ-SRC"}),
    ("a.py", "return render_template_string('<h1>' + request.args['n'])\n",
     {"TI-REQ-SRC"}),
    ("a.py", "return render_template_string(f'<h1>{request.args[\"n\"]}</h1>')\n",
     {"TI-REQ-SRC"}),
    ("a.py", "env.from_string(request.data).render()\n", {"TI-REQ-SRC"}),
    ("a.js", "const ejs = require('ejs');\n"
             "ejs.render(req.query.tpl, {name: 'x'});\n", {"TI-REQ-SRC"}),
    ("a.js", "pug.render(req.body.t)\n", {"TI-REQ-SRC"}),
    ("a.js", "nunjucks.renderString(req.params.src, ctx)\n",
     {"TI-REQ-SRC"}),
    ("a.js", "const t = _.template(req.query.t);\n", {"TI-REQ-SRC"}),
    ("a.js", "doT.template(req.body.tpl)\n", {"TI-REQ-SRC"}),
    ("a.rb", "ERB.new(params[:tpl]).result(binding)\n", {"TI-REQ-SRC"}),
    ("a.rb", "render inline: params[:tpl]\n", {"TI-REQ-SRC"}),
    ("a.php", "$twig->createTemplate($_POST['tpl']);\n", {"TI-REQ-SRC"}),
    ("a.php", "$smarty->display('string:'.$_GET['t']);\n", {"TI-REQ-SRC"}),
    ("a.java",
     "Velocity.evaluate(ctx, w, \"t\", request.getParameter(\"tpl\"));\n",
     {"TI-REQ-SRC"}),
    ("a.java", "loader.putTemplate(\"t\", request.getParameter(\"src\"));\n",
     {"TI-REQ-SRC"}),
    ("a.cs", "Engine.Razor.RunCompile(req.Query[\"tpl\"], \"k\");\n",
     {"TI-REQ-SRC"}),
    ("a.go", "import \"text/template\"\n"
             "template.New(\"t\").Parse(c.QueryParam(\"tpl\"))\n",
     {"TI-REQ-SRC"}),
    # logic-less engines cap HIGH -> MEDIUM
    ("a.js", "Mustache.render(req.query.tpl, view)\n", {"TI-REQ-SRC"}),
    ("a.js", "handlebars.compile(req.body.t)(d)\n", {"TI-REQ-SRC"}),
    ("a.py", "import pystache\npystache.render(request.args['t'], ctx)\n",
     {"TI-REQ-SRC"}),
    # --- non-literal template source (MEDIUM) ---
    ("a.py", "from jinja2 import Template\nreturn Template(tpl).render()\n",
     {"TI-SRC-NONLIT"}),
    ("a.py", "return render_template_string(page_body)\n",
     {"TI-SRC-NONLIT"}),
    ("a.py", "return render_template_string('<p>' + body)\n",
     {"TI-SRC-NONLIT"}),
    ("a.py", "return render_template_string(f'<p>{body}</p>')\n",
     {"TI-SRC-NONLIT"}),
    ("a.js", "ejs.render(html, data)\n", {"TI-SRC-NONLIT"}),
    ("a.js", "pug.compile(src)\n", {"TI-SRC-NONLIT"}),
    ("a.rb", "ERB.new(template).result\n", {"TI-SRC-NONLIT"}),
    ("a.java", "new Template(\"t\", new StringReader(src), cfg);\n",
     {"TI-SRC-NONLIT"}),
    ("a.java", "new Template(\"t\", new StringReader(\"ok\"), cfg);\n",
     set()),
    ("a.cs", "Razor.Parse(templateText, model);\n", {"TI-SRC-NONLIT"}),
    ("a.go", "import \"html/template\"\n"
             "template.New(\"x\").Parse(src)\n", {"TI-SRC-NONLIT"}),
    ("a.groovy", "engine.createTemplate(userTpl).make()\n",
     {"TI-SRC-NONLIT"}),
    # literal source -> silent
    ("a.py", "return render_template_string('<h1>hi</h1>')\n", set()),
    ("a.js", "ejs.render('<h1>hi</h1>', d)\n", set()),
    ("a.rb", "ERB.new('<p>x</p>').result\n", set()),
    ("a.go", "import \"text/template\"\n"
             "template.New(\"t\").Parse(\"{{.Name}}\")\n", set()),
    # --- request value -> template name (HIGH) ---
    ("a.py", "from flask import render_template\n"
             "return render_template(request.args['page'])\n",
     {"TI-REQ-NAME"}),
    ("a.py", "return render_template('pages/' + request.args['p'])\n",
     {"TI-REQ-NAME"}),
    ("a.py", "return render(request, request.GET['t'])\n",
     {"TI-REQ-NAME"}),
    ("a.py", "return render(request, 'app/' + request.GET['p'])\n",
     {"TI-REQ-NAME"}),
    ("a.py", "t = get_template(request.GET['name'])\n", {"TI-REQ-NAME"}),
    ("a.py", "return TemplateResponse(request, request.GET['t'])\n",
     {"TI-REQ-NAME"}),
    ("a.js", "res.render(req.query.view)\n", {"TI-REQ-NAME"}),
    ("a.js", "ejs.renderFile(req.params.f, d)\n", {"TI-REQ-NAME"}),
    ("a.php", "$smarty->display($_GET['tpl']);\n", {"TI-REQ-NAME"}),
    ("a.java", "cfg.getTemplate(request.getParameter(\"t\"));\n",
     {"TI-REQ-NAME"}),
    ("a.rb", "render template: params[:view]\n", {"TI-REQ-NAME"}),
    ("a.rb", "render file: params[:f]\n", {"TI-REQ-NAME"}),
    ("a.go", "import \"html/template\"\n"
             "template.ParseFiles(c.Param(\"f\"))\n", {"TI-REQ-NAME"}),
    # --- non-literal template name (MEDIUM) ---
    ("a.py", "return render_template(page)\n", {"TI-NAME-NONLIT"}),
    ("a.py", "return render_template('pages/' + slug)\n",
     {"TI-NAME-NONLIT"}),
    ("a.py", "return render(request, tpl_name)\n", {"TI-NAME-NONLIT"}),
    ("a.js", "res.render(viewName)\n", {"TI-NAME-NONLIT"}),
    ("a.js", "ejs.renderFile(path.join(VIEWS, f), d)\n",
     {"TI-NAME-NONLIT"}),
    ("a.java", "Velocity.mergeTemplate(name, \"enc\", ctx);\n",
     {"TI-NAME-NONLIT"}),
    # literal names / request-context patterns -> silent
    ("a.py", "return render_template('page.html')\n", set()),
    ("a.py", "return render(request, 'app/page.html', ctx)\n", set()),
    ("a.py", "return render_template('p.html', name=request.args['n'])\n",
     set()),                                            # context = safe
    ("a.js", "res.render('page', {title: req.query.t})\n", set()),
    ("a.js", "ejs.renderFile('views/p.ejs', {x: req.body.x})\n", set()),
    ("a.py", "return render(request, 'a.html', {'x': request.GET['x']})\n",
     set()),
    # --- env / autoescape ---
    ("a.py", "return render_template_string(os.environ['TPL'])\n",
     {"TI-ENV-TMPL"}),
    ("a.js", "ejs.render(process.env.TPL, d)\n", {"TI-ENV-TMPL"}),
    ("a.py", "env = Environment(loader=FileSystemLoader('.'), "
             "autoescape=False)\n", {"TI-AUTOESCAPE-OFF"}),
    ("a.js", "nunjucks.configure({autoescape: false})\n",
     {"TI-AUTOESCAPE-OFF"}),
    ("a.py", "{% autoescape off %}{{ x }}{% endautoescape %}\n",
     {"TI-AUTOESCAPE-OFF"}),
    ("a.py", "env = Environment(autoescape=True)\n", set()),
    # --- guards downgrade to LOW ---
    ("a.py", "return render_template(secure_filename(request.args['p']))\n",
     {"TI-REQ-NAME"}),                                  # -> LOW
    ("a.py", "return render_template('p/' + sanitize(request.args['x']))\n",
     {"TI-REQ-NAME"}),                                  # -> LOW
    ("a.py", "t = get_template('post-%d.html' % int(request.GET['n']))\n",
     {"TI-REQ-NAME"}),                                  # -> LOW
    ("a.js", "ejs.render(DOMPurify.sanitize(req.body.t), d)\n",
     {"TI-REQ-SRC"}),                                   # -> LOW
    # --- comments / clean / imports ---
    ("a.py", "# render_template_string(request.args['t'])\n", set()),
    ("a.js", "// ejs.render(req.query.tpl)\n", set()),
    ("a.js", "/* ejs.render(req.body.t) */\n", set()),
    ("a.py", "from flask import render_template_string\n", set()),
    ("a.py", "def f():\n    return render(request, 'p.html')\n", set()),
    ("a.js", "const x = /render\\((.*?)\\)/;\n", set()),
    ("a.py", "return render_template_string(\n    request.args['t'])\n",
     {"TI-REQ-SRC"}),                                   # lookahead
    # go .Parse not gated -> silent without template import
    ("a.go", "xpath.MustCompile(q).Parse(src)\n", set()),
    ("a.go", "import \"encoding/xml\"\nxml.Unmarshal(b, &v)\n", set()),
    # python bare Template( gated on engine import
    ("a.py", "class T:\n    def build(self): return Template(x)\n",
     set()),                                            # no import -> silent
    ("a.ts", "const tpl = _.template(str);\n", {"TI-SRC-NONLIT"}),
    ("a.php", "$e = $mustache->render($_GET['t'], $d);\n", set()),
    # php smarty literal string: -> silent, string:+var -> MEDIUM
    ("a.php", "$smarty->display('string:hello {$n}');\n", set()),
    ("a.php", "$smarty->fetch('string:' . $tpl);\n", {"TI-SRC-NONLIT"}),
]


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