#!/usr/bin/env python3
"""cmdi_prescan.py — deterministic command-injection / shell-exec pre-scan.

ARION service artifact. Scans source files for the signatures of OS
command injection and unsafe dynamic execution — line-wise, fully
offline, no inference.

Findings (base severity):
  CI-REQ-EXEC       HIGH    request-controlled value reaches an exec /
                            shell / eval sink on the same line
                            (req.query, request.args, params[], $_GET,
                            getParameter, c.Param, Request.Query,
                            event.body, argv ...)
  CI-SHELL-WRAP     HIGH    explicit shell wrapper — sh -c / bash -c /
                            cmd /c / powershell -Command — given a
                            non-literal command tail; MEDIUM when no
                            request marker is visible
  CI-SHELL-TRUE     HIGH    subprocess-family call (subprocess.run/
                            call/Popen/check_output/check_call,
                            asyncio.create_subprocess_shell,
                            loop.subprocess_shell) with shell=True and
                            a non-literal / interpolated command;
                            shell=True with a literal command is LOW
                            (metachar smell, not injection)
  CI-EXEC-NONLIT    MEDIUM  shell-exec sink called with a non-literal /
                            interpolated argument — os.system/os.popen,
                            child_process.exec/execSync, PHP system/
                            exec/passthru/shell_exec/popen/proc_open,
                            Ruby system/%x/backticks, Perl system/qx,
                            Runtime.exec(string), "str".execute(),
                            Java ProcessBuilder(single string),
                            sh-only eval "$var"
  CI-EVAL-NONLIT    MEDIUM  dynamic-eval sink with non-literal arg —
                            eval()/exec(), new Function(), vm.runIn*,
                            Ruby eval/instance_eval/module_eval/
                            class_eval, PHP eval/create_function/
                            string-assert, Groovy/JSR-223 .evaluate,
                            Eval.me, compile()
  CI-SPAWN-ARG      MEDIUM  no-shell exec family (spawn/execFile/
                            execFileSync, exec.Command[Context],
                            Process.Start, new ProcessBuilder(array),
                            syscall.Exec, Process.spawn, os.spawn*,
                            subprocess w/o shell=True) where an args
                            element is user-data-flavoured — argument
                            injection (--flag / -o / -exec surface);
                            generic non-literal args -> LOW
  CI-PIPE-SHELL     MEDIUM  download-piped-to-shell in source
                            (curl|wget|fetch ... | sh|bash|python|perl|
                            ruby|node, incl. sudo)
  CI-ENV-EXEC       LOW     environment value (process.env, os.environ,
                            ENV[], getenv, $_ENV) reaches an exec sink —
                            operator-controlled but worth review
  CI-EXEC-ARG       LOW     subprocess call without shell=True whose
                            command name is non-literal (binary-path
                            injection; no metachar interpretation)

Severity is context-adjusted:
  * a quoting/casting guard marker on the same line (shlex.quote,
    escapeshellarg/escapeshellcmd, Shellwords/shellescape,
    shell-quote, int()/float()/parseInt/Number()/strconv.Atoi cast,
    allowlist match) downgrades the finding one tier -> LOW for
    human review, not suppression
  * test/spec/fixture paths downgrade one tier
  * comment lines and block-comment bodies are skipped
  * literal-only command args stay silent

Multi-line subprocess calls are handled with a bounded lookahead: when
a subprocess-family call leaves parens unbalanced, up to 4 following
lines are consumed (until the call closes) to detect shell=True and
interpolation inside the command expression.

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

  cmdi_prescan.py <file-or-dir> [--json]
  cmdi_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", ".sh",
    ".pl", ".pm", ".groovy", ".ps1",
)

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)

# same-line guard markers — presence downgrades one tier (a human still
# checks the quoting/cast actually covers the injected segment)
GUARD = re.compile(
    r"shlex\.quote|escapeshellarg|escapeshellcmd|Shellwords|"
    r"shellescape|shell_quote|shellquote|quote_arg|"
    r"CommandLineToArgv|allowlist|whitelist|"
    r"\bint\s*\(|\bfloat\s*\(|parseInt|parseFloat|Number\s*\(|"
    r"strconv\.Atoi|strconv\.Parse|"
    r"re\.(?:match|fullmatch)|Pattern\.matches|matches\s*\(|"
    r"sanitize|Sanitize|escape_arg|URI\.escape",
    re.I)

# request-controlled source markers (cross-stack)
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)|"
    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")

# --- sink families -----------------------------------------------------------

# explicit shell wrapper: sh -c "..." / cmd /c ... / powershell -Command,
# including array forms ['sh','-c',x] and exec.Command("sh","-c",x)
SHELL_WRAP = re.compile(
    r"exec\.Command(?:Context)?\s*\(\s*['\"](?:sh|bash|cmd(?:\.exe)?|"
    r"powershell|pwsh)['\"]|"
    r"['\"](?:sh|bash|zsh|cmd(?:\.exe)?|powershell|pwsh)['\"]\s*,\s*"
    r"\[?\s*['\"](?:-c|/c|-Command|-EncodedCommand|-enc)['\"]|"
    r"\b(?:sh|bash|zsh|cmd(?:\.exe)?|powershell|pwsh)\s+"
    r"(?:-c|/c|-Command|-EncodedCommand|-enc)\b|"
    r"Process\.Start\s*\(\s*['\"](?:cmd(?:\.exe)?|powershell|pwsh|"
    r"sh|bash)['\"]|"
    r"new\s+ProcessBuilder\s*\(\s*['\"](?:sh|bash|cmd)['\"]")

# subprocess family — shell only when shell=True is present
SUBPROCESS = re.compile(
    r"\bsubprocess\s*\.\s*(?:call|run|Popen|check_output|check_call|"
    r"getoutput|getstatusoutput)\s*\(|"
    r"\basyncio\s*\.\s*create_subprocess_(?:shell|exec)\s*\(|"
    r"\bsubprocess_shell\s*\(|\bsubprocess_exec\s*\(|"
    r"\bloop\s*\.\s*subprocess_(?:shell|exec)\s*\(")

# always-shell exec sinks (arg is a shell command string)
SHELL_EXEC = re.compile(
    r"\bos\s*\.\s*(?:system|popen|popen2|popen3|popen4)\s*\(|"
    r"\bcommands\s*\.\s*(?:getoutput|getstatusoutput)\s*\(|"
    r"\bpopen2\s*\.\s*(?:popen2|popen3|popen4|Popen3|Popen4)\s*\(|"
    r"\bchild_process\s*\.\s*(?:exec|execSync)\s*\(|"
    r"\bsystem\s*\(|\bpassthru\s*\(|"
    r"\bshell_exec\s*\(|\bpopen\s*\(|\bproc_open\s*\(|"
    r"\bpcntl_exec\s*\(|\bposix_spawn\s*\(|"
    r"\bRuntime\s*\.\s*(?:getRuntime\s*\(\s*\)\s*\.\s*)?exec\s*\(|"
    r"\bIO\s*\.\s*popen\s*\(|\bOpen3\s*\.\s*\w*\s*\(|"
    r"\bopen\s*\(\s*\w+\s*,\s*['\"][^'\"|]*\||"          # perl open(F,"c|")
    r"\bWinExec\s*\(|\bShellExecute\w*\s*\(|"
    r"\bQProcess\s*::\s*execute\s*\(")

# bare exec(/execSync( — only a shell sink when the file plausibly uses
# child_process (JS) or the language has exec as a builtin (php/pl/rb);
# a bare exec( helper in JS is very often a user-defined function
BARE_EXEC = re.compile(r"(?<![\w./])(?:exec|execSync)\s*\(")

# groovy / misc string.execute()
STR_EXECUTE = re.compile(
    r"(['\"`])((?:(?!\1)[^\n])*)\1\s*\.\s*"
    r"(?:execute|executeWaitOrKill)\s*\(")

# eval / dynamic-compile sinks (bare exec( handled per-ext below)
EVAL_SINK = re.compile(
    r"(?<![\w.])eval\s*\(|"
    r"\bnew\s+Function\s*\(|"
    r"\bvm\s*\.\s*runIn(?:ThisContext|NewContext|Context)\s*\(|"
    r"\binstance_eval\s*\(|\bmodule_eval\s*\(|\bclass_eval\s*\(|"
    r"->\s*eval\s*\(|\bcreate_function\s*\(|"
    r"\.\s*evaluate\s*\(|\bEval\s*\.\s*me\s*\(|"
    r"\bScriptEngine\w*\s*\.\s*eval\s*\(|"
    r"(?<![\w.])compile\s*\(")
PY_EXEC_EVAL = re.compile(r"(?<![\w./])exec\s*\(")       # py builtin exec
PHP_STR_ASSERT = re.compile(r"\bassert\s*\(\s*['\"]")    # php<8 string assert

# evaluate() receivers that are DOM/test APIs, not script engines —
# matched against the text immediately before ".evaluate("
EVAL_SAFE_RECV = re.compile(
    r"(?:page|frame|element|locator|handle|cell|node|el|component|"
    r"view|browser|context|sheet|document|request|response|self|"
    r"this|model|record|obj)\s*$")

# no-shell exec family — argument injection only
SPAWN_SINK = re.compile(
    r"\bchild_process\s*\.\s*(?:spawn|spawnSync|execFile|execFileSync|"
    r"fork)\s*\(|"
    r"\bexec\s*\.\s*Command(?:Context)?\s*\(|"
    r"\bsyscall\s*\.\s*Exec\s*\(|\bProcess\s*\.\s*Start\s*\(|"
    r"\bnew\s+ProcessBuilder\s*\(|\bProcess\s*\.\s*spawn\s*\(|"
    r"\bos\s*\.\s*spawn[lvep]*\s*\(")

# bare spawn/execFile/fork — only a sink when the file plausibly uses
# child_process (JS) or the language builtin (ruby Kernel#spawn)
BARE_SPAWN = re.compile(
    r"(?<![\w./])(?:spawn|spawnSync|execFile|execFileSync|fork)\s*\(")

# download piped to a shell/interpreter
PIPE_SHELL = re.compile(
    r"\b(?:curl|wget|fetch|Invoke-WebRequest|iwr)\b[^|;]*\|\s*"
    r"(?:sudo\s+)?(?:sh|bash|zsh|dash|python\d?|perl|ruby|node|pwsh|"
    r"powershell)\b",
    re.I)

# shell-language interpolation inside backticks / %x / qx
SHELL_INTERP = re.compile(
    r"`[^`\n]*(?:\$\{|#\{|\$\w|@\w|\$\()[^`\n]*`|"
    r"%x[\{\(\[<][^\}\)\]>]*(?:#\{|\$\w)[^\}\)\]>]*[\}\)\]>]|"
    r"\bqx[\{\(\[<\/][^\}\)\]>]*\$\w")

# shell (.sh) eval on a variable
SH_EVAL = re.compile(r"\beval\s+['\"$]")

# interpolated f-string / format-brace literal
INTERP_LIT = re.compile(
    r"[fb]?['\"][^'\"]*\{[^}]*\}[^'\"]*['\"]|"
    r"`[^`]*\$\{[^`]*`|\"[^\"]*[#$@]\{[^\"]*\"|\"[^\"]*\$[\w{]")

# join-arg names that smell like external/user data
ARG_HINT = re.compile(
    r"req|request|param|query|user|input|arg|target|host|url|uri|"
    r"domain|ip\b|file|path|name|cmd|command|branch|repo|env|"
    r"remote|client|submitted|filename|script",
    re.I)


def _strip_line_comment(line, ext):
    """Return code before eol comment, or None if line is a comment."""
    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", ".sh", ".pl", ".pm"):
        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 _call_window(lines, start_i, code):
    """Text of a possibly multi-line call: consume following raw lines
    until paren balance closes or 4 extra lines are used."""
    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 _strip_literals(e):
    """Remove quoted segments that carry no interpolation. Interpolated
    literals ($ { } # inside ""/``/f-strings) are kept so they still
    count as non-literal."""
    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 command expression non-literal / interpolated?"""
    if INTERP_LIT.search(expr):
        return True
    e = _strip_literals(expr)
    e = e.strip(" \t,;)]}'\"`")
    if not e:
        return False
    return bool(re.search(r"[A-Za-z_$][\w$.\[\]]*|\+|%|\$\(|#\{", e))


def _args_tail(code, m):
    """Everything after the sink's '(' — rough whole-args slice."""
    return code[m.end():]


def _user_args(expr):
    """Args text contains a user-data-flavoured non-literal?"""
    return bool(ARG_HINT.search(_strip_literals(expr)))


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 = []
    has_cp = "child_process" in "".join(lines)
    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 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))

        has_req = REQ_SRC.search(code)
        has_env = ENV_SRC.search(code)
        is_import = IMPORT_LINE.search(code)
        wrapped = False  # shell-wrapper class already claimed this line

        # class 0 — shell wrapper with a non-literal tail
        wm = SHELL_WRAP.search(code)
        if wm and not is_import:
            tail = _args_tail(code, wm)
            if has_req:
                add("CI-REQ-EXEC", "HIGH",
                    "request-controlled value reaches shell wrapper")
                add("CI-SHELL-WRAP", "HIGH",
                    "shell wrapper given request-controlled command")
                wrapped = True
            elif _nonliteral(tail):
                add("CI-SHELL-WRAP", "MEDIUM",
                    "shell wrapper given a non-literal command tail")
                wrapped = True

        # class 1 — subprocess family: shell=True decides the tier
        if not wrapped:
            for sm in SUBPROCESS.finditer(code):
                window = _call_window(lines, i, code[sm.start():])
                shell_on = re.search(r"shell\s*=\s*True", window) or \
                    "subprocess_shell" in sm.group(0) or \
                    "create_subprocess_shell" in sm.group(0)
                # command expression = window after the call's '(',
                # minus k=v kwargs (shell=True/check=True/...)
                args_text = window[window.index("(") + 1:]
                args_text = re.sub(r",?\s*\b\w+\s*=\s*[^,\)]*", "",
                                   args_text)
                nonlit = _nonliteral(args_text)
                if has_req:
                    add("CI-REQ-EXEC", "HIGH",
                        "request-controlled value reaches subprocess")
                if shell_on and nonlit:
                    add("CI-SHELL-TRUE", "HIGH",
                        "shell=True with non-literal command")
                elif shell_on:
                    add("CI-SHELL-TRUE", "LOW",
                        "shell=True on literal command — metachar smell")
                elif nonlit:
                    add("CI-EXEC-ARG", "LOW",
                        "non-literal command without shell=True — "
                        "binary-path/arg injection review")

        # class 2 — always-shell exec sinks
        if not is_import and not wrapped:
            sink_ms = list(SHELL_EXEC.finditer(code))
            if ext in (".php", ".pl", ".pm", ".rb") or has_cp:
                sink_ms += BARE_EXEC.finditer(code)
            for em in sink_ms:
                arg = _args_tail(code, em)
                if has_req:
                    add("CI-REQ-EXEC", "HIGH",
                        "request-controlled value reaches shell exec")
                elif has_env:
                    add("CI-ENV-EXEC", "LOW",
                        "environment value reaches shell exec")
                elif _nonliteral(arg):
                    add("CI-EXEC-NONLIT", "MEDIUM",
                        "shell-exec sink called with non-literal arg")

        # class 2b — "string".execute() (groovy et al)
        for gm in STR_EXECUTE.finditer(code):
            if "$" in gm.group(2) or "#{" in gm.group(2) or \
                    "@{" in gm.group(2):
                add("CI-EXEC-NONLIT",
                    "HIGH" if has_req else "MEDIUM",
                    "interpolated string passed to .execute()")

        # class 3 — eval / dynamic-compile sinks
        eval_ms = list(EVAL_SINK.finditer(code))
        if ext == ".py":
            eval_ms += PY_EXEC_EVAL.finditer(code)
        if ext == ".php":
            eval_ms += PHP_STR_ASSERT.finditer(code)
        for vm in eval_ms:
            if vm.group(0).lstrip().startswith("."):
                recv = code[:vm.start()].rstrip()
                if EVAL_SAFE_RECV.search(recv):
                    continue  # page.evaluate / cell.evaluate et al
            arg = _args_tail(code, vm)
            if has_req:
                add("CI-REQ-EXEC", "HIGH",
                    "request-controlled value reaches eval")
            elif _nonliteral(arg):
                add("CI-EVAL-NONLIT", "MEDIUM",
                    "dynamic eval of a non-literal expression")

        # class 4 — no-shell spawn/execFile family: arg injection
        if not wrapped:
            spawn_ms = list(SPAWN_SINK.finditer(code))
            if ext == ".rb" or has_cp:
                spawn_ms += BARE_SPAWN.finditer(code)
            for nm in spawn_ms:
                arg = _args_tail(code, nm)
                if has_req:
                    add("CI-REQ-EXEC", "HIGH",
                        "request-controlled value reaches exec")
                elif has_env:
                    add("CI-ENV-EXEC", "LOW",
                        "environment value reaches exec")
                elif _user_args(arg):
                    add("CI-SPAWN-ARG", "MEDIUM",
                        "user-flavoured arg reaches no-shell exec — "
                        "argument-injection review")
                elif _nonliteral(arg):
                    add("CI-SPAWN-ARG", "LOW",
                        "non-literal exec args — no shell, review only")

        # class 5 — download piped to a shell (markup / quoted-string
        # lines -> LOW: rendered install text, not execution; lines
        # carrying an exec/spawn/'-c' marker keep MEDIUM)
        pm = PIPE_SHELL.search(code)
        if pm:
            real_exec = re.search(
                r"['\"]-c['\"]|"
                r"\b(?:exec|execSync|spawn|spawnSync|execFile|"
                r"execFileSync|fork|system|popen|eval)\s*\(", code)
            if ext != ".sh" and not real_exec and (
                    re.search(r"<[a-zA-Z]", code)
                    or re.search(r"['\"`]", code[:pm.start()])):
                add("CI-PIPE-SHELL", "LOW",
                    "pipe-to-shell text inside string/markup — verify "
                    "it is display-only")
            else:
                add("CI-PIPE-SHELL", "MEDIUM",
                    "download piped to a shell/interpreter")

        # class 6 — shell-language backtick/%x/qx with interpolation
        if ext in (".php", ".rb", ".pl", ".pm", ".sh"):
            if SHELL_INTERP.search(code):
                add("CI-EXEC-NONLIT",
                    "HIGH" if has_req else "MEDIUM",
                    "interpolated value inside shell backtick/%x/qx")

        # class 7 — .sh eval on a variable
        if ext == ".sh" and SH_EVAL.search(code) and "$" in code:
            add("CI-EXEC-NONLIT",
                "HIGH" if has_req else "MEDIUM",
                "shell eval of a variable")
    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 = ["cmdi_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 -> exec ---
    ("a.py", "os.system('ping ' + request.args['h'])\n",
     {"CI-REQ-EXEC"}),
    ("a.js",
     "const {exec} = require('child_process');\n"
     "exec('convert ' + req.query.f);\n", {"CI-REQ-EXEC"}),
    ("a.php", "system('ping ' . $_GET['ip']);\n", {"CI-REQ-EXEC"}),
    ("a.java",
     "Runtime.getRuntime().exec(\"cat \" + request.getParameter(\"f\"));\n",
     {"CI-REQ-EXEC"}),
    ("a.go", "exec.Command(\"sh\", \"-c\", c.Param(\"cmd\"))\n",
     {"CI-REQ-EXEC", "CI-SHELL-WRAP"}),
    ("a.rb", "system(\"ls #{params[:d]}\")\n", {"CI-REQ-EXEC"}),
    ("a.py", "subprocess.run('ls ' + request.args['d'], shell=True)\n",
     {"CI-REQ-EXEC", "CI-SHELL-TRUE"}),
    ("a.js",
     "const cp = require('child_process');\n"
     "exec('git clone ' + req.body.url, cb);\n",
     {"CI-REQ-EXEC"}),
    ("a.py", "eval(request.data)\n", {"CI-REQ-EXEC"}),
    ("a.js", "eval(req.query.expr);\n", {"CI-REQ-EXEC"}),
    ("a.py", "os.system('ping ' + request.args['h'])  # nosec\n",
     {"CI-REQ-EXEC"}),
    ("a.py", "os.system('ping ' + str(int(request.args['n'])))\n",
     {"CI-REQ-EXEC"}),                                # -> LOW
    # --- shell wrap ---
    ("a.py", "subprocess.run(['sh','-c',cmd])\n", {"CI-SHELL-WRAP"}),
    ("a.js", "execFile('bash', ['-c', script]);\n", {"CI-SHELL-WRAP"}),
    ("a.cs", "Process.Start(\"cmd.exe\", \"/c \" + args);\n",
     {"CI-SHELL-WRAP"}),
    ("a.sh", "bash -c \"$user_cmd\"\n", {"CI-SHELL-WRAP"}),
    ("a.sh", "sh -c 'echo done'\n", set()),
    ("a.py", "subprocess.run(['sh','-c','echo ok'])\n", set()),
    # --- subprocess shell=True ---
    ("a.py", "subprocess.run(cmd, shell=True)\n", {"CI-SHELL-TRUE"}),
    ("a.py", "subprocess.Popen(f'ls {d}', shell=True)\n",
     {"CI-SHELL-TRUE"}),
    ("a.py",
     "subprocess.run(\n    'tar xf ' + name,\n    shell=True)\n",
     {"CI-SHELL-TRUE"}),                               # lookahead
    ("a.py", "subprocess.run('ls -la', shell=True)\n",
     {"CI-SHELL-TRUE"}),                               # -> LOW
    ("a.py", "subprocess.run(['ls','-la'])\n", set()),
    ("a.py", "subprocess.run(cmd_list)\n", {"CI-EXEC-ARG"}),   # -> LOW
    ("a.py", "asyncio.create_subprocess_shell(f'echo {x}')\n",
     {"CI-SHELL-TRUE"}),
    ("a.py", "subprocess.check_output('wc -l ' + f, shell=True)\n",
     {"CI-SHELL-TRUE"}),
    ("a.py", "subprocess.call('make clean', shell=True)\n",
     {"CI-SHELL-TRUE"}),                               # -> LOW
    # --- always-shell sinks ---
    ("a.py", "os.system('ping ' + host)\n", {"CI-EXEC-NONLIT"}),
    ("a.py", "os.system('uptime')\n", set()),
    ("a.js",
     "const {exec} = require('child_process');\n"
     "exec(`git pull ${branch}`);\n", {"CI-EXEC-NONLIT"}),
    ("a.js",
     "const {exec} = require('child_process');\n"
     "exec('git status');\n", set()),
    ("a.php", "shell_exec($cmd);\n", {"CI-EXEC-NONLIT"}),
    ("a.php", "exec('ls -la');\n", set()),
    ("a.rb", "system(\"tar xf #{fname}\")\n", {"CI-EXEC-NONLIT"}),
    ("a.pl", "system(\"ping $host\");\n", {"CI-EXEC-NONLIT"}),
    ("a.java", "Runtime.getRuntime().exec(\"cat \" + f);\n",
     {"CI-EXEC-NONLIT"}),
    ("a.java", "Runtime.getRuntime().exec(\"/usr/bin/uptime\");\n",
     set()),
    ("a.groovy", "\"git checkout ${b}\".execute()\n",
     {"CI-EXEC-NONLIT"}),
    ("a.py", "os.popen('cat ' + fname).read()\n", {"CI-EXEC-NONLIT"}),
    ("a.py", "os.system('ping ' + shlex.quote(host))\n",
     {"CI-EXEC-NONLIT"}),                              # -> LOW
    ("a.py", "os.system('nproc ' + os.environ['N'])\n",
     {"CI-ENV-EXEC"}),                                 # -> LOW
    # --- eval family ---
    ("a.py", "eval(expr)\n", {"CI-EVAL-NONLIT"}),
    ("a.py", "eval('1 + 1')\n", set()),
    ("a.js", "new Function('x', body);\n", {"CI-EVAL-NONLIT"}),
    ("a.js", "vm.runInNewContext(code, sandbox);\n", {"CI-EVAL-NONLIT"}),
    ("a.rb", "instance_eval(snippet)\n", {"CI-EVAL-NONLIT"}),
    ("a.php", "eval($code);\n", {"CI-EVAL-NONLIT"}),
    ("a.php", "assert('$x > 0');\n", {"CI-EVAL-NONLIT"}),
    ("a.groovy", "new GroovyShell().evaluate(script)\n",
     {"CI-EVAL-NONLIT"}),
    ("a.py", "code = compile(src, '<s>', 'exec')\n", {"CI-EVAL-NONLIT"}),
    ("a.py", "exec(CONF)\n", {"CI-EVAL-NONLIT"}),
    # --- no-shell spawn family ---
    ("a.js",
     "const {spawn} = require('child_process');\n"
     "spawn('git', ['clone', url]);\n", {"CI-SPAWN-ARG"}),
    ("a.js",
     "const {spawn} = require('child_process');\n"
     "spawn('ls', ['-la']);\n", set()),
    ("a.go", "exec.Command(\"git\", \"clone\", repoURL)\n",
     {"CI-SPAWN-ARG"}),
    ("a.go", "exec.Command(\"ls\", \"-la\")\n", set()),
    ("a.cs", "Process.Start(\"ffmpeg\", args);\n", {"CI-SPAWN-ARG"}),
    ("a.cs", "Process.Start(\"notepad.exe\");\n", set()),
    ("a.java", "new ProcessBuilder(\"sh\",\"-c\",c);\n",
     {"CI-SHELL-WRAP"}),
    ("a.java", "new ProcessBuilder(\"git\", \"status\");\n", set()),
    ("a.rb", "Process.spawn('convert', infile, outfile)\n",
     {"CI-SPAWN-ARG"}),
    ("a.pl", "qx(cat $f)\n", {"CI-EXEC-NONLIT"}),
    ("a.rb", "out = `ls #{dir}`\n", {"CI-EXEC-NONLIT"}),
    ("a.rb", "out = `ls -la`\n", set()),
    ("a.php", "$o = `ping $h`;\n", {"CI-EXEC-NONLIT"}),
    ("a.js", "const s = `git pull ${b}`;\n", set()),   # template lit ok
    # --- pipe-to-shell ---
    ("a.sh", "curl -sL https://x.sh | bash\n", {"CI-PIPE-SHELL"}),
    ("a.sh", "wget -qO- $URL | sudo sh\n", {"CI-PIPE-SHELL"}),
    ("a.js",
     "child_process.exec('curl ' + u + ' | sh');\n",
     {"CI-EXEC-NONLIT", "CI-PIPE-SHELL"}),
    ("a.sh", "curl -sL https://x.sh -o install.sh\n", set()),
    # --- shell eval ---
    ("a.sh", "eval \"$cmd\"\n", {"CI-EXEC-NONLIT"}),
    ("a.sh", "eval echo done\n", set()),
    # --- comments / clean ---
    ("a.py", "# os.system('rm ' + x)\n", set()),
    ("a.js",
     "const {exec} = require('child_process');\n"
     "/* exec('rm ' + f); */\n", set()),
    ("a.js", "exec(KEEPER, 'fn', args);\n", set()),   # local helper
    ("a.py", "import subprocess\n", set()),
    ("a.py", "def f():\n    return subprocess.run(['make'])\n", set()),
    ("a.ts", "const re = /exec\\((.*?)\\)/;\n", set()),
]


def run_selftest():
    import tempfile
    fails = []
    for i, (name, src, expect) in enumerate(SELFTEST):
        with tempfile.TemporaryDirectory() as td:
            p = os.path.join(td, name)
            os.makedirs(os.path.dirname(p), exist_ok=True)
            open(p, "w").write(src)
            got = {f.code for f in scan_file(p, name)}
            missing = expect - got
            extra = got - expect
            if missing or extra:
                fails.append(("v%d %s" % (i, name),
                              "missing=%s extra=%s" % (missing, extra)))
    if fails:
        for f_ in fails:
            print("SELFTEST FAIL", f_)
        return 1
    print("SELFTEST PASS (%d vectors)" % len(SELFTEST))
    return 0


def main():
    args = [a for a in sys.argv[1:] if a != "--json"]
    want_json = "--json" in sys.argv
    if "--selftest" in sys.argv:
        return run_selftest()
    if not args:
        print(__doc__)
        return 2
    root = args[0]
    targets = find_targets(root)
    findings = []
    for p in targets:
        rel = os.path.relpath(p, root) if os.path.isdir(root) \
            else os.path.basename(p)
        findings += scan_file(p, rel)
    if want_json:
        print(json.dumps([f.as_dict() for f in findings], indent=1))
    else:
        print(render(findings, targets))
    return 1 if any(f.sev in ("HIGH", "MEDIUM") for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
