#!/usr/bin/env python3
"""path_prescan.py — deterministic path-traversal / file-handling pre-scan.

ARION service artifact. Scans source files for the signatures of
path traversal, archive-slip, local file inclusion, unsafe upload
naming, and insecure temp/destructive file handling — line-wise,
fully offline, no inference.

Findings (base severity):
  PT-REQ-FILE-SINK   HIGH    request-controlled value reaches a
                             filesystem open/read/write/serve/delete
                             sink on the same line (req.query,
                             request.args, params[], $_GET,
                             getParameter, c.Param, Request.Query ...)
  PT-ARCHIVE-EXTRACT HIGH    archive extraction without a traversal
                             filter/guard — extractall/extractTo/
                             ExtractToDirectory/unpack_archive/
                             ZipInputStream/unzip/tar -x/Expand-Archive
                             (Zip Slip class; CVE-2007-4559 family)
  PT-PHP-INCLUDE     HIGH    include/require with a superglobal;
                             MEDIUM when the argument is any variable
                             (LFI/RFI surface)
  PT-ENTRY-NAME      MEDIUM  archive-entry name joined into an output
                             path inside a manual extract loop
                             (entry.getName()/f.Name()/info.filename/
                             member.name -> Join/new File/Combine)
  PT-JOIN-VAR        MEDIUM  path-join of a base with a
                             user-data-flavoured segment (req/param/
                             user/input/upload/remote/env-named var)
                             and no guard marker — join does NOT
                             stop ".."
  PT-DYNAMIC-SERVE   MEDIUM  web file-serve/download sink
                             (res.sendFile, res.download, send_file,
                             http.ServeFile, PhysicalFile, readfile,
                             return File(...) ) with a non-literal arg
  PT-DYNAMIC-DELETE  LOW     destructive sink (unlink/os.remove/
                             shutil.rmtree/fs.rm/File.Delete/
                             Files.delete) with a non-literal arg —
                             routine temp cleanup is idiomatic, so
                             this tier is review-only
  PT-UPLOAD-NAME     MEDIUM  attacker-controlled upload filename
                             (originalname/$_FILES/.filename/
                             .FileName/getOriginalFilename/
                             getSubmittedFileName/transferTo) reaching
                             a join/move/save/copy verb on the line
  PT-DOTDOT-CONCAT   LOW     "../" literal concatenated or interpolated
                             with a variable (import/require/from
                             lines suppressed)
  PT-MKTEMP          LOW     mktemp()/tmpnam()/tempnam() — predictable
                             temp path, symlink-race prone
  PT-CHMOD-WIDE      LOW     chmod 777 family / umask(0) in app code

Severity is context-adjusted:
  * a guard marker on the same line (startswith/prefix check,
    secure_filename, safe_join, realpath/canonical resolution,
    basename(), filepath.Clean, GetFullPath, tar filter=) downgrades
    the finding one tier -> LOW for human review, not suppression
  * test/spec/fixture paths downgrade one tier
  * comment lines and block-comment bodies are skipped

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

  path_prescan.py <file-or-dir> [--json]
  path_prescan.py --selftest
Stdlib only. Exit 0 = no HIGH/MEDIUM findings, 1 = findings, 2 = usage.
"""
import json
import os
import re
import sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}

TARGET_EXT = (
    ".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
    ".php", ".rb", ".go", ".java", ".kt", ".cs", ".sh",
)

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*$)")

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

# same-line guard markers — presence downgrades one tier (review still
# needed: resolve() alone does not stop ".."; the marker means a human
# should check the prefix comparison actually exists)
GUARD = re.compile(
    r"starts?[wW]ith|startswith|secure_filename|safe_join|basename\s*\(|"
    r"commonpath|commonprefix|realpath|canonical|Canonical|"
    r"getCanonicalPath|getCanonicalFile|filepath\.Clean|filepath\.Abs|"
    r"GetFullPath|isPathInside|within\s*\(|Path\.GetFileName|"
    r"sanitize|Sanitize|filter\s*=|relative\s*\(|"
    r"os\.path\.relpath|path\.relative",
    re.I)

# request-controlled source markers (cross-stack)
REQ_SRC = re.compile(
    r"\b(?:req|request|ctx|context)\s*\.\s*(?:query|params?|body|"
    r"headers?|args|form|GET|POST|files|values|json|input|"
    r"query_params|path_params|cookies|url)|"
    r"\bparams\s*\[|"
    r"\$_(?:GET|POST|REQUEST|COOKIE|FILES|SERVER)|"
    r"\bgetParameter\s*\(|\bgetQueryString|@RequestParam|@PathVariable|"
    r"\bc\s*\.\s*(?:Param|Query|QueryParam|FormValue|PathParam|URLParam)|"
    r"\bRequest\s*\.\s*(?:Query|Form|Params|Headers|Get)|"
    r"\bInput::get|\$request->|"
    r"\b(?:req|request|ctx)\s*\[\s*['\"]",
    re.I)

# --- class 1: file read/write/serve sinks -----------------------------------

FILE_SINK = re.compile(
    r"\b(?:open|io\.open)\s*\(|"
    r"\bfs\s*\.\s*(?:readFile|readFileSync|createReadStream|"
    r"createWriteStream|writeFile|writeFileSync|appendFile|"
    r"appendFileSync|readdir|readdirSync|stat|statSync|access|"
    r"accessSync|copyFile|rename|renameSync)\s*\(|"
    r"\b(?:readFileSync|writeFileSync|createReadStream)\s*\(|"
    r"\bsend_file\b|\bx_sendfile\s*\(|"
    r"\bFile\s*\.\s*(?:read|open|write|readlines|binread)\b|"
    r"\bIO\s*\.\s*(?:read|write|binread)\b|"
    r"\bos\s*\.\s*(?:Open|ReadFile|WriteFile|OpenFile|Create)\s*\(|"
    r"\bioutil\s*\.\s*(?:ReadFile|WriteFile)\s*\(|"
    r"\bFiles\s*\.\s*(?:read|readString|readAllBytes|readAllLines|"
    r"newInputStream|newBufferedReader|copy|write|lines)\s*\(|"
    r"\bnew\s+(?:FileInputStream|FileReader|FileWriter|"
    r"FileOutputStream|RandomAccessFile)\s*\(|"
    r"\bFile\s*\.\s*(?:ReadAllText|ReadAllBytes|ReadAllLines|"
    r"OpenRead|OpenText|WriteAllText|AppendAllText)\s*\(|"
    r"\bPath\s*\.\s*of\s*\(|\bPaths\s*\.\s*get\s*\(|"
    r"\breadfile\s*\(|\bfopen\s*\(|\bfile_get_contents\s*\(|"
    r"\bfile_put_contents\s*\(|\bfpassthru\s*\(|"
    r"\bhttp\s*\.\s*(?:ServeFile|ServeContent|ServeFileFS)\s*\(|"
    r"\bnew\s+File\s*\(|\bfile\s*\(\s*\$")

# --- class 2: archive extraction (Zip Slip family) ---------------------------

ARCHIVE_EXTRACT = re.compile(
    r"\b\w*(?:zip|tar|archive|phar)\w*\s*\.\s*(?:extractall|extract|"
    r"extractTo|decompress)\s*\(|"
    r"\bextractall\s*\(|"
    r"\bextractToDirectory\s*\(|\bExtractToDirectory\s*\(|"
    r"\bshutil\s*\.\s*unpack_archive\s*\(|"
    r"\bnew\s+ZipInputStream|\bnew\s+ZipArchive|->\s*extractTo\s*\(|"
    r"\bZipArchive\s*::|->\s*open\s*\([^)]*\.(?:zip|phar)|"
    r"\bunzip\s+(?:-o|-d|-q|\S)|"
    r"\btar\s+(?:-[a-zA-Z]*x|--extract\b|x[a-zA-Z]*\s)|"
    r"\bExpand-Archive\b|\b7z\s+x\b|"
    r"\bentry\s*\.\s*extract\s*\(|\bentry\s*\.\s*writeTo\s*\(",
    re.I)

# archive-entry filename used to build the extraction path — the
# manual-loop form of Zip Slip
ENTRY_NAME = re.compile(
    r"(?:entry|ze|zipEntry)\s*\.\s*(?:getName|Name)\s*\(|"
    r"(?:f|e|file)\s*\.\s*Name\s*\(\s*\)|"
    r"(?:info|member|header|tarinfo)\s*\.\s*(?:filename|name)\b|"
    r"\bgetEntryName\s*\(")
ENTRY_JOIN = re.compile(
    r"Join|new\s+File|Path\.Combine|path\.join|os\.path\.join|"
    r"resolveSibling|FileOutputStream|createWriteStream|open\s*\(|"
    r"File\s*\.\s*(?:create|open)|Files\s*\.\s*(?:copy|create)")

# --- class 3: PHP include / require -----------------------------------------

PHP_INCLUDE = re.compile(
    r"\b(?:include|include_once|require|require_once)\b"
    r"\s*\(?\s*([^\s;)]*)", re.I)

# --- class 4: path joins -----------------------------------------------------

JOIN_CALL = re.compile(
    r"\b(?:os\.path\.join|path\.join|path\.posix\.join|path\.win32\.join|"
    r"path\.resolve|path\.normalize|os\.path\.normpath|"
    r"filepath\.Join|filepath\.Abs|"
    r"Path\.Combine|Path\.Join|Path\.GetFullPath|"
    r"File\.join|Rails\.root\.join|"
    r"Paths?\.get|resolveSibling|join_path|"
    r"File\.new|Pathname\.new)\s*\(")

LITERAL_ARG = re.compile(
    r"^\s*(?:'[^']*'|\"[^\"]*\"|`[^`$]*`|\d+|true|false|null|nil|"
    r"None|__dirname|__file__|__DIR__|[A-Z_][A-Z0-9_]{2,})\s*$")


# join-arg names that smell like external/user data — a bare
# `os.path.join(dir, name)` is idiomatic, `join(base, user_file)`
# deserves a look
JOIN_HINT = re.compile(
    r"req|request|param|query|user|input|upload|remote|client|"
    r"form|environ|\benv\b|argv|url|target|submitted|filename|"
    r"file_name|fname",
    re.I)


def _nonliteral_join_args(code, m):
    """Rough arg split of the join call on this line; returns the
    non-literal non-first args, or [] when all are literal."""
    tail = code[m.start():]
    open_i = tail.find("(")
    depth = 0
    end = len(tail)
    for j in range(open_i, len(tail)):
        c = tail[j]
        if c == "(":
            depth += 1
        elif c == ")":
            depth -= 1
            if depth == 0:
                end = j
                break
    inner = tail[open_i + 1:end]
    # split top-level commas only
    args, depth, cur = [], 0, []
    for c in inner:
        if c in "([{":
            depth += 1
        elif c in ")]}":
            depth -= 1
        if c == "," and depth == 0:
            args.append("".join(cur))
            cur = []
        else:
            cur.append(c)
    args.append("".join(cur))
    out = []
    for a in args[1:]:
        a = a.strip()
        if not a or LITERAL_ARG.match(a):
            continue
        if a[0] in "'\"`" and "${" not in a and "#{" not in a:
            continue
        out.append(a)
    return out


# --- class 5/6: serve + delete sinks ----------------------------------------

SERVE_SINK = re.compile(
    r"\bres\s*\.\s*(?:sendFile|download)\s*\(|"
    r"\bresponse\s*\.\s*(?:sendFile|download|TransmitFile|WriteFile)\s*\(|"
    r"\bsend_file\b|\bx_sendfile\s*\(|"
    r"\bhttp\s*\.\s*(?:ServeFile|ServeContent|ServeFileFS)\s*\(|"
    r"\b(?:PhysicalFile|VirtualFile|FileResult)\s*\(|"
    r"\breturn\s+File\s*\(|"
    r"\breadfile\s*\(|\bfpassthru\s*\(")

DELETE_SINK = re.compile(
    r"\bfs\s*\.\s*(?:unlink|unlinkSync|rm|rmSync|rmdir|rmdirSync)\s*\(|"
    r"\bos\s*\.\s*(?:remove|removedirs|rmdir|unlink)\s*\(|"
    r"\bshutil\s*\.\s*rmtree\s*\(|\bpathlib\.\w*\.unlink\s*\(|"
    r"\.unlink\s*\(\s*\)|"
    r"\bFile\s*\.\s*Delete\s*\(|\bFileUtils\s*\.\s*(?:delete|"
    r"forceDelete|deleteDirectory)\s*\(|"
    r"\bFiles\s*\.\s*delete(?:IfExists)?\s*\(|"
    r"\bunlink\s*\(|\brmdir\s*\(")

CALL_ALL_LITERAL = re.compile(
    r"\(\s*(?:'[^']*'|\"[^\"]*\"|`[^`$]*`|\d+|true|false|null|nil|"
    r"None|__dirname|__file__|__DIR__|PATH\.?\w*|"
    r"[A-Z_]{3,})\s*"
    r"(?:,\s*(?:'[^']*'|\"[^\"]*\"|`[^`$]*`|\d+|true|false|null|nil|"
    r"None|true)\s*)*\)")

# --- class 7: upload filename -------------------------------------------------

UPLOAD_NAME = re.compile(
    r"originalname|"
    r"(?:f|file|upload|uploaded_file|attachment|storage|form\.file|"
    r"part)\s*\.\s*filename\b|"
    r"\$_FILES|\.FileName\b|"
    r"getSubmittedFileName|getOriginalFilename|MultipartFile|"
    r"IFormFile|\.transferTo\s*\(|file\.name\b|part\.getName\s*\(|"
    r"uploadedFile|form\.file|request\.files|req\.files")
UPLOAD_VERB = re.compile(
    r"join|Combine|move_uploaded_file|transferTo|save|write|copy|"
    r"dest|target|rename|store|put|Files\.copy|create|sink|open|"
    r"FileOutputStream|createWriteStream",
    re.I)

# --- class 8: "../" concat/interp ---------------------------------------------

DOTDOT = re.compile(
    r'\.\.[/\\][^\'"`]*\$\{|'                       # `../${x}`
    r'\$\{[^}]*\}\s*[^\'"`]*\.\.[/\\]|'
    r'[\'"`][^\'"`]*\.\.[/\\][^\'"`]*[\'"`]\s*\+|'  # '../' + x
    r'\+\s*[\'"`][^\'"`]*\.\.[/\\][^\'"`]*[\'"`]|'  # x + '../'
    r'[\'"`][^\'"`]*\.\.[/\\][\'"`]\s*[,)]|'        # f('../', x)
    r'\.\.[/\\][^\'"`]*%')
DOTDOT_SUPPRESS = re.compile(
    r"\bimport\b|\brequire\s*\(|^\s*from\s+['\"]|#include|\buse\s+\w|"
    r"from ['\"]|include_path|source\s+['\"]|"
    r"module\.exports|export\s+\*")

# --- class 9/10: misc ---------------------------------------------------------

MKTEMP = re.compile(
    r"\bmktemp\s*\(|\btmpnam\s*\(|\btempnam\s*\(|"
    r"os\s*\.\s*tmpnam|tempfile\s*\.\s*mktemp")

CHMOD_WIDE = re.compile(
    r"\bchmod\s*\([^)]*\b(?:0o?777|0b111111111|0777|S_IRWXO)\b|"
    r"\bchmod\s+(?:-R\s+)?(?:777|a\+rwx)\b|"
    r"\bumask\s*\(\s*0{1,2}\s*\)|\bumask\s+0{3,4}\b")


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 _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"):
        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 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))

        has_req = REQ_SRC.search(code)
        is_import = IMPORT_LINE.search(code)
        sink_hit = FILE_SINK.search(code)
        serve_hit = SERVE_SINK.search(code) and not is_import
        del_hit = DELETE_SINK.search(code) and not is_import

        # class 1 — request value straight into a filesystem sink
        if has_req and (sink_hit or serve_hit or del_hit or
                        JOIN_CALL.search(code)):
            add("PT-REQ-FILE-SINK", "HIGH",
                "request-controlled value reaches a filesystem sink")

        # class 2 — archive extraction (Zip Slip)
        entry_hit = (ENTRY_NAME.search(code)
                     and ENTRY_JOIN.search(code))
        if ARCHIVE_EXTRACT.search(code):
            add("PT-ARCHIVE-EXTRACT", "HIGH",
                "archive extract without traversal filter/guard")
        elif entry_hit:
            add("PT-ENTRY-NAME", "MEDIUM",
                "archive-entry name used to build output path")

        # class 3 — PHP include/require with a variable
        if ext == ".php":
            m = PHP_INCLUDE.search(code)
            if m:
                arg = m.group(1)
                if "$_" in arg or "$_COOKIE" in arg:
                    add("PT-PHP-INCLUDE", "HIGH",
                        "include/require with superglobal input")
                elif "$" in arg:
                    add("PT-PHP-INCLUDE", "MEDIUM",
                        "include/require with a variable path")

        # class 4 — join(base, <non-literal>) without a guard marker
        upload_hit = (UPLOAD_NAME.search(code)
                      and UPLOAD_VERB.search(code))
        claimed = (has_req or serve_hit or del_hit
                   or entry_hit or upload_hit)
        jm = JOIN_CALL.search(code)
        if jm and not claimed:
            nl = _nonliteral_join_args(code, jm)
            if nl and JOIN_HINT.search(" ".join(nl)):
                add("PT-JOIN-VAR", "MEDIUM",
                    "join/resolve of user-data segment does not "
                    "stop '..'")

        # class 5 — dynamic file serve/download
        if serve_hit and not CALL_ALL_LITERAL.search(code):
            if not has_req:
                add("PT-DYNAMIC-SERVE", "MEDIUM",
                    "file serve/download path is non-literal")

        # class 6 — destructive sink on a non-literal path
        if del_hit and not CALL_ALL_LITERAL.search(code):
            if not has_req:
                add("PT-DYNAMIC-DELETE", "LOW",
                    "delete/rmtree target is non-literal")

        # class 7 — upload filename into destination path
        if upload_hit and not entry_hit:
            add("PT-UPLOAD-NAME", "MEDIUM",
                "upload-supplied filename reaches a destination path")

        # class 8 — literal '../' combined with a variable
        if DOTDOT.search(code) and not DOTDOT_SUPPRESS.search(code):
            add("PT-DOTDOT-CONCAT", "LOW",
                "'../' literal combined with a variable")

        # class 9 — insecure tempfile
        if MKTEMP.search(code):
            add("PT-MKTEMP", "LOW",
                "predictable temp path — symlink-race prone")

        # class 10 — world-writable permission
        if CHMOD_WIDE.search(code):
            add("PT-CHMOD-WIDE", "LOW",
                "world-writable permission bits in app code")
    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 = ["path_prescan report — %d file(s), %d finding(s)"
             % (len(targets), len(findings)), ""]
    for f in findings:
        lines.append("%-6s %-19s %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 != path-safe")
    return "\n".join(lines)


SELFTEST = [
    # --- request value -> sink ---
    ("a.py", "open(request.args['f'])\n", {"PT-REQ-FILE-SINK"}),
    ("a.js", "fs.readFile(req.query.name, cb);\n", {"PT-REQ-FILE-SINK"}),
    ("a.js", "res.sendFile(req.params.f);\n", {"PT-REQ-FILE-SINK"}),
    ("a.php", "readfile($_GET['page']);\n", {"PT-REQ-FILE-SINK"}),
    ("a.java", "new FileInputStream(request.getParameter(\"p\"));\n",
     {"PT-REQ-FILE-SINK"}),
    ("a.go", "http.ServeFile(w, r, c.Param(\"f\"))\n",
     {"PT-REQ-FILE-SINK"}),
    ("a.cs", "return File(Request.Query[\"f\"], \"text/plain\");\n",
     {"PT-REQ-FILE-SINK"}),
    ("a.rb", "send_file params[:path]\n", {"PT-REQ-FILE-SINK"}),
    ("a.py", "open(request.args['f']) if safe.startswith(base)\n",
     {"PT-REQ-FILE-SINK"}),                                # -> LOW
    ("a.py", "open(config_path)\n", set()),                 # plain var
    # --- archive extract ---
    ("a.py", "zf.extractall(dest)\n", {"PT-ARCHIVE-EXTRACT"}),
    ("a.py", "tf.extractall(path=out, members=tf.getmembers())\n",
     {"PT-ARCHIVE-EXTRACT"}),
    ("a.py", "tf.extractall(out, filter='data')\n",
     {"PT-ARCHIVE-EXTRACT"}),                                # -> LOW
    ("a.java", "ZipFile.ExtractToDirectory(a, d);\n",
     {"PT-ARCHIVE-EXTRACT"}),
    ("a.sh", "unzip -o bundle.zip -d /srv/app\n",
     {"PT-ARCHIVE-EXTRACT"}),
    ("a.sh", "tar -xzf release.tgz\n", {"PT-ARCHIVE-EXTRACT"}),
    ("a.py", "shutil.unpack_archive(src, dst)\n",
     {"PT-ARCHIVE-EXTRACT"}),
    ("a.php", "$zip->extractTo('/var/www');\n",
     {"PT-ARCHIVE-EXTRACT"}),
    ("a.go", "out := filepath.Join(dir, f.Name())\n",
     {"PT-ENTRY-NAME"}),
    ("a.java", "File out = new File(destDir, entry.getName());\n",
     {"PT-ENTRY-NAME"}),
    ("a.py", "p = os.path.join(out, info.filename)\n",
     {"PT-ENTRY-NAME"}),
    ("a.go", "n := f.Name()\n", set()),                     # no join
    # --- php include ---
    ("a.php", "include $_GET['p'];\n", {"PT-PHP-INCLUDE"}),
    ("a.php", "require_once($tpl);\n", {"PT-PHP-INCLUDE"}),  # MEDIUM
    ("a.php", "include 'header.php';\n", set()),
    ("a.php", "require __DIR__ . '/config.php';\n", set()),
    # --- join-var ---
    ("a.js", "const p = path.join(__dirname, reqPath);\n",
     {"PT-JOIN-VAR"}),
    ("a.py", "p = os.path.join(base, fname)\n", {"PT-JOIN-VAR"}),
    ("a.cs", "var f = Path.Combine(root, name);\n", set()),  # generic
    ("a.cs", "var f = Path.Combine(root, userFile);\n",
     {"PT-JOIN-VAR"}),
    ("a.go", "p := filepath.Join(dir, user)\n", {"PT-JOIN-VAR"}),
    ("a.py", "p = os.path.join(base, 'static', 'x.css')\n", set()),
    ("a.js", "path.join(__dirname, 'public', 'index.html');\n", set()),
    ("a.py", "p = os.path.join(base, secure_filename(f))\n",
     {"PT-JOIN-VAR"}),                                       # -> LOW
    ("a.rb", "File.join(Rails.root, name)\n", set()),
    ("a.rb", "File.join(Rails.root, params_id)\n", {"PT-JOIN-VAR"}),
    # --- dynamic serve/delete ---
    ("a.js", "res.sendFile(fname);\n", {"PT-DYNAMIC-SERVE"}),
    ("a.js", "res.download(path.join(base, f));\n",
     {"PT-DYNAMIC-SERVE"}),
    ("a.py", "send_file(fname)\n", {"PT-DYNAMIC-SERVE"}),
    ("a.go", "http.ServeFile(w, r, p)\n", {"PT-DYNAMIC-SERVE"}),
    ("a.js", "res.sendFile('/srv/static/index.html');\n", set()),
    ("a.py", "send_from_directory(base, fname)\n", set()),  # safe_join
    ("a.js", "fs.unlink(target);\n", {"PT-DYNAMIC-DELETE"}),
    ("a.py", "shutil.rmtree(workdir)\n", {"PT-DYNAMIC-DELETE"}),
    ("a.cs", "File.Delete(tempPath);\n", {"PT-DYNAMIC-DELETE"}),
    ("a.py", "os.remove(path)\n", {"PT-DYNAMIC-DELETE"}),
    ("a.py", "os.remove('/tmp/x')\n", set()),
    ("a.js", "fs.unlinkSync('/tmp/lock');\n", set()),
    # --- upload name ---
    ("a.js", "cb(null, path.join(UP, file.originalname));\n",
     {"PT-UPLOAD-NAME"}),
    ("a.php", "move_uploaded_file($t, $d.$_FILES['f']['name']);\n",
     {"PT-UPLOAD-NAME"}),
    ("a.cs", "var p = Path.Combine(up, file.FileName);\n",
     {"PT-UPLOAD-NAME"}),
    ("a.java", "mf.transferTo(new File(dir, mf.getOriginalFilename()));\n",
     {"PT-UPLOAD-NAME"}),
    ("a.py", "save(os.path.join(up, f.filename))\n",
     {"PT-UPLOAD-NAME"}),
    ("a.py", "p = os.path.join(up, secure_filename(f.filename))\n",
     {"PT-UPLOAD-NAME"}),                                    # -> LOW
    ("a.js", "console.log(file.originalname);\n", set()),   # no verb
    # --- dotdot ---
    ("a.js", "p = '../' + name;\n", {"PT-DOTDOT-CONCAT"}),
    ("a.js", "s = `../${id}`;\n", {"PT-DOTDOT-CONCAT"}),
    ("a.js", "import x from '../lib/x';\n", set()),
    ("a.js", "const y = require('../util');\n", set()),
    # --- misc ---
    ("a.py", "tmp = tempfile.mktemp()\n", {"PT-MKTEMP"}),
    ("a.php", "$t = tempnam('/tmp', 'x');\n", {"PT-MKTEMP"}),
    ("a.py", "fd, p = tempfile.mkstemp()\n", set()),
    ("a.py", "os.chmod(f, 0o777)\n", {"PT-CHMOD-WIDE"}),
    ("a.sh", "chmod -R 777 /data\n", {"PT-CHMOD-WIDE"}),
    ("a.py", "os.umask(0)\n", {"PT-CHMOD-WIDE"}),
    # --- comments / clean ---
    ("a.py", "# zf.extractall(dest)\n", set()),
    ("a.js", "/* fs.unlink(x) */\n", set()),
    ("a.py", "def f():\n    return open(FIXTURE)\n", set()),
    ("a.ts", "if (raw.startsWith('//')) return null;\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())
