#!/usr/bin/env python3
"""deser_prescan.py — deterministic unsafe-deserialization / XXE source
pre-scan.

ARION service artifact. Scans source files for the deserialization and
XML-parser classes that are checkable offline, line-wise, without
executing code:

  * Python object deserialization: pickle/cPickle/dill load(s),
    marshal load(s), shelve.open (pickle-backed), jsonpickle decode,
    np.load(allow_pickle=True), pandas.read_pickle, yaml.load without a
    Safe loader / unsafe_load / full_load
  * JVM: ObjectInputStream / readObject / readUnshared, java.beans
    XMLDecoder, Jackson enableDefaultTyping/activateDefaultTyping/
    @JsonTypeInfo(Id.CLASS), XStream, SnakeYaml new Yaml(), Kryo
    readClassAndObject
  * .NET: BinaryFormatter / SoapFormatter / NetDataContractSerializer /
    LosFormatter / ObjectStateFormatter / JavaScriptSerializer,
    Newtonsoft TypeNameHandling != None
  * PHP: unserialize() / maybe_unserialize()
  * Ruby: Marshal.load/restore, YAML.load / Psych.load
  * Node: node-serialize / serialize-to-js / funcster (eval-bearing
    deserializers), js-yaml load (pre-v4 semantics)
  * Go: gob.NewDecoder/Decode on untrusted streams
  * XXE: DocumentBuilderFactory / SAXParserFactory / XMLInputFactory /
    SAXReader / DocumentHelper / Transformer / XPath / Schema factories,
    Python xml.etree/minidom/sax/lxml parsers, .NET XmlDocument/
    XmlReader/XslCompiledTransform, PHP simplexml/DOMDocument,
    entity-expansion enablers (resolve_entities=True, LIBXML_NOENT)
    — factory/parser hits downgrade to LOW when hardening markers
    (disallow-doctype, FEATURE_SECURE_PROCESSING, ACCESS_EXTERNAL_*,
    defusedxml, resolve_entities=False, XmlResolver=null,
    DtdProcessing, LIBXML_NONET, setExpandEntityReferences(false))
    appear anywhere in the same file

Honest scope: PRE-SCAN, not an audit. Line-based matching — it cannot
prove the sink is reachable from attacker input, see runtime parser
configuration, or resolve framework defaults. A FLAG means "deserves
human review"; a clean report is not proof deserialization is safe.

Usage:
  deser_prescan.py <file-or-dir> [--json]
  deser_prescan.py --selftest
Stdlib only. Exit 0 = no HIGH/MEDIUM findings, 1 = findings, 2 = usage error.
"""
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"}

SRC_EXT = {".py", ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".go",
           ".java", ".rb", ".php", ".cs", ".kt", ".kts", ".scala",
           ".groovy", ".cshtml"}

COMMENT_PREFIX = ("#", "//")
BLOCK_COMMENT_RE = re.compile(r"^\s*(\*|/\*|<!--)")

SAFE_YAML_LOADER_RE = re.compile(
    r"(?i)(Loader\s*=\s*(yaml\.)?(C?SafeLoader|SafeConstructor)|"
    r"yaml\.safe_load|Loader=SafeLoader|Loader=CSafeLoader)")
UNSAFE_YAML_LOADER_RE = re.compile(
    r"(?i)Loader\s*=\s*(yaml\.)?(Loader|FullLoader|UnsafeLoader)")

# XML parser-hardening markers — presence anywhere in the same file
# downgrades XXE factory/parser hits to LOW.
XML_HARDEN_RE = re.compile(
    r"(?i)(disallow-doctype|FEATURE_SECURE_PROCESSING|"
    r"ACCESS_EXTERNAL_DTD|ACCESS_EXTERNAL_SCHEMA|"
    r"SUPPORT_DTD[^\n]{0,40}false|"
    r"IS_SUPPORTING_EXTERNAL_ENTITIES[^\n]{0,40}false|"
    r"setExpandEntityReferences\s*\(\s*false|"
    r"setFeature\s*\([^)]*external-entities[^)]*false|"
    r"defusedxml|resolve_entities\s*=\s*False|"
    r"no_network\s*=\s*True|XmlResolver\s*=\s*null|"
    r"DtdProcessing\.(Prohibit|Ignore)|LIBXML_NONET)")


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

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


def is_comment_or_blank(line):
    s = line.strip()
    return (not s or any(s.startswith(p) for p in COMMENT_PREFIX)
            or BLOCK_COMMENT_RE.match(s))


# ---------------------------------------------------------------- rules
# (code, base_sev, regex, ctx, msg)
# ctx: None = base always; "yaml" = loader-aware tiering;
#      "php" = maybe_unserialize MEDIUM; "xxe" = file-hardening post-pass
RULES = [
    # --- Python object deserialization ---------------------------------
    ("PICKLE-LOAD", "HIGH",
     re.compile(r"\b(?:c?[Pp]ickle|dill)\.loads?\s*\(|"
                r"\bpickle\.Unpickler\s*\(|\bcPickle\.loads?\s*\("),
     None, "pickle/dill deserialization — arbitrary code execution on "
     "untrusted input; use json or a typed format"),
    ("YAML-UNSAFE-LOAD", "HIGH",
     re.compile(r"(?i)\byaml\.(load|unsafe_load|full_load|load_all)"
                r"\s*\("),
     "yaml", "yaml.load without a safe loader — arbitrary object "
     "construction; use safe_load / Loader=SafeLoader"),
    ("MARSHAL-LOAD", "MEDIUM",
     re.compile(r"\bmarshal\.loads?\s*\("),
     None, "marshal.load — code-adjacent object format, unsafe on "
     "untrusted input"),
    ("JSONPICKLE-DECODE", "MEDIUM",
     re.compile(r"\bjsonpickle\.(decode|loads?)\s*\("),
     None, "jsonpickle decodes arbitrary Python classes — treat input "
     "as code"),
    ("SHELVE-OPEN", "LOW",
     re.compile(r"\bshelve\.open\s*\("),
     None, "shelve is pickle-backed — never open files from untrusted "
     "sources"),
    ("NUMPY-PICKLE", "HIGH",
     re.compile(r"\b(?:numpy|np)\.load\s*\([^)]*allow_pickle\s*=\s*True"),
     None, "np.load(allow_pickle=True) — pickle deserialization inside "
     "array load"),
    ("PANDAS-READPICKLE", "MEDIUM",
     re.compile(r"\b(?:pandas|pd)\.read_pickle\s*\("),
     None, "pandas.read_pickle — pickle deserialization of the file"),
    # --- JVM -------------------------------------------------------------
    ("JAVA-DESERIALIZE", "HIGH",
     re.compile(r"\bnew\s+ObjectInputStream\s*\(|\breadObject\s*\(|"
                r"\breadUnshared\s*\(|java\.beans\.XMLDecoder|"
                r"\bnew\s+XMLDecoder\s*\("),
     None, "Java native deserialization — gadget-chain RCE class on "
     "untrusted input"),
    ("JACKSON-DEFAULT-TYPING", "HIGH",
     re.compile(r"enableDefaultTyping\s*\(|activateDefaultTyping\s*\(|"
                r"ObjectMapper\.DefaultTyping\.|"
                r"@JsonTypeInfo\s*\([^)]*Id\.CLASS"),
     None, "Jackson polymorphic default-typing — historic RCE gadget "
     "surface; use explicit @JsonSubTypes or a validator allowlist"),
    ("XSTREAM", "MEDIUM",
     re.compile(r"\bnew\s+XStream\s*\(|\.fromXML\s*\("),
     None, "XStream deserialization — requires an explicit allowlist/"
     "security setup for untrusted input"),
    ("SNAKEYAML-LOAD", "MEDIUM",
     re.compile(r"\bnew\s+Yaml\s*\(|org\.yaml\.snakeyaml|"
                r"new\s+Constructor\s*\(\s*Class|"
                r"new\s+Constructor\s*\(\s*\w+\.class"),
     None, "SnakeYaml Yaml().load — default Constructor allows "
     "arbitrary classes; use SafeConstructor"),
    ("KRYO-DESERIALIZE", "MEDIUM",
     re.compile(r"\breadClassAndObject\s*\(|setRegistrationRequired"
                r"\s*\(\s*false"),
     None, "Kryo unregistered-class deserialization — arbitrary class "
     "instantiation on untrusted input"),
    # --- .NET --------------------------------------------------------------
    ("DOTNET-DESERIALIZE", "HIGH",
     re.compile(r"\bBinaryFormatter\b|\bSoapFormatter\b|"
                r"\bNetDataContractSerializer\b|\bLosFormatter\b|"
                r"\bObjectStateFormatter\b|\bJavaScriptSerializer\b"),
     None, "dangerous .NET deserializer (BinaryFormatter family) — "
     "Microsoft: never use on untrusted input"),
    ("DOTNET-TYPENAMEHANDLING", "HIGH",
     re.compile(r"TypeNameHandling\s*=\s*TypeNameHandling\.(All|Auto|"
                r"Objects|Arrays)"),
     None, "Newtonsoft TypeNameHandling != None — deserializer will "
     "instantiate attacker-named types"),
    # --- PHP -------------------------------------------------------------
    ("PHP-UNSERIALIZE", "HIGH",
     re.compile(r"\b(?:maybe_)?unserialize\s*\("),
     "php", "PHP unserialize() — object-injection class on "
     "untrusted input"),
    # --- Ruby ------------------------------------------------------------
    ("RUBY-MARSHAL", "HIGH",
     re.compile(r"\bMarshal\.(load|restore)\s*\("),
     None, "Ruby Marshal.load — arbitrary object/code loading on "
     "untrusted input"),
    ("RUBY-YAML-LOAD", "MEDIUM",
     re.compile(r"\b(?:YAML|Psych)\.load\s*\("),
     None, "Ruby YAML.load (Psych < 4) — unsafe object loading; use "
     "safe_load"),
    # --- Node ------------------------------------------------------------
    ("NODE-DESERIALIZE-EVAL", "HIGH",
     re.compile(r"require\s*\(\s*['\"](?:node-serialize|serialize-to-js|"
                r"funcster|serialize\.js)['\"]|from\s+['\"](?:node-"
                r"serialize|serialize-to-js)['\"]"),
     None, "eval-bearing deserializer package — unserialize() executes "
     "embedded function bodies"),
    ("JS-YAML-LOAD", "MEDIUM",
     re.compile(r"\b(?:jsyaml|yaml)\.load\s*\("),
     "jsyaml", "js-yaml load() — pre-v4 unsafe-schema semantics; "
     "prefer yaml.load with explicit schema or JSON"),
    # --- Go ---------------------------------------------------------------
    ("GO-GOB-DECODE", "LOW",
     re.compile(r"\bgob\.NewDecoder\s*\(|\bgob\.NewEncoder"),
     None, "gob decode — not RCE-class but panics/type-confusion on "
     "untrusted input"),
    # --- XXE: entity-expansion explicitly enabled -------------------------
    ("XXE-ENTITIES-ON", "HIGH",
     re.compile(r"resolve_entities\s*=\s*True|\bLIBXML_NOENT\b|"
                r"setExpandEntityReferences\s*\(\s*true|"
                r"SUPPORT_DTD[^\n]{0,40}true|"
                r"IS_SUPPORTING_EXTERNAL_ENTITIES[^\n]{0,40}true"),
     None, "external entity expansion explicitly enabled — direct XXE "
     "exposure if input is untrusted"),
    # --- XXE: parser/factory without visible hardening --------------------
    ("XXE-JAVA-PARSER", "MEDIUM",
     re.compile(r"DocumentBuilderFactory\.newInstance|SAXParserFactory"
                r"\.newInstance|XMLInputFactory\.newInstance|"
                r"XMLReaderFactory\.|new\s+SAXReader\s*\(|"
                r"DocumentHelper\.parseText|TransformerFactory"
                r"\.newInstance|XPathFactory\.newInstance|"
                r"SchemaFactory\.newInstance|"
                r"XMLConstants\.ACCESS_EXTERNAL"),
     "xxe", "JAXP/SAX/dom4j XML parser — XXE unless doctype/external-"
     "entity features disabled"),
    ("XXE-PY-PARSER", "MEDIUM",
     re.compile(r"xml\.etree\.ElementTree\.(?:parse|fromstring|iterparse|"
                r"XML)\s*\(|xml\.dom\.minidom\.parse|"
                r"xml\.dom\.pulldom|xml\.sax\.parse|"
                r"xml\.sax\.make_parser|sax2exts|"
                r"\betree\.(?:parse|fromstring|XML|XMLParser)\s*\(|"
                r"\bET\.(?:parse|fromstring|XML|iterparse|XMLParser)"
                r"\s*\(|lxml\.etree"),
     "xxe", "Python XML parser — stdlib parsers ignore external "
     "general entities but lxml resolves them by default; prefer "
     "defusedxml"),
    ("XXE-DOTNET", "MEDIUM",
     re.compile(r"\bnew\s+XmlDocument\s*\(|XmlReader\.Create|"
                r"XslCompiledTransform|new\s+XmlTextReader|"
                r"XmlDocument\.Load"),
     "xxe", ".NET XML parser — set XmlResolver=null and "
     "DtdProcessing=Prohibit/Ignore for untrusted input"),
    ("XXE-PHP", "MEDIUM",
     re.compile(r"\bsimplexml_load_(?:string|file)\s*\(|"
                r"->loadXML\s*\(|->loadHTML\s*\(|new\s+DOMDocument|"
                r"\bxml_parse\s*\("),
     "xxe", "PHP XML parser — pass LIBXML_NONET and avoid LIBXML_NOENT "
     "for untrusted input"),
]


def adjust_sev(rule_ctx, base, line, fpath):
    ext = os.path.splitext(fpath)[1].lower()
    if rule_ctx == "yaml":
        if "unsafe_load" in line:
            return "HIGH"
        if "full_load" in line:
            return "MEDIUM"
        if SAFE_YAML_LOADER_RE.search(line):
            return None
        if UNSAFE_YAML_LOADER_RE.search(line):
            return "MEDIUM" if ext in (".js", ".ts", ".mjs",
                                       ".jsx", ".tsx") else "HIGH"
        # bare yaml.load(...) — Python is unsafe-by-default; js-yaml v4+
        # defaults to safe schema; JVM SnakeYaml covered by its own rule
        if ext in (".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx"):
            return "MEDIUM"
        if ext in (".java", ".kt", ".kts", ".scala", ".groovy"):
            return None
        return "HIGH"
    if rule_ctx == "php":
        return "MEDIUM" if "maybe_unserialize" in line else "HIGH"
    if rule_ctx == "jsyaml":
        return base if ext in (".js", ".mjs", ".cjs", ".jsx", ".ts",
                               ".tsx") else None
    return base


def scan_line(line, fpath, lineno, out):
    if is_comment_or_blank(line):
        return
    hit = set()
    for code, base, rx, ctx, msg in RULES:
        if not rx.search(line):
            continue
        sev = adjust_sev(ctx, base, line, fpath)
        if sev is None:
            continue
        hit.add(code)
        out.append(Finding(code, sev, fpath, lineno, msg))
    # entity-expansion flag already carries the parser line's meaning
    if "XXE-ENTITIES-ON" in hit:
        out[:] = [f for f in out
                  if not (f.file == fpath and f.line == lineno
                          and f.code == "XXE-PY-PARSER")]


def xxe_followup(text, fpath, lineno_map, out):
    """File-level pass: downgrade XXE parser/factory findings to LOW
    when hardening markers appear anywhere in the same file."""
    if not XML_HARDEN_RE.search(text):
        return
    for f in out:
        if f.file == fpath and f.code.startswith("XXE-") and \
                f.code != "XXE-ENTITIES-ON" and f.sev == "MEDIUM":
            f.sev = "LOW"
            f.msg += " (hardening marker present in file — verify it "
            "covers this instance)"


def iter_source_files(target):
    if os.path.isfile(target):
        yield target
        return
    for root, dirs, files in os.walk(target):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for f in sorted(files):
            if os.path.splitext(f)[1].lower() in SRC_EXT:
                yield os.path.join(root, f)


def scan(target):
    findings = []
    scanned = 0
    for path in iter_source_files(target):
        rel = os.path.relpath(path, target) if os.path.isdir(target) \
            else os.path.basename(path)
        try:
            with open(path, "r", errors="replace") as fh:
                text = fh.read()
        except OSError:
            continue
        scanned += 1
        for i, line in enumerate(text.splitlines(), 1):
            scan_line(line, rel, i, findings)
        xxe_followup(text, rel, None, findings)
    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 = ["# Unsafe-deserialization / XXE pre-scan",
           "",
           f"- target: `{target}`",
           f"- source 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-based; no "
           "dataflow, no reachability proof, no runtime config. A clean "
           "report is not proof deserialization is safe._",
           ""]
    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 = [
    # (name, suffix, source, expected code:sev set)
    ("py-pickle", ".py",
     "import pickle\ndata = pickle.loads(req.body)\n",
     {"PICKLE-LOAD:HIGH"}),
    ("py-yaml-bare", ".py",
     "import yaml\nobj = yaml.load(open('f.yml'))\n",
     {"YAML-UNSAFE-LOAD:HIGH"}),
    ("py-yaml-safeloader", ".py",
     "import yaml\nobj = yaml.load(open('f.yml'), Loader=yaml.SafeLoader)\n",
     set()),
    ("py-yaml-safe-load", ".py",
     "import yaml\nobj = yaml.safe_load(open('f.yml'))\n",
     set()),
    ("py-yaml-unsafe", ".py",
     "import yaml\nobj = yaml.unsafe_load(open('f.yml'))\n",
     {"YAML-UNSAFE-LOAD:HIGH"}),
    ("py-yaml-full", ".py",
     "import yaml\nobj = yaml.full_load(open('f.yml'))\n",
     {"YAML-UNSAFE-LOAD:MEDIUM"}),
    ("py-marshal", ".py",
     "import marshal\nx = marshal.loads(blob)\n",
     {"MARSHAL-LOAD:MEDIUM"}),
    ("py-jsonpickle", ".py",
     "import jsonpickle\no = jsonpickle.decode(s)\n",
     {"JSONPICKLE-DECODE:MEDIUM"}),
    ("py-np-pickle", ".py",
     "import numpy as np\na = np.load('x.npy', allow_pickle=True)\n",
     {"NUMPY-PICKLE:HIGH"}),
    ("py-pandas-pickle", ".py",
     "import pandas as pd\ndf = pd.read_pickle('x.pkl')\n",
     {"PANDAS-READPICKLE:MEDIUM"}),
    ("java-ois", ".java",
     "ObjectInputStream ois = new ObjectInputStream(in);\n"
     "Object o = ois.readObject();\n",
     {"JAVA-DESERIALIZE:HIGH"}),
    ("java-jackson-typing", ".java",
     "mapper.enableDefaultTyping();\n",
     {"JACKSON-DEFAULT-TYPING:HIGH"}),
    ("java-xmldecoder", ".java",
     "XMLDecoder d = new XMLDecoder(new FileInputStream(f));\n",
     {"JAVA-DESERIALIZE:HIGH"}),
    ("java-snakeyaml", ".java",
     "Yaml yaml = new Yaml();\nObject o = yaml.load(in);\n",
     {"SNAKEYAML-LOAD:MEDIUM"}),
    ("cs-binaryformatter", ".cs",
     "var bf = new BinaryFormatter();\nvar o = bf.Deserialize(s);\n",
     {"DOTNET-DESERIALIZE:HIGH"}),
    ("cs-typename-all", ".cs",
     "settings.TypeNameHandling = TypeNameHandling.All;\n",
     {"DOTNET-TYPENAMEHANDLING:HIGH"}),
    ("cs-typename-none-ok", ".cs",
     "settings.TypeNameHandling = TypeNameHandling.None;\n",
     set()),
    ("php-unserialize", ".php",
     "$o = unserialize($_GET['d']);\n",
     {"PHP-UNSERIALIZE:HIGH"}),
    ("php-maybe", ".php",
     "$o = maybe_unserialize($v);\n",
     {"PHP-UNSERIALIZE:MEDIUM"}),
    ("rb-marshal", ".rb",
     "obj = Marshal.load(data)\n",
     {"RUBY-MARSHAL:HIGH"}),
    ("js-node-serialize", ".js",
     "var s = require('node-serialize');\n",
     {"NODE-DESERIALIZE-EVAL:HIGH"}),
    ("js-yaml-load", ".js",
     "const yaml = require('js-yaml');\nvar o = yaml.load(s);\n",
     {"JS-YAML-LOAD:MEDIUM", "YAML-UNSAFE-LOAD:MEDIUM"}),
    ("java-xxe-nofix", ".java",
     "DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();\n"
     "DocumentBuilder b = f.newDocumentBuilder();\n",
     {"XXE-JAVA-PARSER:MEDIUM"}),
    ("java-xxe-hardened", ".java",
     "DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();\n"
     "f.setFeature(\"http://apache.org/xml/features/disallow-doctype-"
     "decl\", true);\nDocumentBuilder b = f.newDocumentBuilder();\n",
     {"XXE-JAVA-PARSER:LOW"}),
    ("py-xxe-stdlib", ".py",
     "import xml.etree.ElementTree as ET\nt = ET.parse('u.xml')\n",
     {"XXE-PY-PARSER:MEDIUM"}),
    ("py-xxe-defused", ".py",
     "from defusedxml import ElementTree as ET\nt = ET.parse('u.xml')\n",
     {"XXE-PY-PARSER:LOW"}),
    ("py-lxml-resolve", ".py",
     "p = etree.XMLParser(resolve_entities=True)\n",
     {"XXE-ENTITIES-ON:HIGH"}),
    ("php-libxml-noent", ".php",
     "$d->loadXML($x, LIBXML_NOENT);\n",
     {"XXE-ENTITIES-ON:HIGH", "XXE-PHP:MEDIUM"}),
    ("cs-xml-doc", ".cs",
     "var doc = new XmlDocument();\ndoc.LoadXml(input);\n",
     {"XXE-DOTNET:MEDIUM"}),
    ("cs-xml-hardened", ".cs",
     "var doc = new XmlDocument();\ndoc.XmlResolver = null;\n"
     "doc.LoadXml(input);\n",
     {"XXE-DOTNET:LOW"}),
    ("py-clean", ".py",
     "import json\nobj = json.loads(req.body)\n",
     set()),
]


def selftest():
    import tempfile
    fails = []
    for name, suffix, src, expect in SELFTEST:
        with tempfile.NamedTemporaryFile(
                "w", suffix=suffix, 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))
