#!/usr/bin/env python3
"""nosql_prescan.py — deterministic NoSQL injection & mass-assignment pre-scan.

ARION service artifact. Scans source files for the signatures of NoSQL
operator injection (MongoDB-family operators in place of scalar values)
and mass-assignment / overposting (whole request objects written into
models). Line-wise, fully offline, no inference.

Findings (base severity):
  NQ-REQ-QUERY      HIGH    whole request object (req.body / req.query /
                            request.data / $request->all() / $_POST /
                            params.to_unsafe_h ...) used as the filter of
                            a find/count/distinct/aggregate/exists/delete
                            call — $gt/$ne/$where operator injection
                            reads or deletes beyond intent
  NQ-MASS-ASSIGN    HIGH    whole request object written into a model —
                            create/insert/update/replace/fill/set/new
                            Model(req.body)/Model(**request.POST)/
                            TryUpdateModel — attacker can set any column
                            incl. role/isAdmin/verified. permit! /
                            to_unsafe_h alone flag MEDIUM
  NQ-FIELD-REQ      MEDIUM  scalar request value in field-value position
                            of a query/update literal —
                            findOne({password: req.body.p}) accepts a JSON
                            object {"$ne":""} as the value -> auth bypass
  NQ-COMPUTED-KEY   MEDIUM  request-controlled key inside a query/update
                            document — {[req.body.k]: 1} or '$'+k concat —
                            attacker picks the operator/field name
  NQ-WHERE-NONLIT   HIGH    server-side JS query operators — $where,
                            $function, $accumulator, $expr, db.eval,
                            mapReduce — fed a non-literal / request value
                            (literal-only usage tiers LOW)
  NQ-REGEX-REQ      MEDIUM  request-sourced regex into a query —
                            new RegExp(req.x), $regex + req marker,
                            re.compile(request.x), Regexp.new(params[x])
                            (operator injection + ReDoS surface)
  NQ-PROTO-MERGE    MEDIUM  deep merge/extend/assign of a request object
                            into a server object — _.merge/defaultsDeep/
                            deepmerge/Object.assign/$.extend(true,...) —
                            prototype-pollution + field-takeover surface
  NQ-PROTO-KEY      MEDIUM  dynamic property write keyed by a request
                            value (obj[req.body.k] = v) or a literal
                            __proto__/constructor/prototype key access —
                            classic prototype-pollution sink
  NQ-FORM-ALL       MEDIUM  mass-assign surface in form/serializer config:
                            Django/DRF fields='__all__', exclude = (),
                            Laravel ->with($request->all())

Severity is context-adjusted:
  * a sanitising/whitelisting marker on the same line (mongo-sanitize,
    sanitize(), .pick()/.omit(), params.permit(...), ->only(),
    ->validated(), schema .parse(), Joi./z./yup., escapeRegExp/
    re.escape, allowlist/whitelist) downgrades one tier -> LOW for
    human review, not suppression
  * test/spec/fixture paths downgrade one tier
  * comment lines and block-comment bodies are skipped

Multi-line calls are handled with a bounded lookahead: when a sink call
leaves parens unbalanced, up to 4 following lines are consumed (until
the call closes) so Model.create(\n  req.body\n) still flags.

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

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

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 sanitising/whitelisting markers — presence downgrades one
# tier (a human still checks the guard actually covers the value)
GUARD = re.compile(
    r"mongo[_-]?sanitize|express-mongo-sanitize|sanitize\s*\(|"
    r"\.\s*pick\s*\(|\.\s*omit\s*\(|_\.pick|_\.omit|pickBy|"
    r"permit\s*\(|->\s*only\s*\(|->\s*validated|validated\s*\(|"
    r"\.parse\s*\(|whitelist|allowlist|"
    r"escapeRegExp|escape_regex|re\.escape|RegExp\.escape|"
    r"Joi\.|yup\.|z\.object|zod|schema\s*\.\s*validate|"
    r"strong_?params|filterKeys|cleanBody|xss-clean",
    re.I)

# whole request-object markers (bare collections — NOT field accesses).
# req.body.email is REQ_FIELD; req.body alone is REQ_OBJ.
REQ_OBJ = re.compile(
    r"\breq\s*\.\s*(?:body|query|params)\b(?!\s*[\.\[:])|"
    r"\bctx\s*\.\s*(?:request\s*\.\s*)?(?:body|query|params)\b"
    r"(?!\s*[\.\[:])|"
    r"\brequest\s*\.\s*(?:data|json|args|form|values|POST|GET|"
    r"query_params|parameters|body|FILES)\b(?!\s*[\.\[:])|"
    r"\$request->(?:all|input|post|query|json|request|cookie)\s*\(|"
    r"\$_(?:POST|GET|REQUEST|COOKIE)\b(?!\s*\[)|"
    r"\bInput::(?:all|get|input|query)\s*\(|"
    r"\bparams\s*\.\s*(?:to_unsafe_h(?:ash)?|permit\s*!)|"
    r"\bpermit\s*!|"
    r"\bweb\s*\.\s*input\s*\(\s*\)|"
    r"\bevent\s*\.\s*(?:body|queryStringParameters)\b(?!\s*[\.\[])|"
    r"\bself\.request\.(?:body|arguments)|"
    r"\br\s*\.\s*(?:FormValue|PostFormValue)|"
    r"\bform\s*\.\s*data\b")

# rails: bare `params` word (no .method, no [index]) is the whole hash
REQ_OBJ_RB = re.compile(r"(?<![\w.@])params\b(?!\s*[\.\[:])")

# scalar request-field markers (indexed/attribute access)
REQ_FIELD = re.compile(
    r"\breq\s*\.\s*(?:body|query|params)\s*[\.\[]|"
    r"\bctx\s*\.\s*(?:request\s*\.\s*)?(?:body|query)\s*[\.\[]|"
    r"\brequest\s*\.\s*(?:data|json|args|form|values|POST|GET|"
    r"query_params|parameters)\s*[\.\[]|"
    r"\brequest\s*\.\s*(?:args|values|form|GET|POST|data)\s*\.\s*get\s*\(|"
    r"\$_(?:GET|POST|REQUEST|COOKIE)\s*\[|"
    r"\bparams\s*\[|"
    r"\$request->(?:input|get|post|query|cookie|header)\s*\(|"
    r"\bInput::(?:get|has)\s*\(|"
    r"\bgetParameter\s*\(|"
    r"\bc\s*\.\s*(?:Param|Query|QueryParam|FormValue|PathParam|"
    r"URLParam)\s*\(|"
    r"\bRequest\s*\.\s*(?:Query|Form|Params|Headers)|"
    r"\bself\.get_argument|"
    r"\bevent\s*\.\s*(?:pathParameters|queryStringParameters)\s*[\.\[]")

# any request marker (for proto-key / where / regex checks)
REQ_ANY = re.compile(
    r"\breq\s*\.|\brequest\s*\.|\bparams\s*\[|\$_(?:GET|POST|REQUEST|"
    r"COOKIE)|\$request->|\bInput::|\bgetParameter\s*\(|"
    r"\bc\s*\.\s*(?:Param|Query|FormValue)|\bweb\s*\.\s*input|"
    r"\bevent\s*\.|\bself\.get_argument|\bctx\s*\.")

# document-DB / ORM read-filter sinks
READ_SINK = re.compile(
    r"\b(?:find|findOne|find_one|findById|findByIds|findMany|findAll|"
    r"findAndCountAll|count|countDocuments|count_documents|"
    r"estimatedDocumentCount|estimated_document_count|"
    r"distinct|aggregate|exists|deleteOne|deleteMany|delete_one|"
    r"delete_many|findOneAndDelete|findOneAndRemove|findOneAndReplace|"
    r"find_one_and_delete|find_one_and_replace|"
    r"where|firstWhere|firstOrFail|"
    r"firstOrNew|hydrate|mapReduce|geoNear|geoSearch|find_by)\s*\(|"
    r"->\s*(?:where|firstWhere|whereIn|whereRaw|find|findOrFail|"
    r"firstOrFail|first)\s*\(|"
    r"::\s*(?:where|find|findBy|firstWhere|firstOrFail)\s*\(|"
    r"\bCriteria\s*\.\s*where\s*\(")

# model write sinks — whole request object in = mass assignment
WRITE_SINK = re.compile(
    r"\b(?:create|insert|insertOne|insertMany|insert_one|insert_many|"
    r"update|updateOne|updateMany|update_one|update_many|"
    r"replaceOne|replace_one|findOneAndUpdate|findByIdAndUpdate|"
    r"findByIdAndRemove|find_one_and_update|findAndModify|"
    r"bulkWrite|bulk_write|fill|forceFill|"
    r"updateAttributes|update_attributes|firstOrCreate|updateOrCreate|"
    r"createOrUpdate|updateAll|massAssign|hydrate|store|"
    r"TryUpdateModel(?:Async)?|UpdateModel)\s*\(|"
    r"->\s*(?:fill|forceFill|update|create|insert|save|push|store|"
    r"updateOrCreate|firstOrCreate|replicate)\s*\(|"
    r"::\s*(?:create|insert|update|updateOrCreate|firstOrCreate|"
    r"forceFill)\s*\(")

# generic object-write helpers — same arg shape, lower confidence
GENERIC_WRITE = re.compile(
    r"\.\s*(?:set|save|push|store|setAttributes)\s*\(")

# new Model(req.body) / User(**request.POST) / Model.new(params)
NEW_MODEL = re.compile(
    r"\bnew\s+[A-Z][\w.$]*\s*\(\s*(?:\.\.\.\s*)?|"
    r"\b[A-Z][\w.]*\s*\(\s*\*\*\s*|"
    r"\.new\s*\(\s*")

# deep-merge / assign helpers (prototype-pollution + takeover surface)
MERGE_SINK = re.compile(
    r"(?<![\w])(?:_\.|lodash\s*\.\s*)?(?:merge|mergeDeep|defaultsDeep|"
    r"deepmerge|deepAssign|deep_assign|assignIn|assign)\s*\(|"
    r"\bObject\s*\.\s*assign\s*\(|"
    r"(?:\$|jQuery)\s*\.\s*extend\s*\(|"
    r"\bextend\s*\(\s*true\b")

# request-controlled or proto-name key in object-position
COMPUTED_KEY = re.compile(
    r"[\{,]\s*\[\s*[^\]]*?(?:req|request|params|_GET|_POST|_REQUEST|"
    r"event\.|getParameter|c\.Param|Input::|\$request)[^\]]*?\]\s*:|"
    r"['\"]\s*\$\s*['\"]\s*\+|\+\s*['\"]\$[a-zA-Z]|"
    r"['\"]\$['\"]\s*\+\s*\w")

# request-keyed dynamic property write, or literal proto key access
PROTO_KEY = re.compile(
    r"\[\s*[^\]]*?(?:req\s*\.|request\s*\.|params\s*\[|\$_(?:GET|POST|"
    r"REQUEST)|\$request|event\.|getParameter|c\.Param|Input::)"
    r"[^\]]*?\]\s*=[^=]|"
    r"\[\s*['\"](?:__proto__|constructor|prototype)['\"]\s*\]")

# server-side JS-in-query operators
WHERE_OP = re.compile(
    r"\$(?:where|function|accumulator)\b|\$expr\b|"
    r"\bdb\s*\.\s*eval\s*\(|\bmapReduce\s*\(")

# request-sourced regex construction in query context
REGEX_REQ = re.compile(
    r"\bnew\s+RegExp\s*\(|"
    r"\bRegExp\s*\(\s*[^'\"]|"
    r"['\"]\$regex['\"]\s*:|\$regex\s*:|"
    r"\bre\s*\.\s*compile\s*\(|"
    r"\bRegexp\s*\.\s*new\s*\(|"
    r"\bpreg_match\s*\(")

# form/serializer mass-assign config
FORM_ALL = re.compile(
    r"\bfields\s*[=:]\s*['\"]__all__['\"]|"
    r"\bexclude\s*=\s*[\(\[]\s*[\)\]]|"
    r"->\s*with\s*\(\s*\$request->all|"
    r"\bModelForm\b[^=\n]*\bfields\s*=\s*['\"]__all__")


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 _nonliteral(expr):
    """Is the expression non-literal / does it carry a request marker?"""
    if REQ_ANY.search(expr):
        return True
    e = re.sub(r"'[^'\"]*'", "", expr)
    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)
    e = e.strip(" \t,;)]}'\"`")
    if not e:
        return False
    return bool(re.search(r"[A-Za-z_$][\w$.\[\]]*|\+|%|#\{", e))


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 _req_obj(code, ext):
    if REQ_OBJ.search(code):
        return True
    return ext == ".rb" and bool(REQ_OBJ_RB.search(code))


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 = []
    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))

        is_import = IMPORT_LINE.search(code)
        if is_import:
            continue

        # class 1 — whole request object into a read/filter sink
        claimed_query = False
        for m in READ_SINK.finditer(code):
            window = _call_window(lines, i, code[m.start():])
            if _req_obj(window, ext):
                add("NQ-REQ-QUERY", "HIGH",
                    "whole request object used as query/filter — "
                    "operator injection surface")
                claimed_query = True
                break

        # class 2 — whole request object into a write/create sink
        claimed_write = False
        for m in WRITE_SINK.finditer(code):
            window = _call_window(lines, i, code[m.start():])
            if _req_obj(window, ext):
                add("NQ-MASS-ASSIGN", "HIGH",
                    "whole request object written into model — "
                    "attacker sets any field")
                claimed_write = True
                break
        if not claimed_write:
            nm = NEW_MODEL.search(code)
            if nm:
                window = _call_window(lines, i, code[nm.start():])
                if _req_obj(window, ext):
                    add("NQ-MASS-ASSIGN", "HIGH",
                        "model constructed from whole request object")
                    claimed_write = True
        if not claimed_write:
            for m in GENERIC_WRITE.finditer(code):
                window = _call_window(lines, i, code[m.start():])
                if _req_obj(window, ext):
                    add("NQ-MASS-ASSIGN", "MEDIUM",
                        "request object into generic object-write "
                        "helper — review assignability")
                    claimed_write = True
                    break

        # rails mass-assign bypass markers standalone
        if ext == ".rb" and re.search(r"permit\s*!|to_unsafe_h", code) \
                and not claimed_write:
            add("NQ-MASS-ASSIGN", "MEDIUM",
                "strong-params bypass marker (permit! / to_unsafe_h)")

        # class 3 — scalar request value as a field value in a query
        # literal: findOne({password: req.body.p}) accepts {"$ne":""}.
        # Skipped when a whole-object finding already claims the call.
        if not (claimed_query or claimed_write):
            field_hit = False
            for m in READ_SINK.finditer(code):
                window = _call_window(lines, i, code[m.start():])
                if not _req_obj(window, ext) and \
                        REQ_FIELD.search(window) and \
                        re.search(r"[{:=>]", window):
                    add("NQ-FIELD-REQ", "MEDIUM",
                        "request value in query field position — "
                        "operator-object injection possible when body "
                        "is JSON")
                    field_hit = True
                    break
            if not field_hit:
                for m in WRITE_SINK.finditer(code):
                    window = _call_window(lines, i, code[m.start():])
                    if not _req_obj(window, ext) and \
                            REQ_FIELD.search(window) and \
                            re.search(r"[{:=>]", window):
                        add("NQ-FIELD-REQ", "MEDIUM",
                            "request value in update field position — "
                            "operator-object injection review")
                        break

        # class 4 — request-controlled key inside a document / operator
        # name built by concat
        if COMPUTED_KEY.search(code):
            add("NQ-COMPUTED-KEY", "MEDIUM",
                "request-controlled key/operator name in a query or "
                "update document")

        # class 5 — server-side JS query operator
        wm = WHERE_OP.search(code)
        if wm:
            window = _call_window(lines, i, code)
            if REQ_ANY.search(window):
                add("NQ-WHERE-NONLIT", "HIGH",
                    "server-side JS query operator fed request data")
            elif _nonliteral(window[wm.start():]):
                add("NQ-WHERE-NONLIT", "MEDIUM",
                    "server-side JS query operator fed a non-literal")
            else:
                add("NQ-WHERE-NONLIT", "LOW",
                    "$where/$function-style operator — review necessity")

        # class 6 — request-sourced regex into a query
        rm = REGEX_REQ.search(code)
        if rm and REQ_ANY.search(code):
            add("NQ-REGEX-REQ", "MEDIUM",
                "request-sourced regex in query — operator injection "
                "+ ReDoS surface")

        # class 7 — deep merge / assign of request object
        for m in MERGE_SINK.finditer(code):
            window = _call_window(lines, i, code[m.start():])
            if _req_obj(window, ext):
                add("NQ-PROTO-MERGE", "MEDIUM",
                    "request object deep-merged into a server "
                    "object — prototype-pollution surface")
                break

        # class 8 — dynamic property write / literal proto key
        if PROTO_KEY.search(code):
            add("NQ-PROTO-KEY", "MEDIUM",
                "property keyed by request value or __proto__/"
                "constructor name — prototype-pollution sink")

        # class 9 — form/serializer wildcard field config
        if FORM_ALL.search(code):
            add("NQ-FORM-ALL", "MEDIUM",
                "wildcard form/serializer fields — every model column "
                "becomes writable")

    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 = ["nosql_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 = [
    # --- whole request object -> query sink ---
    ("a.js", "User.find(req.query);\n", {"NQ-REQ-QUERY"}),
    ("a.js", "db.users.findOne(req.body);\n", {"NQ-REQ-QUERY"}),
    ("a.js",
     "User.findOneAndDelete(req.body);\n", {"NQ-REQ-QUERY"}),
    ("a.py", "users.find(request.json)\n", {"NQ-REQ-QUERY"}),
    ("a.py", "coll.aggregate(request.args)\n", {"NQ-REQ-QUERY"}),
    ("a.php", "User::where($request->all())->get();\n",
     {"NQ-REQ-QUERY"}),
    ("a.php", "$q->where($_GET)->first();\n",
     {"NQ-REQ-QUERY"}),
    ("a.js", "User.find({ active: true });\n", set()),
    ("a.js", "items.filter(i => i.ok);\n", set()),
    # --- whole request object -> write sink (mass assignment) ---
    ("a.js", "User.create(req.body);\n", {"NQ-MASS-ASSIGN"}),
    ("a.js",
     "const u = new User(req.body);\n", {"NQ-MASS-ASSIGN"}),
    ("a.js",
     "User.findByIdAndUpdate(id, req.body);\n",
     {"NQ-MASS-ASSIGN"}),
    ("a.js",
     "await User.update(\n    req.body,\n    { where: { id } });\n",
     {"NQ-MASS-ASSIGN"}),                           # lookahead
    ("a.py", "user = User(**request.POST)\n", {"NQ-MASS-ASSIGN"}),
    ("a.php", "$u->fill($request->all());\n", {"NQ-MASS-ASSIGN"}),
    ("a.php", "User::create($request->input());\n", {"NQ-MASS-ASSIGN"}),
    ("a.rb", "User.create(params.require(:u).permit!)\n",
     {"NQ-MASS-ASSIGN"}),
    ("a.rb", "attrs = params.to_unsafe_h\n", {"NQ-MASS-ASSIGN"}),
    ("a.rb", "User.new(params)\n", {"NQ-MASS-ASSIGN"}),
    ("a.cs", "await TryUpdateModelAsync(user, \"\", req);\n", set()),
    ("a.js", "doc.save();\n", set()),
    ("a.js",
     "User.create({ name: req.body.name });\n", {"NQ-FIELD-REQ"}),
    # --- scalar request value in field position ---
    ("a.js",
     "User.findOne({ email: req.body.email });\n", {"NQ-FIELD-REQ"}),
    ("a.js",
     "findOne({ user: req.query.u, pass: req.query.p });\n",
     {"NQ-FIELD-REQ"}),
    ("a.php",
     "User::where('email', $_POST['email'])->first();\n",
     {"NQ-FIELD-REQ"}),
    ("a.py",
     "users.find_one({'name': request.args.get('n')})\n",
     {"NQ-FIELD-REQ"}),
    ("a.rb", "User.find_by(email: params[:email])\n",
     {"NQ-FIELD-REQ"}),
    ("a.js", "const n = req.body.name;\n", set()),
    # --- computed key / operator-name concat ---
    ("a.js", "q = { [req.body.field]: 1 };\n", {"NQ-COMPUTED-KEY"}),
    ("a.js", "upd['$' + k] = v;\n", {"NQ-COMPUTED-KEY"}),
    ("a.js", "const o = { [k]: v };\n", set()),
    # --- server-side JS operators ---
    ("a.js", "db.users.find({ $where: req.body.f });\n",
     {"NQ-WHERE-NONLIT", "NQ-FIELD-REQ"}),
    ("a.js", "coll.find({ $where: expr });\n",
     {"NQ-WHERE-NONLIT"}),
    ("a.js", "coll.find({ $where: 'this.x > 1' });\n",
     {"NQ-WHERE-NONLIT"}),                            # -> LOW
    ("a.js", "db.eval(req.body.code);\n",
     {"NQ-WHERE-NONLIT"}),
    # --- regex from request ---
    ("a.js",
     "User.find({ name: new RegExp(req.query.q) });\n",
     {"NQ-REGEX-REQ", "NQ-FIELD-REQ"}),
    ("a.js",
     "q = { $regex: req.body.pat };\n",
     {"NQ-REGEX-REQ"}),
    ("a.py", "re.compile(request.args['p'])\n", {"NQ-REGEX-REQ"}),
    ("a.js", "new RegExp('^lit$');\n", set()),
    # --- deep merge / assign ---
    ("a.js", "_.merge(cfg, req.body);\n", {"NQ-PROTO-MERGE"}),
    ("a.js", "Object.assign(user, req.body);\n", {"NQ-PROTO-MERGE"}),
    ("a.js", "$.extend(true, opts, req.query);\n", {"NQ-PROTO-MERGE"}),
    ("a.js", "Object.assign(a, b);\n", set()),
    # --- proto key ---
    ("a.js", "obj[req.body.key] = val;\n", {"NQ-PROTO-KEY"}),
    ("a.js", "o['__proto__'].x = 1;\n", {"NQ-PROTO-KEY"}),
    ("a.js", "obj[k] = v;\n", set()),
    # --- form wildcard ---
    ("a.py", "    fields = '__all__'\n", {"NQ-FORM-ALL"}),
    ("a.py", "    exclude = ()\n", {"NQ-FORM-ALL"}),
    ("a.py", "    fields = ('name', 'email')\n", set()),
    # --- guards downgrade ---
    ("a.js",
     "User.find(mongoSanitize(req.query));\n", {"NQ-REQ-QUERY"}),
    ("a.js",
     "User.create(_.pick(req.body, 'name'));\n", {"NQ-MASS-ASSIGN"}),
    # --- comments / clean ---
    ("a.js", "// User.create(req.body)\n", set()),
    ("a.js", "/* User.find(req.query); */\n", set()),
    ("a.js", "const User = require('./models/user');\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())
