#!/usr/bin/env python3
"""proto_prescan.py — deterministic prototype-pollution / unsafe-merge
pre-scan for JavaScript and TypeScript (CWE-1321 / CWE-915).

ARION service artifact. Scans .js/.mjs/.cjs/.jsx/.ts/.tsx sources for
the shapes that let an attacker write to Object.prototype or smuggle
special keys through merge/set paths, checkable offline, line- and
window-based, without executing code:

  * direct special-key writes — obj.__proto__ = x,
    o["__proto__"] = v, a.constructor.prototype.b = c,
    Object.defineProperty(o,'__proto__',…) — HIGH
  * merge/extend/assign calls fed an attacker-shaped source —
    req.*, request.*, .body/.query/.params, JSON.parse, .json(),
    payload/input/ctx/event args — HIGH (sink name may be a local
    helper; the source is what makes it reachable)
  * dynamic path setters — _.set / _.update / _.setWith /
    dotProp.set / setValue / setPath — HIGH when the path argument
    is attacker-derived, MEDIUM for any non-literal path
  * hand-rolled deep merges — for-in / Object.keys loops that copy
    into another object via bracket writes with NO __proto__ /
    constructor / prototype deny guard in scope — MEDIUM
  * known deep-merge library sinks — _.merge, _.mergeWith,
    _.defaultsDeep, _.assignIn, $.extend(true, …), deepmerge(),
    deepAssign/deepExtend, mergeDeep — MEDIUM regardless of args
    (patched-version state is unknowable offline)
  * Object.assign(target, attacker-source) — [[Set]] semantics
    invoke the __proto__ setter on the target — MEDIUM
  * for-in copy loops (dest[k] = src[k]) with no own-property guard —
    inherited keys plus special keys ride along — LOW (suppressed
    where the same block already flagged as a deep merge)
  * object spread of an attacker object — {…req.body} — the
    __proto__ own-key survives and feeds downstream merges — LOW
  * file-level hygiene — dynamic key writes / merge vocabulary with
    zero proto-guard vocabulary anywhere in the file — INFO

Guard model: a merge loop or path-write counts as guarded when
`__proto__`/`constructor`/`prototype` deny-comparisons, hasOwnProperty /
Object.hasOwn checks, Object.create(null), or named safe-key helpers
appear in the same loop body, its enclosing function, or the 8 lines
above the loop. Comments are stripped (string literals are kept —
guard vocabulary lives inside them). Test/spec/fixture paths
downgrade one tier (INFO drops off).

Honest scope: PRE-SCAN, not an audit. Line/window-based; no dataflow.
A FLAG means "this merge/set path deserves human review"; a clean
report is not proof the codebase is pollution-free — taint through
wrappers, exported helpers, or imported merges is invisible here.

Usage:
  proto_prescan.py <file-or-dir> [--json]
  proto_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}

SKIP_DIRS = {".git", "node_modules", "vendor", "__pycache__", ".venv",
             "venv", "dist", "build", ".tox", ".mypy_cache", "target",
             ".idea", ".next", "coverage", "docs", "Documentation"}

JS_EXT = {".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx"}

TEST_PATH_RE = re.compile(
    r"(?i)(^|/)(test|tests|spec|specs|fixtures?|mocks?|__tests__|"
    r"__mocks__|testdata|e2e|stubs?)(/|$)|(^|/)test_|_test\.|"
    r"\.test\.|\.spec\.|spec_|\.d\.ts$|\.min\.js$")

# ------------------------------------------------------------ vocabulary

# guard vocabulary — presence means the author thought about the keys
GUARD_RE = re.compile(
    r"(?i)(__proto__|constructor\b|prototype\b)[\w$.\[\]'\"\s]*"
    r"(===|!==|==|!=)|"
    r"(===|!==|==|!=)\s*['\"]?"
    r"(__proto__|constructor|prototype)|"
    r"['\"](__proto__|constructor|prototype)['\"]|"
    r"hasOwnProperty|Object\.hasOwn\b|Object\.getOwnPropertyNames|"
    r"Object\.create\s*\(\s*null|"
    r"\b(blacklist|denylist|safekey|safe_key|isSafeKey|validKey|"
    r"forbiddenKeys?|blockedKeys?|unsafeKey|pollut)")

# attacker-shaped source vocabulary inside call arguments
REQ_SRC_RE = re.compile(
    r"(?i)(\breq\b|\brequest\b|\bctx\b|\bcontext\.(request|body)|"
    r"\.body\b|\.query\b|\.params\b|\.param\b|JSON\.parse|\.json\(\)|"
    r"\.text\(\)|payload|\binput\b|user_?input|userData|event\b|"
    r"\.searchParams|formData|\.post\b|\bargs\b)")

# merge/extend/assign call names — local helpers included. Bare
# names only: `client.extend(`, `obj.merge(` are method calls on a
# receiver, not merge helpers — receiver-prefixed library merges
# (_.merge, $.extend) live in LIB_DEEPMERGE_RE.
MERGE_CALL_RE = re.compile(
    r"(?i)(?<![\w$.])(deepmerge|merge|extend|deepextend|deepassign|"
    r"deep_assign|deep_extend|assigndeep|assign_deep|mixin|"
    r"defaultsdeep|defaults_deep|mergedeep|merge_deep|"
    r"objectassign|extenddeep|applydefaults|deepdefaults|"
    r"deepcopy|deep_copy|mergeoptions|mergeoptions|"
    r"mergesettings|mergeconfig|mergeinto)\s*\(")

# known vulnerable-class library sinks (bare deepAssign/deepExtend/
# mergeDeep are common LOCAL helper names — they live in MERGE_CALL_RE
# only and flag when fed a request source)
LIB_DEEPMERGE_RE = re.compile(
    r"(?i)((?<![\w$])_\s*\.\s*(merge|mergeWith|defaultsDeep|"
    r"assignIn|extend|assignDeep|setWith)|"
    r"lodash\.\s*(merge|mergeWith|defaultsDeep|assignIn)|"
    r"(?:\$|jQuery)\s*\.\s*extend\s*\(\s*true|"
    r"\bdeepmerge\s*\(|\bmerge\.deep\s*\(|\bdefaultsDeep\s*\(|"
    r"\bextend\s*\(\s*true\s*,)")

# dynamic path setters — _.set(o, path, v), setValue(o, path, v)…
PATHSET_RE = re.compile(
    r"(?i)((?<![\w$])_\s*\.\s*(set|update|setWith)|"
    r"lodash\.\s*(set|update)|"
    r"\bdotProp\.(set|setValue)|"
    r"\b(setValue|setPath|setIn|putValue|assignPath|"
    r"setDeepValue|deepSet|objectPath\.set)\s*\()")

# special-key writes
PROTO_WRITE_RES = [
    re.compile(r"\.__proto__\s*(?:\.[\w$]+|\[\s*[^\]]+\])*\s*=[^=]"),
    re.compile(r"\[\s*['\"]__proto__['\"]\s*\]\s*=[^=]"),
    re.compile(r"\.constructor\s*\.\s*prototype\s*(?:\.[\w$]+|"
               r"\[\s*[^\]]+\])+\s*=[^=]"),
    re.compile(r"\.constructor\s*\[\s*['\"]prototype['\"]\s*\]"
               r"\s*(?:\.[\w$]+|\[\s*[^\]]+\])*\s*=[^=]"),
    re.compile(r"defineProperty\s*\([^,]+,\s*['\"]__proto__['\"]"),
    re.compile(r"Object\.defineProperties\s*\([^,]+,\s*\{[^}]*"
               r"__proto__"),
]

# loop headers that enumerate keys
FORIN_RE = re.compile(
    r"\bfor\s*\(\s*(?:const|let|var)?\s*\w+\s+in\s+")
KEYS_ENUM_RE = re.compile(
    r"\bObject\.(keys|entries|getOwnPropertyNames)\s*\(|"
    r"\bfor\s*\(\s*(?:const|let|var)?\s*(?:\[[^\]]+\]|\w+)\s+of\s+"
    r"Object\.(keys|entries)\s*\(|"
    r"\.forEach\s*\(")

# dynamic key write: IDENT [ IDENT-or-expr ] = (not comparison)
BRACKET_WRITE_RE = re.compile(
    r"[A-Za-z_$][\w$.\[\]'\"]*\s*\[\s*[A-Za-z_$][\w$.\[\]'\"]*\s*\]"
    r"\s*(?:\.[\w$]+|\[\s*[^\]]+\])*\s*=[^=]")

OWNPROP_RE = re.compile(r"hasOwnProperty|Object\.hasOwn\b|"
                        r"Object\.keys|Object\.getOwnPropertyNames|"
                        r"\.has\s*\(")

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

# spread-source vocab is tighter than REQ_SRC — `{...request}`/
# `{...args}`/`{...parameters}` are idiomatic builder patterns, not
# request bodies. Only high-signal request tokens flag. In .jsx/.tsx
# files prop-spread ({...props}, {...item}) is the dominant idiom —
# only explicit request/body/parse sources flag there.
SPREAD_SRC_RE = re.compile(
    r"(?i)\{\s*\.\.\.\s*(\breq\b|request\.|c\.req\b|ctx\b|"
    r"\.body\b|\.query\b|\.params\b|JSON\.parse|\.json\(\)|"
    r"\bevent\b|formData|searchParams|user_?input|\.post\b)")
SPREAD_JSX_RE = re.compile(
    r"(?i)\{\s*\.\.\.\s*(\breq\b|\brequest\b|c\.req\b|ctx\.req\b|"
    r"\.body\b|\.query\b|\.params\b|JSON\.parse|\.json\(\))")

OBJASSIGN_RE = re.compile(r"\bObject\.assign\s*\(")

FINDING = []


class Finding:
    __slots__ = ("file", "line", "sev", "code", "msg")

    def __init__(self, file, line, sev, code, msg):
        self.file = file
        self.line = line
        self.sev = sev
        self.code = code
        self.msg = msg

    def as_dict(self):
        return {"file": self.file, "line": self.line, "sev": self.sev,
                "code": self.code, "msg": self.msg}


# ------------------------------------------------------------ text utils

def strip_comments(lines):
    """Remove // and /* */ comments, respecting quotes and templates.
    Returns code lines aligned with the input (same count)."""
    out = []
    in_block = False
    for raw in lines:
        res = []
        i, n = 0, len(raw)
        quote = None
        while i < n:
            ch = raw[i]
            nxt = raw[i + 1] if i + 1 < n else ""
            if in_block:
                if ch == "*" and nxt == "/":
                    in_block = False
                    i += 2
                    continue
                i += 1
                continue
            if quote:
                if ch == "\\":
                    i += 2
                    continue
                if ch == quote:
                    quote = None
                res.append(ch)
                i += 1
                continue
            if ch in "'\"`":
                quote = ch
                res.append(ch)
                i += 1
                continue
            if ch == "/" and nxt == "/":
                break
            if ch == "/" and nxt == "*":
                in_block = True
                i += 2
                continue
            res.append(ch)
            i += 1
        out.append("".join(res))
    return out


def call_window(lines, i):
    """Return the call text starting at line i's first unbalanced '(',
    joined across up to 6 lines until parens balance."""
    text = lines[i]
    depth = 0
    started = False
    for j in range(i, min(i + 6, len(lines))):
        seg = lines[j] if j != i else lines[j]
        for ch in seg:
            if ch == "(":
                depth += 1
                started = True
            elif ch == ")":
                depth -= 1
        if started and depth <= 0:
            text = "\n".join(lines[i:j + 1])
            break
    return text


def split_args(text):
    """Split the argument list of a call text (first '(' to matching
    ')') on top-level commas, respecting quotes/parens/brackets."""
    try:
        start = text.index("(")
    except ValueError:
        return []
    depth, q, cur, args = 0, None, "", []
    for ch in text[start:]:
        if q:
            cur += ch
            if ch == q:
                q = None
            continue
        if ch in "'\"`":
            q = ch
            cur += ch
            continue
        if ch in "([{":
            depth += 1
            if depth == 1:
                continue
            cur += ch
            continue
        if ch in ")]}":
            depth -= 1
            if depth == 0:
                break
            cur += ch
            continue
        if ch == "," and depth == 1:
            args.append(cur)
            cur = ""
            continue
        cur += ch
    if cur.strip():
        args.append(cur)
    return args


def loop_body(lines, i):
    """Extract the body of a loop starting at line i via brace
    matching. Returns (body_text, end_index)."""
    joined = "\n".join(lines[i:i + 40])
    depth, started = 0, False
    out = []
    for ch in joined:
        if ch == "{":
            depth += 1
            started = True
            if depth == 1:
                continue
        elif ch == "}":
            depth -= 1
            if started and depth == 0:
                return "".join(out), i
            if depth < 0:
                break
        if started:
            out.append(ch)
    return "".join(out), i


def has_guard(text):
    return bool(GUARD_RE.search(text))


def is_literal(s):
    s = s.strip()
    return (len(s) >= 2 and s[0] in "'\"`" and s[-1] == s[0]) or \
           re.fullmatch(r"[\w$.\-]+", s) is not None and \
           s.replace(".", "").replace("-", "").isdigit()


# ------------------------------------------------------------ scanning

def scan_lines(path, raw_lines):
    ext = os.path.splitext(path)[1].lower()
    lines = strip_comments(raw_lines)
    test_path = bool(TEST_PATH_RE.search(path.replace("\\", "/")))
    findings = []
    file_text = "\n".join(lines)
    file_guarded = has_guard(file_text)

    for i, line in enumerate(lines):
        if not line.strip():
            continue
        sev_shift = 1 if test_path else 0

        def emit(sev, code, msg, li=i):
            order = list(SEV_ORDER)
            try:
                idx = order.index(sev) + sev_shift
            except ValueError:
                idx = 3
            if idx >= len(order):
                return
            findings.append(Finding(path, li + 1, order[idx], code, msg))

        # ---- special-key writes -------------------------------------
        for rx in PROTO_WRITE_RES:
            if rx.search(line):
                # comparisons and deny-checks are guards, not writes
                if re.search(r"(===|!==|==|!=|\.includes\s*\(|"
                             r"\.indexOf\s*\(|\bthrow\b|\breturn\b|"
                             r"\bcontinue\b)", line):
                    break
                emit("HIGH", "PP-WRITE",
                     "direct write to __proto__/constructor.prototype — "
                     "prototype pollution sink")
                break

        # ---- dynamic path setters -----------------------------------
        m = PATHSET_RE.search(line)
        if m:
            win = call_window(lines, i)
            args = split_args(win[win.find("("):])
            patharg = args[1] if len(args) > 1 else ""
            if patharg and not is_literal(patharg):
                if REQ_SRC_RE.search(patharg):
                    emit("HIGH", "PP-PATH-SET",
                         "dynamic path setter fed an attacker-derived "
                         "path — `a.b.__proto__.c` shapes reach "
                         "Object.prototype")
                else:
                    emit("MEDIUM", "PP-PATH-SET",
                         "dynamic path setter with non-literal path — "
                         "verify the path cannot carry __proto__ /"
                         " constructor segments")

        # ---- merge calls --------------------------------------------
        lm = LIB_DEEPMERGE_RE.search(line)
        gm = MERGE_CALL_RE.search(line)
        om = OBJASSIGN_RE.search(line)
        if lm or gm or om:
            win = call_window(lines, i)
            args = split_args(win[win.find("("):])
            src_hit = any(
                REQ_SRC_RE.search(a) for a in args[1:]
                if not a.strip().startswith(("{", "["))) \
                if len(args) > 1 else False
            if om:
                if src_hit:
                    emit("MEDIUM", "PP-ASSIGN-REQ",
                         "Object.assign copies attacker keys via "
                         "[[Set]] — a __proto__ own-key invokes the "
                         "prototype setter on the target")
            else:
                if src_hit:
                    emit("HIGH", "PP-MERGE-REQ",
                         "merge/extend call fed an attacker-controlled "
                         "source — deep merges copy __proto__ keys to "
                         "Object.prototype")
                elif lm:
                    emit("MEDIUM", "PP-MERGE-LIB",
                         "deep-merge library call — these merge "
                         "implementations copy __proto__ keys; verify "
                         "the source is never attacker-shaped")

        # ---- object spread of attacker source ------------------------
        sre = SPREAD_JSX_RE if ext in (".jsx", ".tsx") else SPREAD_SRC_RE
        if sre.search(line) and "..." in line:
            emit("LOW", "PP-SPREAD-REQ",
                 "object spread of an attacker-shaped object — the "
                 "__proto__ own-key survives and feeds downstream "
                 "merges")

        # ---- hand-rolled loops --------------------------------------
        if FORIN_RE.search(line) or KEYS_ENUM_RE.search(line):
            body, _ = loop_body(lines, i)
            if body and BRACKET_WRITE_RE.search(body):
                ctx = "\n".join(lines[max(0, i - 8):i]) + "\n" + body
                guarded = has_guard(ctx)
                # the enumerated object: `for (k in X)` / `Object.keys(X)`
                it = FORIN_RE.search(line)
                if it:
                    mobj = re.search(r"in\s+([\w$.]+)", line)
                    iter_obj = mobj.group(1) if mobj else ""
                else:
                    mobj = re.search(
                        r"Object\.(?:keys|entries|"
                        r"getOwnPropertyNames)\s*\(\s*([\w$.]+)",
                        line)
                    iter_obj = mobj.group(1) if mobj else ""
                writes = COPY_WRITE_RE.findall(body)
                copy_loop = any(
                    a != iter_obj and a != b for a, b in writes)
                # deep-merge signature: the loop BRANCHES on object-
                # ness of the copied value (typeof === 'object'),
                # calls a merge helper, or splits a key path. A
                # typeof === 'string'/'function' filter is a flat
                # copy, not a merge.
                recursive = bool(
                    re.search(r"typeof.{0,40}(?:===|==)\s*['\"]object",
                              body) or
                    re.search(r"(?i)(isObject|isPlainObject|"
                              r"\.split\s*\()", body) or
                    MERGE_CALL_RE.search(body))
                if guarded:
                    continue
                if recursive:
                    emit("MEDIUM", "PP-DEEPMERGE",
                         "hand-rolled recursive/deep merge with no "
                         "__proto__ / constructor / prototype deny "
                         "guard — special keys reach the target")
                elif copy_loop and not OWNPROP_RE.search(body):
                    emit("LOW", "PP-FORIN-COPY",
                         "key-enumeration copy into another object "
                         "with no own-property/special-key guard — "
                         "__proto__ own-keys from parsed input write "
                         "to the target's prototype")

    # ---- file-level hygiene ------------------------------------------
    has_finding = any(f.sev in ("HIGH", "MEDIUM") for f in findings)
    merge_vocab = bool(MERGE_CALL_RE.search(file_text)) or \
        bool(LIB_DEEPMERGE_RE.search(file_text)) or \
        bool(PATHSET_RE.search(file_text))
    if merge_vocab and not file_guarded and not test_path \
            and not has_finding:
        findings.append(Finding(
            path, 1, "INFO", "PP-NOGUARD",
            "file performs dynamic key writes/merges with zero "
            "proto-guard vocabulary anywhere — no __proto__ check, "
            "hasOwnProperty, Object.hasOwn, or null-prototype map"))

    findings.sort(key=lambda f: (SEV_ORDER.get(f.sev, 9), f.line))
    return findings


def scan(target):
    findings, scanned = [], 0
    if os.path.isfile(target):
        paths = [target]
    else:
        paths = []
        for root, dirs, files in os.walk(target):
            dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
            for fn in sorted(files):
                if os.path.splitext(fn)[1].lower() in JS_EXT:
                    paths.append(os.path.join(root, fn))
    for p in sorted(paths):
        try:
            with open(p, "r", encoding="utf-8", errors="replace") as fh:
                raw = fh.readlines()
        except (OSError, UnicodeError):
            continue
        scanned += 1
        findings.extend(scan_lines(p, raw))
    findings.sort(key=lambda f: (SEV_ORDER.get(f.sev, 9), f.file,
                                 f.line))
    return findings, scanned


def report_md(findings, scanned, target):
    counts = {s: 0 for s in SEV_ORDER}
    for f in findings:
        counts[f.sev] = counts.get(f.sev, 0) + 1
    out = ["# Prototype-pollution / unsafe-merge pre-scan",
           "",
           f"- target: `{target}`",
           f"- JS/TS files scanned: {scanned}",
           f"- findings: HIGH {counts['HIGH']} · MEDIUM {counts['MEDIUM']}"
           f" · LOW {counts['LOW']} · INFO {counts['INFO']}",
           "",
           "_Deterministic pre-scan — not an audit. Line/window-based; "
           "no dataflow. A clean report is not proof the codebase is "
           "pollution-free._",
           ""]
    if not findings:
        out.append("No findings.")
        return "\n".join(out)
    cur = None
    for f in findings:
        if f.sev != cur:
            cur = f.sev
            out += ["", f"## {cur}", ""]
        out.append(f"- `{f.file}:{f.line}` **{f.code}** — {f.msg}")
    return "\n".join(out)


SELFTEST = [
    # ---- PP-WRITE ---------------------------------------------------
    ("proto-attr-write", ".js",
     ['obj.__proto__.isAdmin = true;'],
     {"PP-WRITE:HIGH"}),
    ("proto-bracket-write", ".js",
     ["o['__proto__'] = payload;"],
     {"PP-WRITE:HIGH"}),
    ("ctor-proto-write", ".js",
     ['u.constructor.prototype.role = "admin";'],
     {"PP-WRITE:HIGH"}),
    ("proto-guard-ok", ".js",
     ["if (key === '__proto__' || key === 'constructor') continue;"],
     set()),
    ("proto-read-ok", ".js",
     ['const p = obj.__proto__;'],
     set()),
    ("proto-log-ok", ".js",
     ['console.log("warn: __proto__ key rejected");'],
     set()),
    ("defineprop-proto", ".js",
     ["Object.defineProperty(o, '__proto__', {value: x});"],
     {"PP-WRITE:HIGH"}),
    # ---- PP-PATH-SET -------------------------------------------------
    ("set-req-path", ".js",
     ["_.set(obj, req.params.path, value);"],
     {"PP-PATH-SET:HIGH"}),
    ("set-body-path", ".js",
     ["_.set(config, req.body.key, req.body.val);"],
     {"PP-PATH-SET:HIGH"}),
    ("set-dyn-path", ".js",
     ["_.set(obj, userPath, value);"],
     {"PP-PATH-SET:MEDIUM"}),
    ("set-literal-ok", ".js",
     ["_.set(obj, 'profile.name', name);"],
     {"PP-NOGUARD:INFO"}),
    ("dotprop-set", ".js",
     ["dotProp.set(store, req.query.k, v);"],
     {"PP-PATH-SET:HIGH"}),
    # ---- PP-MERGE-REQ / LIB ------------------------------------------
    ("merge-req-body", ".js",
     ["const cfg = merge(defaults, req.body);"],
     {"PP-MERGE-REQ:HIGH"}),
    ("deepmerge-req", ".js",
     ["_.merge(settings, JSON.parse(raw));"],
     {"PP-MERGE-REQ:HIGH"}),
    ("lodash-merge-plain", ".js",
     ["const o = _.merge({}, a, b);"],
     {"PP-MERGE-LIB:MEDIUM"}),
    ("deepmerge-lib", ".js",
     ["const out = deepmerge(x, y);"],
     {"PP-MERGE-LIB:MEDIUM"}),
    ("jquery-extend", ".js",
     ["$.extend(true, target, src);"],
     {"PP-MERGE-LIB:MEDIUM"}),
    ("merge-clean-ok", ".js",
     ["const cfg = merge(defaults, opts);"],
     {"PP-NOGUARD:INFO"}),
    ("extend-req", ".js",
     ["deepExtend(target, request.data);"],
     {"PP-MERGE-REQ:HIGH"}),
    # ---- PP-ASSIGN-REQ -----------------------------------------------
    ("assign-req-body", ".js",
     ["Object.assign(model, req.body);"],
     {"PP-ASSIGN-REQ:MEDIUM"}),
    ("assign-opts-ok", ".js",
     ["Object.assign({}, defaults, opts);"],
     set()),
    ("assign-jsonparse", ".js",
     ["Object.assign(cfg, JSON.parse(input));"],
     {"PP-ASSIGN-REQ:MEDIUM"}),
    # ---- PP-SPREAD-REQ -------------------------------------------------
    ("spread-req", ".js",
     ["const view = { ...req.body, ts: Date.now() };"],
     {"PP-SPREAD-REQ:LOW"}),
    ("spread-clean-ok", ".js",
     ["const merged = { ...defaults, ...opts };"],
     set()),
    # ---- PP-DEEPMERGE ---------------------------------------------------
    ("handrolled-merge", ".js",
     ["function merge(t, s) {",
      "  for (const k in s) {",
      "    if (typeof s[k] === 'object' && s[k] !== null) {",
      "      t[k] = merge(t[k] || {}, s[k]);",
      "    } else { t[k] = s[k]; }",
      "  } return t; }"],
     {"PP-DEEPMERGE:MEDIUM"}),
    ("keys-merge", ".js",
     ["function deepAssign(dst, src) {",
      "  Object.keys(src).forEach(k => {",
      "    if (typeof src[k] === 'object') dst[k] = deepAssign({}, src[k]);",
      "    else dst[k] = src[k];",
      "  });",
      "}"],
     {"PP-DEEPMERGE:MEDIUM"}),
    ("guarded-merge-ok", ".js",
     ["function merge(t, s) {",
      "  for (const k in s) {",
      "    if (k === '__proto__' || k === 'constructor') continue;",
      "    if (typeof s[k] === 'object') t[k] = merge({}, s[k]);",
      "    else t[k] = s[k];",
      "  } return t; }"],
     set()),
    ("hasown-merge-ok", ".js",
     ["function merge(t, s) {",
      "  for (const k in s) {",
      "    if (!Object.hasOwn(s, k)) continue;",
      "    if (typeof s[k] === 'object') t[k] = merge({}, s[k]);",
      "    else t[k] = s[k];",
      "  } return t; }"],
     set()),
    # ---- PP-FORIN-COPY ---------------------------------------------------
    ("forin-copy", ".js",
     ["for (const k in src) {",
      "  out[k] = src[k];",
      "}"],
     {"PP-FORIN-COPY:LOW"}),
    ("forin-hasown-ok", ".js",
     ["for (const k in src) {",
      "  if (src.hasOwnProperty(k)) out[k] = src[k];",
      "}"],
     set()),
    ("forin-same-obj-ok", ".js",
     ["for (const k in map) {",
      "  map[k] = normalize(map[k]);",
      "}"],
     set()),
    # ---- misc -----------------------------------------------------------
    ("test-path-downgrade", ".test.js",
     ['obj.__proto__ = x;'],
     {"PP-WRITE:MEDIUM"}),
    ("multiline-merge", ".js",
     ["const c = merge(",
      "  base,",
      "  req.body",
      ");"],
     {"PP-MERGE-REQ:HIGH"}),
    ("comment-proto-ok", ".js",
     ["// never allow __proto__ = here",
      "const x = 1;"],
     set()),
    ("template-str-ok", ".ts",
     ['const s = `key ${k} => ${v}`;'],
     set()),
]


def selftest():
    import tempfile
    fails = []
    for name, ext, lines, expect in SELFTEST:
        src = "\n".join(lines) + "\n"
        with tempfile.NamedTemporaryFile(
                "w", suffix=ext, delete=False) as tf:
            tf.write(src)
            path = tf.name
        try:
            findings, _ = scan(path)
            got = {f"{f.code}:{f.sev}" for f in findings}
            if got != expect:
                fails.append((name, sorted(expect), sorted(got)))
        finally:
            os.unlink(path)
    if fails:
        print(f"SELFTEST FAIL ({len(fails)})")
        for n, e, g in fails:
            print(f"  {n}: expected {e} got {g}")
        return 1
    print(f"SELFTEST PASS ({len(SELFTEST)} vectors)")
    return 0


def main(argv):
    if "--selftest" in argv:
        return selftest()
    args = [a for a in argv[1:] if not a.startswith("--")]
    if not args:
        print(__doc__)
        return 2
    target = args[0]
    if not os.path.exists(target):
        print(f"error: {target} not found", file=sys.stderr)
        return 2
    findings, scanned = scan(target)
    if "--json" in argv:
        print(json.dumps({"target": target, "files_scanned": scanned,
                          "findings": [f.as_dict() for f in findings]},
                         indent=2))
    else:
        print(report_md(findings, scanned, target))
    return 1 if any(SEV_ORDER[f.sev] < 2 for f in findings) else 0


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