#!/usr/bin/env python3
"""upload_prescan.py — deterministic file-upload-validation pre-scan.

ARION service artifact. Scans source files for the signatures of
unsafe file-upload handling — executable/active-content extensions
inside allow lists, validation gates that read the client-supplied
Content-Type instead of the bytes, deny-list ("blacklist") extension
checks, client filenames flowing into destination paths, upload
middleware built with no size bound, saves under statically-served
directories, and upload endpoints whose file does no validation work
at all — line-wise, fully offline, no inference.

Findings (base severity):
  UPLOAD-EXEC-EXT-ALLOWED   HIGH    an allow/accept/permit list that
                                    contains an executable extension —
                                    php/phtml/phar/jsp(x)/asp(x)/ashx/
                                    exe/dll/sh/bat/cmd/ps1/vbs/pl/cgi/
                                    py/rb/war/jar/htaccess/shtml … —
                                    the classic webshell-upload hole;
                                    accept= HTML attributes tier LOW
                                    (client-side hint only)
  UPLOAD-CLIENT-MIME-TRUST  HIGH    the validation gate reads the
                                    client-controlled type field —
                                    file.mimetype / file.type /
                                    $_FILES[..]['type'] /
                                    content_type / getContentType() /
                                    getMimeType() compared against an
                                    allow list or literal; requires an
                                    upload marker in the file
  UPLOAD-BLACKLIST-EXT      MEDIUM  deny-list ("blacklist") extension
                                    checks — reject/block/deny/bad/
                                    invalid/forbid lists or an exec-ext
                                    literal inside a rejecting
                                    conditional; bypassable by case,
                                    double extensions, trailing dots,
                                    alternate data names
  UPLOAD-ORIGINAL-NAME-PATH MEDIUM  client-supplied filename —
                                    originalname / originalFilename /
                                    getOriginalFilename /
                                    getClientOriginalName /
                                    clientFileName / .FileName /
                                    $_FILES[..]['name'] / part.filename
                                    (and .filename under flask/
                                    werkzeug markers) — in a join/
                                    dest/save expression; traversal +
                                    overwrite + predictable landing;
                                    basename()/sanitize/uuid on the
                                    same line tiers LOW
  UPLOAD-NO-SIZE-LIMIT      MEDIUM  upload middleware constructed with
                                    no byte bound — multer()/fileUpload(
                                    )/busboy()/formidable/IncomingForm/
                                    multiparty Form/@MultipartConfig —
                                    the call span (8 lines) holds no
                                    limits/fileSize/maxFileSize key;
                                    a size marker anywhere in the file
                                    silences
  UPLOAD-WEBROOT-SAVE       MEDIUM  the upload destination lands under
                                    a statically-served segment —
                                    public/static/www/wwwroot/htdocs/
                                    webroot/assets/media/images — or a
                                    static-file mount exposes an
                                    uploads dir (express.static/
                                    serveStatic/StaticFiles/FileServer/
                                    UseStaticFiles/sendFile); a saved
                                    script becomes a served page
  UPLOAD-SVG-HTML-ALLOWED   MEDIUM  an allow list admitting active
                                    content — svg/html/htm/xhtml/xml —
                                    stored XSS served from your own
                                    origin; accept= attribute LOW
  UPLOAD-NO-VALIDATION      MEDIUM  file-level: an upload endpoint +
                                    a save/write sink and no validation
                                    marker anywhere in the file — no
                                    ext/mime check, no size bound, no
                                    rename, no sniffing library — the
                                    "upload + store, forgot the gate"
                                    shape
  UPLOAD-EXT-ONLY           LOW     file-level: an extension check is
                                    present but no content verification
                                    (file-type/mmagic/imghdr/
                                    python-magic/finfo/mime_content_
                                    type/getimagesize/probeContentType/
                                    Tika/exif/sharp/jimp/magic bytes) —
                                    renamed .php.jpg beats an ext list

Context rules:
  * comment lines and block-comment bodies are stripped first
  * import/require lines never flag
  * test/spec/fixture paths downgrade one tier
  * UPLOAD-CLIENT-MIME-TRUST requires an upload marker in the file
    (multipart/req.files/$_FILES/multer/formidable/UploadFile/
    IFormFile/enctype/.mimetype …) — generic `.type` fields elsewhere
    stay silent; a bare `.filename` name read only counts under
    flask/werkzeug markers — multer's .filename is generated
  * allow-vs-deny direction is decided by vocabulary on the line:
    allow|white|accept|permit|valid|approved|safe -> allowed-list
    checks; deny|reject|forbid|block|black|banned|danger|bad|invalid|
    disallow|prohibit|except -> deny-list checks; an exec-ext literal
    inside a conditional that returns/throws/dies counts as deny
  * NO-SIZE-LIMIT looks through an 8-line call window so multi-line
    option objects are seen; a fileSize/limit marker anywhere in the
    file silences it (the bound exists somewhere)

Scope: deterministic pre-scan for human review — flags upload
constructions that miss or fake validation; no dataflow proof that a
route is reachable or that a checked list is enforced — clean output
!= upload-safe.

  upload_prescan.py <file-or-dir> [--json]
  upload_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",
    ".html", ".htm", ".erb", ".ejs", ".hbs", ".handlebars",
    ".twig", ".jinja", ".jinja2", ".j2", ".njk", ".mustache",
    ".phtml", ".tpl", ".vue", ".svelte",
)

SKIP_DIRS = {
    "node_modules", ".git", "vendor", "dist", "build", "target",
    "__pycache__", ".venv", "venv", "coverage", ".next", "out",
}

IMPORT_LINE = re.compile(
    r"^\s*(?:from\s+[\w.]+\s+import\b|import\s+[\w.{*]|"
    r"(?:const|let|var)\s+[\w${},\s]*=\s*require\s*\(|"
    r"\buse\s+[\w:]+|require\s*\(['\"][\w./-]+['\"]\s*\)?\s*;?\s*$|"
    r"#include|package\s+[\w.]+;?\s*$)")

TEST_PATH = re.compile(
    r"(^|/)(test|tests|spec|specs|fixtures?|mocks?|__tests__|__mocks__|"
    r"e2e|cypress|playwright)(/|$)|(_test|_spec|\.test|\.spec)\.",
    re.I)

# --- vocabulary --------------------------------------------------------
EXEC_EXTS = {
    "php", "phtml", "phar", "php3", "php4", "php5", "php7", "phps",
    "jsp", "jspx", "asp", "aspx", "ashx", "asmx", "axd",
    "exe", "dll", "com", "scr", "bat", "cmd", "msi", "ps1", "vbs",
    "wsf", "sh", "bash", "zsh", "pl", "cgi", "fcgi", "py", "pyw",
    "pyo", "pyc", "rb", "war", "jar", "htaccess", "shtml",
}
ACTIVE_EXTS = {"svg", "html", "htm", "xhtml", "xml"}
EXT_VOCAB = EXEC_EXTS | ACTIVE_EXTS | {
    "jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "tif", "tiff",
    "pdf", "txt", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt",
    "zip", "rar", "tar", "gz", "7z", "bz2", "csv", "tsv", "json", "md",
    "mp3", "mp4", "wav", "ogg", "flac", "avi", "mov", "webm", "mkv",
    "heic", "heif", "woff", "woff2", "ttf", "eot", "epub",
}

ALLOW_HINT = re.compile(
    r"allow|white_?list|accept|permit|valid|approved|safe|"
    r"(?<![\w.])ok[_a-z0-9]*\b|good|support", re.I)
DENY_HINT = re.compile(
    r"deny|denied|reject|forbid|block|black_?list|banned|danger|"
    r"\bbad\b|bad_|_bad|invalid|not_?allow|disallow|prohibit|"
    r"except|exclude|illegal", re.I)

# quoted string literals -> split on separators so ".png,.php" and
# '.jpg','.php' both yield ext tokens. A literal containing spaces but
# no , or | is prose — harvest nothing from it ('... the cgi ' must
# not mint a 'cgi' token).
QUOTED = re.compile(r"['\"]([^'\"]+)['\"]")


def _ext_tokens(code):
    toks = []
    for lit in QUOTED.findall(code):
        if re.fullmatch(r"\.?[a-zA-Z0-9]{1,8}", lit):
            toks.append(lit.lstrip(".").lower())
            continue
        if (" " in lit) and ("," not in lit) and ("|" not in lit):
            continue   # prose sentence, not a list
        for part in re.split(r"[,;|\s]+", lit):
            if re.fullmatch(r"\.?[a-zA-Z0-9]{1,8}", part or " "):
                toks.append(part.lstrip(".").lower())
    return toks
# exec ext inside a regex alternation — requires an actual '|' so a
# parenthesized arg like which(cmd) does not count
EXEC_ALT = re.compile(
    r"(?:php|phtml|phar|jspx?|aspx?|ashx|exe|dll|bat|cmd|ps1|vbs|sh|"
    r"pl|cgi|fcgi|pyw?|pyo|rb|war|jar|htaccess|shtml)\s*\||"
    r"\|\s*(?:php|phtml|phar|jspx?|aspx?|ashx|exe|dll|bat|cmd|ps1|"
    r"vbs|sh|pl|cgi|fcgi|pyw?|pyo|rb|war|jar|htaccess|shtml)\b", re.I)
# accept="..." html attribute — client-side hint, lower tier
ACCEPT_ATTR = re.compile(r"\baccept\s*=", re.I)

# reject outcome on the same line as an exec-ext literal in a condition
REJECT_OUT = re.compile(
    r"\breturn\b|\bthrow\b|\bdie\s*\(|\bexit\b|\breject\b|"
    r"status\s*\(\s*4|\bsend\s*\(|unlink|res\.json|abort|raise\b")
# stricter set for the 2-line lookahead — a bare `return` there is as
# often the success path as the reject
REJECT_STRICT = re.compile(
    r"\bthrow\b|\bdie\b|\bexit\b|\breject\b|status\s*\(\s*4|"
    r"unlink|abort|raise\b|forbidden|\b403\b|denied")
COND = re.compile(
    r"\bif\b|\.includes?\s*\(|in_array|indexOf|preg_match|"
    r"\.test\s*\(|\.match\s*\(|(?<![\w.])in\s*[\[({]|"
    r"(?<![\w.])in\s+[\w$({]")
# list-membership tests — a quoted ext inside `in (...)`/includes()
# is a list question, not an equality check
MEMBER_COND = re.compile(
    r"\.includes?\s*\(|in_array\s*\(|indexOf\s*\(|\.has\s*\(|"
    r"\.contains\s*\(|\.toContain\s*\(|preg_match|"
    r"(?<![\w.])in\s*[\[({]|(?<![\w.])in\s+[\w$(]|"
    r"(?<![\w.])not\s+in\s*[\[({]|(?<![\w.])not\s+in\s+[\w$(]")

# --- UPLOAD-CLIENT-MIME-TRUST ------------------------------------------
CLIENT_TYPE = re.compile(
    # distinctive attr names on any receiver — file.mimetype,
    # f.content_type, upload.mimeType, part.ContentType
    r"\b\w+\s*(?:\.|->)\s*(?:mimetype|mime_?[Tt]ype|"
    r"content_?[Tt]ype)\b|"
    # bare .type is generic — gate it on file-ish var names
    r"\b\w*(?:file|upload|part|attach|avatar|photo|image|doc|media|"
    r"asset|blob|item|payload)\w*\s*(?:\.|->|\[)\s*type\b|"
    r"\$_FILES\s*\[[^\]]*\]\s*\[\s*['\"]type['\"]\s*\]|"
    r"\b(?:getContentType|getMimeType)\s*\(|"
    r"\.headers\s*\[\s*['\"]content-type", re.I)
CONDITIONAL = re.compile(
    r"\bif\b|\belif\b|\bassert\b|\braise\b|\bunless\b|\bswitch\b|"
    r"\bcase\b|in_array|\.includes?\s*\(|\.indexOf\s*\(|startsWith|"
    r"endsWith|\.match\s*\(|\.test\s*\(|preg_match|valid|allow|"
    r"check|reject|->is|===|!==|==|!=|\bnot\s+in\b|\bin\s+[\w.([]")

# --- UPLOAD-ORIGINAL-NAME-PATH ------------------------------------------
# strong = unambiguously client-supplied, self-evidencing upload context
STRONG_NAME = re.compile(
    r"original_?[Nn]ame|originalFilename|getOriginalFilename|"
    r"getClientOriginalName|clientFileName|"
    r"\$_FILES\s*\[[^\]]*\]\s*\[\s*['\"]name['\"]\s*\]|"
    r"\.FileName\b|part\.filename\b")
# weak = ambiguous: flask FileStorage.filename is client, multer
# file.filename is generated — only counts under flask/werkzeug markers
WEAK_NAME = re.compile(r"\.filename\b")
FLASKY = re.compile(r"request\.files|FileStorage|werkzeug|flask", re.I)
NAME_SINK = re.compile(
    r"path\.join|os\.path\.join|\.join\s*\(|Path\.Combine|Combine\s*\(|"
    r"Paths\.get|new\s+File|dest\b|\bdir\b|target|save|store|move|"
    r"write|fopen|copy|CopyTo|rename|transferTo|bucket|\bKey\b|upload|"
    r"concat|\+|/", re.I)
NAME_SANITIZED = re.compile(
    r"basename\s*\(|sanitize|slugify|uuid|randomBytes|randombytes|"
    r"uniqid|nanoid|crypto\.|secure_filename|md5\s*\(|sha1\s*\(|"
    r"sha256|hashlib|\bhash\s*\(|time\s*\(\s*\)|now\s*\(\s*\)",
    re.I)

# --- UPLOAD-NO-SIZE-LIMIT ------------------------------------------------
UPLOAD_MW_CALL = re.compile(
    r"\bmulter\s*\(|\bfileUpload\s*\(|express\.fileUpload\s*\(|"
    r"\bbusboy\s*\(|\bBusboy\s*\(|\bformidable\s*\(|"
    r"IncomingForm\s*\(|multiparty\.Form|@MultipartConfig\b|"
    r"CommonsMultipartResolver|MultipartConfigElement")
SIZE_MARK = re.compile(
    r"limits|maxFileSize|fileSize|fieldSize|maxFieldSize|max_size|"
    r"maxFileSizeBytes|maxRequestSize|maxContentLength|"
    r"MAX_CONTENT_LENGTH|FILE_UPLOAD_MAX|size\s*[:=]\s*[\d_]+|"
    r"max_fields|maxFields", re.I)

# --- UPLOAD-WEBROOT-SAVE -------------------------------------------------
# a save path segment that is conventionally served — 'uploads' alone is
# not evidence; the static-mount rule below covers it
WEBROOT_SEG = re.compile(
    r"['\"`/\\](?:public|static|www|wwwroot|htdocs|webroot|assets|"
    r"media|images?|img)(?:['\"`/\\]|$)", re.I)
STATIC_SERVE = re.compile(
    r"express\.static|serveStatic|serve_static|StaticFiles\s*\(|"
    r"sendFile|res\.sendFile|http\.FileServer|ServeFile|"
    r"UseStaticFiles|\.Static\s*\(|send_file|serve_file|"
    r"add_static|static_route|DirectoryBrowser", re.I)
# mime-type literals ('image/png') fake a served-segment match — strip
# them before the webroot test
MIME_LIT = re.compile(
    r"['\"](?:image|audio|video|text|application|font|multipart|"
    r"message|model|chemical|x-[a-z]+)/[\w.+-]+['\"]", re.I)
SAVE_SINK = re.compile(
    r"move_uploaded_file|createWriteStream|writeFile|writeFileSync|"
    r"fs\.write|\.save\s*\(|->store|storeAs|->move\s*\(|transferTo|"
    r"\.copy\s*\(|CopyTo|rename\s*\(|putObject|s3\.upload|"
    r"bucket\.|upload_file|saveAs|MoveTo|SaveAs|fopen|"
    r"\.write\s*\(\s*[\w.]*(?:file|upload|part|blob|content|body|"
    r"buf|stream|data|chunk|\bf)\b|\.mv\s*\(|dest\s*[:=]|destination")
DESTISH = re.compile(
    r"\bdest\b|destination|\bdir\b|path\.join|os\.path\.join|join\s*\(|"
    r"save|store|move|write|copy|rename|upload|static|serve", re.I)

# --- file-level markers ---------------------------------------------------
UPLOAD_MARK = re.compile(
    r"req\.file|req\.files|request\.files|request\.FILES|\$_FILES|"
    r"move_uploaded_file|originalname|originalFilename|"
    r"getClientOriginal|UploadFile|IFormFile|MultipartFile|"
    r"getParts\s*\(|multer|busboy|formidable|fileUpload\s*\(|"
    r"multipart|upload\.single|upload\.array|upload\.fields|"
    r"upload\.none|FileItem|enctype|part\.filename|"
    r"file\.stream|MultipartForm|FormFile|diskStorage|"
    r"memoryStorage|fileFilter|incoming_form|cgi\.FieldStorage|"
    r"\.mimetype\b|type\s*=\s*['\"]file['\"]|await\s+\w+\.file\(",
    re.I)
VALIDATION_MARK = re.compile(
    r"allow|white_?list|deny|reject|black_?list|\.extname|"
    r"path\.extname|extension|pathinfo|in_array\s*\(|\.includes?\s*\(|"
    r"ends_?with|endsWith|mime|filetype|file-type|file_type|mmmagic|"
    r"imghdr|magic|finfo|getimagesize|Image\.open|probeContentType|"
    r"Tika|MimeTypes|secure_filename|sanitize|slugify|basename|uuid|"
    r"randomBytes|randombytes|uniqid|nanoid|crypto\.|limits|"
    r"maxFileSize|fileSize|MAX_CONTENT_LENGTH|MAX_FILE_SIZE|"
    r"size_limit|clamav|antivirus|virus|sharp\s*\(|jimp|resize|"
    r"accept\s*=|fileFilter|validat|check", re.I)
EXT_CHECK_MARK = re.compile(
    r"path\.extname|\.extname\b|ends_?with\s*\(\s*['\"]\.|"
    r"endsWith\s*\(\s*['\"]\.|in_array\s*\([^)]*\bext|"
    r"\bext\w*\s*(?:in_array|not\s+in|in)\s|allow\w*[\s_.]*ext|"
    r"ext\w*[\s_.]*allow|\bext(?:ension)?s?\b\s*[=:(]|pathinfo\s*\(|"
    r"PATHINFO_EXTENSION|getExtension|getClientOriginalExtension|"
    r"fileExtension|get_extension|ext_check|check_ext|validate_ext|"
    r"allowed_ext|extension_whitelist|file_ext|"
    r"\.r?split\s*\(\s*['\"]\.", re.I)
CONTENT_CHECK_MARK = re.compile(
    r"file-?type|filetype|mmmagic|imghdr|python-?magic|finfo\s*\(|"
    r"mime_content_type|getimagesize|Image\.open|probeContentType|"
    r"Tika|exif|sharp\s*\(|jimp|magic[_ ]?bytes|signature|sniff|"
    r"readUInt|fromBuffer|detectType|MIMEDetect|"
    r"exif_imagetype|verifyImage|isImage\s*\(", re.I)


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", ".jinja", ".jinja2",
                                     ".j2", ".njk", ".twig", ".sh",
                                     ".yaml", ".yml"):
        return None
    if s.startswith("{#") or (s.startswith("{%") and "comment" in s):
        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 _ext_of(path):
    base = os.path.basename(path).lower()
    for e in (".blade.php", ".jinja2", ".handlebars"):
        if base.endswith(e):
            return e
    return os.path.splitext(path)[1].lower()


def _call_window(codes, i, limit=8):
    """Join a call's text from line i until parens balance or limit."""
    buf, depth = [], 0
    for k in range(i, min(len(codes), i + limit)):
        seg = codes[k]
        buf.append(seg)
        depth += seg.count("(") - seg.count(")")
        if k > i and depth <= 0:
            break
        if k == i and ("{" in seg or depth <= 0) and ")" in seg:
            break
    return "\n".join(buf)


def scan_file(path, rel):
    ext = _ext_of(path)
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
    except OSError:
        return []
    findings = []
    seen = set()
    in_block = False
    codes = []
    for raw in lines:
        line = raw.rstrip("\n")
        j = 0
        out = []
        while j < len(line):
            if in_block:
                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)
        codes.append(code or "")

    stripped = "\n".join(codes)
    # accept="..." attribute contents are client-side hints, not
    # validation — strip them before the file-level ext-check test
    stripped_noattr = re.sub(r"accept\s*=\s*['\"][^'\"]*['\"]",
                             " ", stripped, flags=re.I)
    file_upload = bool(UPLOAD_MARK.search(stripped))
    file_validation = bool(VALIDATION_MARK.search(stripped))
    file_ext_check = bool(EXT_CHECK_MARK.search(stripped_noattr))
    file_content_check = bool(CONTENT_CHECK_MARK.search(stripped))
    file_size_mark = bool(SIZE_MARK.search(stripped))
    flasky = bool(FLASKY.search(stripped))

    def add(cls, sev, i, note):
        if (cls, i + 1) in seen:
            return
        seen.add((cls, i + 1))
        if _is_test_path(rel):
            sev = "LOW"
        findings.append(Finding(cls, sev, rel, i + 1, lines[i], note))

    # ---- per-line classes ----
    for i, code in enumerate(codes):
        if not code.strip():
            continue
        if IMPORT_LINE.search(code):
            continue

        toks = _ext_tokens(code)
        # allow/deny vocabulary is matched on a string-stripped copy —
        # prose literals ('accepts equivalent XML', assert.ok) must not
        # feed the direction hints
        nostr = QUOTED.sub(" ", code)
        exec_toks = [t for t in toks if t in EXEC_EXTS]
        active_toks = [t for t in toks if t in ACTIVE_EXTS]
        vocab_toks = [t for t in toks if t in EXT_VOCAB]
        allow = bool(ALLOW_HINT.search(nostr))
        deny = bool(DENY_HINT.search(nostr))
        attr = bool(ACCEPT_ATTR.search(nostr))
        # an "extension list" = >=2 vocab tokens in the line, a regex
        # alternation, or an explicit allow/deny/accept hint beside an
        # exec ext — a lone ('.py') equality is neither
        has_exec = bool(exec_toks) or bool(EXEC_ALT.search(code))
        ext_list = len(vocab_toks) >= 2 or bool(EXEC_ALT.search(code)) \
            or (bool(exec_toks) and (allow or deny or attr))
        mcond = MEMBER_COND.search(code)
        # reject evidence must come AFTER the membership test —
        # `return x in (..)` is a computed bool, not a bail-out
        after = code[mcond.end():] if mcond else ""
        rejects = (bool(REJECT_OUT.search(after)) or
                   bool(REJECT_STRICT.search(
                       "\n".join(codes[i + 1:i + 3]))))
        # `return`/`yield` BEFORE the membership test means the in()
        # result is the answer — direction unknowable, stay silent
        ambiguous = bool(mcond) and bool(
            re.search(r"\breturn\b|\byield\b", code[:mcond.start()]))

        # --- exec ext inside an allow vs deny list ---
        if has_exec and ext_list and not deny:
            if allow or attr:
                sev = "LOW" if attr else "HIGH"
                add("UPLOAD-EXEC-EXT-ALLOWED", sev, i,
                    "executable extension in an allow/accept list — a "
                    "script upload becomes server-side code")
            elif mcond and rejects and not ambiguous:
                add("UPLOAD-BLACKLIST-EXT", "MEDIUM", i,
                    "exec-ext list inside a rejecting conditional — "
                    "deny-list check, bypassable (case/double-ext/"
                    "trailing-dot)")
            elif mcond and file_upload and not ambiguous:
                add("UPLOAD-EXEC-EXT-ALLOWED", "HIGH", i,
                    "exec-ext list inside an admitting conditional — "
                    "in_array/includes with no reject reads as allow")
        elif has_exec and ext_list and deny:
            add("UPLOAD-BLACKLIST-EXT", "MEDIUM", i,
                "exec-ext deny list — blacklist validation bypassable "
                "(case/double-ext/trailing-dot/alternate names)")
        elif deny and not allow and len(vocab_toks) >= 2:
            add("UPLOAD-BLACKLIST-EXT", "MEDIUM", i,
                "extension deny list — blacklist validation is "
                "bypassable; whitelist instead")

        # --- active content in an allow list ---
        if active_toks and allow and not deny and not has_exec:
            sev = "LOW" if attr else "MEDIUM"
            add("UPLOAD-SVG-HTML-ALLOWED", sev, i,
                "allow list admits %s — stored XSS served from your "
                "origin" % "/".join(sorted(set(active_toks))))

        # --- gate reads the client-supplied type field ---
        if file_upload and CLIENT_TYPE.search(code) \
                and CONDITIONAL.search(code):
            add("UPLOAD-CLIENT-MIME-TRUST", "HIGH", i,
                "validation gate reads the client-supplied type field "
                "— attacker declares the type; check the bytes instead")

        # --- client filename into a destination path ---
        if STRONG_NAME.search(code) or \
                (WEAK_NAME.search(code) and flasky):
            if NAME_SINK.search(code):
                sev = "LOW" if NAME_SANITIZED.search(code) else "MEDIUM"
                add("UPLOAD-ORIGINAL-NAME-PATH", sev, i,
                    "client filename inside the save/join path — "
                    "traversal + overwrite + predictable landing")

        # --- middleware built without a byte bound ---
        if UPLOAD_MW_CALL.search(code):
            win = _call_window(codes, i)
            if not SIZE_MARK.search(win) and not file_size_mark:
                add("UPLOAD-NO-SIZE-LIMIT", "MEDIUM", i,
                    "upload middleware constructed with no size bound "
                    "— unbounded multipart = memory/disk exhaustion")

        # --- save under a statically-served segment ---
        code_nm = MIME_LIT.sub(" ", code)
        if STATIC_SERVE.search(code) and re.search(r"upload", code, re.I):
            add("UPLOAD-WEBROOT-SAVE", "MEDIUM", i,
                "static-file mount serves the uploads dir — a saved "
                "script becomes a served page")
        elif WEBROOT_SEG.search(code_nm) and file_upload \
                and DESTISH.search(code):
            add("UPLOAD-WEBROOT-SAVE", "MEDIUM", i,
                "upload destination under a statically-served segment "
                "(public/static/www/assets/media)")

    # ---- file-level: upload + save, zero validation ----
    # an ext check (however weak) counts as validation work — that shape
    # is the EXT-ONLY advisory, not the umbrella
    if file_upload and not file_validation and not file_ext_check:
        for i, code in enumerate(codes):
            if SAVE_SINK.search(code):
                add("UPLOAD-NO-VALIDATION", "MEDIUM", i,
                    "upload endpoint writes/stores files and the file "
                    "has no validation marker anywhere — no ext/mime "
                    "check, size bound, rename or sniffing")
                break

    # ---- file-level: extension check only, no content verify ----
    if file_upload and file_ext_check and not file_content_check:
        for i, code in enumerate(codes):
            if EXT_CHECK_MARK.search(code):
                add("UPLOAD-EXT-ONLY", "LOW", i,
                    "extension-only validation — no magic-byte/content "
                    "check; a renamed script clears an ext list")
                break

    return findings


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 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 _ext_of(fn) 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 = ["upload_prescan report — %d file(s), %d finding(s)"
             % (len(targets), len(findings)), ""]
    for f in findings:
        lines.append("%-6s %-26s %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 upload "
                 "constructions that miss or fake validation; not an "
                 "audit, clean != upload-safe")
    return "\n".join(lines)


SELFTEST = [
    # --- UPLOAD-EXEC-EXT-ALLOWED (HIGH) ---
    ("a.js", "const allowed = ['.jpg', '.php', '.png'];\n",
     {"UPLOAD-EXEC-EXT-ALLOWED"}),
    ("a.py", "ALLOWED_EXT = {'png', 'jpg', 'sh'}\n",
     {"UPLOAD-EXEC-EXT-ALLOWED"}),
    ("a.php", "$valid = array('jpg', 'phtml', 'gif');\n",
     {"UPLOAD-EXEC-EXT-ALLOWED"}),
    ("a.js", "whitelist: ['png', '.exe']\n", {"UPLOAD-EXEC-EXT-ALLOWED"}),
    ("a.py", "accepted = ['docx', 'jsp']\n", {"UPLOAD-EXEC-EXT-ALLOWED"}),
    ("a.js", "const ok = safeList.includes(ext) // ['pdf','war']\n",
     set()),
    ("a.js", "// allowed = ['php']\n", set()),
    ("a.html", "<input type=\"file\" accept=\".png,.php\">\n",
     {"UPLOAD-EXEC-EXT-ALLOWED"}),
    ("a.js", "const blocked = ['.php', '.exe', '.sh'];\n",
     {"UPLOAD-BLACKLIST-EXT"}),
    # --- UPLOAD-BLACKLIST-EXT (MEDIUM) ---
    ("a.js", "if (['php','exe'].includes(ext)) return res.status(400);\n"
             "app.use(multer());\n",
     {"UPLOAD-BLACKLIST-EXT", "UPLOAD-NO-SIZE-LIMIT"}),
    ("a.py", "bad = ['.jsp', '.asp']\nif ext in bad: return 400\n",
     {"UPLOAD-BLACKLIST-EXT"}),
    ("a.php", "$deny = array('exe', 'bat', 'cmd');\n",
     {"UPLOAD-BLACKLIST-EXT"}),
    ("a.js", "const forbidden_ext = ['zip', 'rar'];\n",
     {"UPLOAD-BLACKLIST-EXT"}),
    ("a.js", "const ext_list = ['zip', 'rar'];\n", set()),
    # --- UPLOAD-CLIENT-MIME-TRUST (HIGH) ---
    ("a.js", "app.post('/u', upload.single('f'), (req,res)=>{\n"
             "  if (req.file.mimetype === 'image/png') save();\n});\n",
     {"UPLOAD-CLIENT-MIME-TRUST"}),
    ("a.js", "const multer = require('multer');\n"
             "app.use(multer({limits:{fileSize: 1000}}));\n"
             "if (!ALLOWED.includes(file.mimetype)) reject();\n",
     {"UPLOAD-CLIENT-MIME-TRUST"}),
    ("a.php", "if (in_array($_FILES['f']['type'], $ok)) {\n"
              "  move_uploaded_file($t, $d);\n}\n",
     {"UPLOAD-CLIENT-MIME-TRUST"}),
    ("a.py", "def up():\n    f = request.files['x']\n"
             "    if f.content_type not in OK: abort(400)\n",
     {"UPLOAD-CLIENT-MIME-TRUST"}),
    ("a.js", "app.post('/u', upload.single('f'), (req,res)=>{\n"
             "  console.log(req.file.mimetype);\n"
             "  fs.writeFile(p, req.file.buffer, ()=>{});\n});\n",
     set()),  # mime read but not gated; 'mime' marker silences umbrella
    ("a.js", "if (item.type === 'folder') expand();\n", set()),
    # --- UPLOAD-ORIGINAL-NAME-PATH (MEDIUM) ---
    ("a.js", "app.use(multer());\n"
             "const p = path.join(dir, req.file.originalname);\n",
     {"UPLOAD-ORIGINAL-NAME-PATH", "UPLOAD-NO-SIZE-LIMIT"}),
    ("a.php", "$t = '/up/' . $_FILES['f']['name'];\n"
              "move_uploaded_file($tmp, $t);\n",
     {"UPLOAD-ORIGINAL-NAME-PATH", "UPLOAD-NO-VALIDATION"}),
    ("a.php", "move_uploaded_file($_FILES['f']['tmp_name'],"
              " $dir.$_FILES['f']['name']);\n",
     {"UPLOAD-ORIGINAL-NAME-PATH", "UPLOAD-NO-VALIDATION"}),
    ("a.py", "f = request.files['x']\n"
             "f.save(os.path.join(UP, f.filename))\n",
     {"UPLOAD-ORIGINAL-NAME-PATH", "UPLOAD-NO-VALIDATION"}),
    ("a.cs", "var p = Path.Combine(dir, formFile.FileName);\n"
             "formFile.CopyTo(s);\n",
     {"UPLOAD-ORIGINAL-NAME-PATH", "UPLOAD-NO-VALIDATION"}),
    ("a.js", "const p = path.join(dir, file.filename);\n", set()),
    ("a.py", "f = request.files['x']\n"
             "n = secure_filename(f.filename)\n"
             "f.save(os.path.join(UP, n))\n",
     set()),  # secure_filename is the correct fix — silent
    ("a.js", "app.use(multer());\n"
             "fs.writeFile(path.join(d, basename(f.originalname)), b);\n",
     {"UPLOAD-ORIGINAL-NAME-PATH", "UPLOAD-NO-SIZE-LIMIT"}),
    # --- UPLOAD-NO-SIZE-LIMIT (MEDIUM) ---
    ("a.js", "const upload = multer({ dest: 'tmp/' });\n",
     {"UPLOAD-NO-SIZE-LIMIT", "UPLOAD-NO-VALIDATION"}),
    ("a.js", "const upload = multer();\n", {"UPLOAD-NO-SIZE-LIMIT"}),
    ("a.js", "const upload = multer({ dest: 't/',\n"
             "  limits: { fileSize: 1024 } });\n", set()),
    ("a.js", "app.use(fileUpload());\n", {"UPLOAD-NO-SIZE-LIMIT"}),
    ("a.js", "const bus = busboy({ headers: h });\n",
     {"UPLOAD-NO-SIZE-LIMIT"}),
    ("a.js", "const form = new formidable.IncomingForm();\n",
     {"UPLOAD-NO-SIZE-LIMIT"}),
    ("a.js", "const form = new IncomingForm({ maxFileSize: 100 });\n",
     set()),
    ("a.java", "@MultipartConfig(location=\"/tmp\")\n",
     {"UPLOAD-NO-SIZE-LIMIT"}),
    ("a.java", "@MultipartConfig(maxFileSize = 1024L)\n", set()),
    ("a.js", "const OPTS = { limits: { fileSize: 9 } };\n"
             "const upload = multer(OPTS);\n", set()),
    # --- UPLOAD-WEBROOT-SAVE (MEDIUM) ---
    ("a.js", "app.use(multer({ dest: 'public/uploads' }));\n",
     {"UPLOAD-WEBROOT-SAVE", "UPLOAD-NO-SIZE-LIMIT",
      "UPLOAD-NO-VALIDATION"}),
    ("a.js", "app.use(multer({limits:{fileSize:1}}));\n"
             "app.use('/files', express.static('uploads'));\n",
     {"UPLOAD-WEBROOT-SAVE"}),
    ("a.py", "f = request.files['x']\n"
             "f.save('/srv/www/static/' + n)\n",
     {"UPLOAD-WEBROOT-SAVE", "UPLOAD-NO-VALIDATION"}),
    ("a.js", "app.use(multer({limits:{fileSize:1}}));\n"
             "const p = path.join('/var/app/data', 'files', n);\n",
     set()),
    # --- UPLOAD-SVG-HTML-ALLOWED (MEDIUM) ---
    ("a.js", "const allowed = ['.png', '.svg', '.jpg'];\n",
     {"UPLOAD-SVG-HTML-ALLOWED"}),
    ("a.py", "OK_EXT = {'png', 'html', 'jpg'}\n",
     {"UPLOAD-SVG-HTML-ALLOWED"}),
    ("a.html", "<input type=\"file\" accept=\".svg,.png\">\n",
     {"UPLOAD-SVG-HTML-ALLOWED"}),
    ("a.js", "const ok = ['png','jpg','jpeg'];\n", set()),
    # --- UPLOAD-NO-VALIDATION (MEDIUM, file-level) ---
    ("a.js", "app.post('/u', upload.single('f'), (req,res)=>{\n"
             "  fs.writeFile('/tmp/x', req.file.buffer, ()=>{});\n});\n",
     {"UPLOAD-NO-VALIDATION"}),
    ("a.js", "const ok=['png'];\napp.post('/u', upload.single('f'),\n"
             "  (req,res)=>{ const e = path.extname(req.file.originalname);\n"
             "  if(!ok.includes(e)) return;\n"
             "  fs.writeFile(p,req.file.buffer,()=>{}); });\n",
     {"UPLOAD-EXT-ONLY"}),
    # --- UPLOAD-EXT-ONLY (LOW, file-level) ---
    ("a.py", "f = request.files['x']\n"
             "if f.filename.endswith('.png'): f.save(p)\n",
     {"UPLOAD-ORIGINAL-NAME-PATH", "UPLOAD-EXT-ONLY"}),
    ("a.py", "import imghdr\nf = request.files['x']\n"
             "if f.filename.endswith('.png') and imghdr.what(f):"
             " f.save(p)\n",
     {"UPLOAD-ORIGINAL-NAME-PATH"}),
    # --- interplay ---
    ("test/a.js", "app.use(multer());\n", {"UPLOAD-NO-SIZE-LIMIT"}),
    ("a.js", "const allowed = ['png','jpg'];\n"
             "if (f.mimetype==='x') {}\n",
     {"UPLOAD-CLIENT-MIME-TRUST"}),  # a declared-type gate IS the class
    ("a.js", "const t = f.mimetype; log(t);\n", set()),
    ("a.js", "app.get('/status', h);\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())
