#!/usr/bin/env python3
"""xxe_prescan.py — deterministic XXE / unsafe-XML-parser pre-scan.

ARION service artifact. Scans source files for the signatures of
XML-external-entity exposure — parsers that resolve external entities
by default, explicit entity/DTD-enabling options, DTD-accepting
parsers (internal-expansion risk) and unrestricted XSLT/schema
external-resource access — line-wise, fully offline, no inference.

Findings (base severity):
  XXE-EXT-OPT        HIGH    an explicit entity/DTD-enabling option —
                             resolve_entities=True, LIBXML_NOENT,
                             noent:true / ParseOptions::NOENT / .noent,
                             DtdProcessing.Parse, XmlUrlResolver,
                             libxml_disable_entity_loader(false),
                             setExpandEntityReferences(true),
                             setXIncludeAware(true),
                             SUPPORT_DTD / isSupportingExternalEntities
                             set true, sax external-*-entities features
                             set true, FEATURE_SECURE_PROCESSING false
  XXE-DEFAULT-PARSER HIGH    parser/factory whose DEFAULT resolves
                             external entities and no hardening marker
                             nearby — DocumentBuilderFactory /
                             SAXParserFactory / XMLInputFactory /
                             XMLReaderFactory / SAXReader(dom4j) /
                             SAXBuilder(jdom) / Digester /
                             DocumentHelper.parseText, xml.sax
                             parse/parseString/make_parser, lxml
                             etree parse/fromstring/XML/XMLParser,
                             new XmlTextReader
  XXE-DTD-ENABLED    MEDIUM  DTD/internal-entity expansion accepted
                             (billion laughs / quadratic blowup) or
                             framework-default unclear — minidom,
                             pulldom, xml.etree/ET, expat
                             ParserCreate, REXML, Nokogiri::XML,
                             simplexml_load_*/SimpleXMLElement,
                             DOMDocument->load/loadXML, xml_parse,
                             XmlDocument.Load/LoadXml, XmlReader.Create,
                             JAXB unmarshal, .xinclude()
  XXE-EXT-ACCESS     MEDIUM  TransformerFactory / SAXTransformerFactory /
                             SchemaFactory / XPathFactory without a
                             visible ACCESS_EXTERNAL_* restriction —
                             external stylesheet/schema/DTD fetch

Context rules:
  * hardening markers downgrade one tier -> LOW for human review, not
    suppression. Markers apply within a +/-8-line window of the
    finding (setFeature/setProperty/setAttribute(XMLConstants|http...),
    disallow-doctype-decl, ACCESS_EXTERNAL_*, DtdProcessing.Prohibit/
    Ignore, XmlSecureResolver, XmlResolver=null,
    setXIncludeAware(false), setExpandEntityReferences(false),
    resolve_entities=False, no_network/nonet, external-*-entities
    false) — one hardened call site can't whitewash unhardened
    factories elsewhere in the file. Only request-global calls
    (libxml_disable_entity_loader(true), forbid_dtd/forbid_external)
    apply file-wide.
  * XXE-EXT-OPT findings are never downgraded and subsume softer
    findings on the same line — the dangerous option is explicit.
  * a `defused` import silences the python XML findings entirely
    (defusedxml IS the fix)
  * lxml vs stdlib etree disambiguated by the file's imports —
    `etree.`/`ET.` receivers read as lxml when `lxml` is imported,
    stdlib `xml.etree` otherwise
  * test/spec/fixture paths downgrade one tier
  * comment lines and block-comment bodies are skipped

Scope: deterministic pre-scan for human review — flags parser
constructions and options; no dataflow proof that untrusted input
reaches the parse call, clean output != XXE-safe.

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

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)

# --- explicit dangerous options -> XXE-EXT-OPT (HIGH, never downgraded) ---
EXT_OPT = re.compile(
    r"resolve_entities\s*[=:]\s*(?:True|true)|"
    r"LIBXML_NOENT|"
    r"ParseOptions::NOENT|(?<![\w:])\.noent\b|"
    r"DtdProcessing\s*[=:]\s*(?:DtdProcessing\.)?Parse|"
    r"DtdProcessing\.Parse|"
    r"XmlResolver\s*=\s*new\s+XmlUrlResolver|new\s+XmlUrlResolver\s*\(|"
    r"libxml_disable_entity_loader\s*\(\s*false|"
    r"setExpandEntityReferences\s*\(\s*true|"
    r"setXIncludeAware\s*\(\s*true|"
    r"(?:SUPPORT_DTD|IS_SUPPORTING_EXTERNAL_ENTITIES|"
    r"isSupportingExternalEntities|supportDTD)[^;\n]*\btrue\b|"
    r"external-(?:general|parameter)-entities['\"\s,)]+true|"
    r"load-external-dtd['\"\s,)]+true|"
    r"SECURE_PROCESSING[^;\n]*\bfalse\b")

# --- external-entity-resolving defaults -> XXE-DEFAULT-PARSER (HIGH) ---
JAVA_DEFAULT = re.compile(
    r"DocumentBuilderFactory\s*\.\s*new(?:NS)?Instance|"
    r"SAXParserFactory\s*\.\s*newInstance|"
    r"SAXParserFactory\s*\.\s*newDefaultInstance|"
    r"XMLInputFactory\s*\.\s*new(?:Factory|Instance)|"
    r"XMLReaderFactory\s*\.\s*createXMLReader|"
    r"new\s+(?:SAXReader|SAXBuilder|Digester)\s*\(|"
    r"DocumentHelper\s*\.\s*(?:parseText|parse)\s*\(")

CS_DEFAULT = re.compile(r"new\s+XmlTextReader\s*\(")

PY_SAX = re.compile(
    r"(?:xml\s*\.\s*)?sax\s*\.\s*(?:parse|parseString|make_parser)\s*\(|"
    r"\bmake_parser\s*\(")

# lxml receivers — gated on an lxml import in the file
LXML_SINK = re.compile(
    r"\b(?:etree|ET|lxml\s*\.\s*etree)\s*\.\s*"
    r"(?:parse|fromstring|XML|XMLParser|parseid|XMLTree|"
    r"ElementTree|iterparse|HTML)\s*\(|"
    r"\bXMLParser\s*\(")

# --- internal-expansion / unclear-default parsers -> XXE-DTD-ENABLED ---
MINIDOM_RX = re.compile(
    r"\bminidom\s*\.\s*(?:parse|parseString)\s*\(|"
    r"\bpulldom\s*\.\s*parse\s*\(")

ETREE_RX = re.compile(
    r"(?:xml\s*\.\s*)?etree\s*\.\s*(?:parse|fromstring|iterparse|"
    r"XMLParser|XML)\s*\(|"
    r"\bElementTree\s*(?:\(\s*\))?\s*\.\s*(?:parse|fromstring|iterparse|"
    r"XMLParser)\s*\(|"
    r"\bET\s*\.\s*(?:parse|fromstring|iterparse|XMLParser)\s*\(")

EXPAT_RX = re.compile(
    r"\bexpat\s*\.\s*ParserCreate\s*\(|\bParserCreate\s*\(")

PHP_PARSE = re.compile(
    r"\bsimplexml_load_(?:string|file)\s*\(|"
    r"new\s+SimpleXMLElement|simplexml_import_dom|"
    r"->\s*loadXML\s*\(|"
    r"\bxml_parse\s*\(|\bxml_parser_create\s*\(")

# ->load( is DOMDocument-specific only when the file already uses DOM/XML
PHP_DOM_LOAD = re.compile(r"->\s*load\s*\(")
HAS_DOMDOC = re.compile(r"DOMDocument|DomDocument|domdocument")

RB_PARSE = re.compile(
    r"REXML::(?:Document\s*\.\s*new|Parsers)|"
    r"Nokogiri::XML\s*\(|Nokogiri\s*\.\s*(?:XML|parse)\s*\(")

CS_PARSE = re.compile(
    r"new\s+XmlDocument\s*\(|\.LoadXml\s*\(|"
    r"XmlReader\s*\.\s*Create\s*\(")

# .Load( alone is XML only when the file already uses Xml types
CS_LOAD = re.compile(r"\.Load\s*\(")
HAS_XML = re.compile(r"\bXml[A-Z]|System\.Xml|XmlDocument|XmlReader")

JAVA_UNMARSHAL = re.compile(
    r"\.unmarshal\s*\(|JAXB\s*\.\s*unmarshal|Unmarshaller")

XINCLUDE = re.compile(r"\.xinclude\s*\(|XInclude\s*\.\s*include|"
                      r"etree\s*\.\s*XInclude")

JS_LIBXML_OPT = re.compile(r"noent\s*:\s*true")

# --- unrestricted external-resource access -> XXE-EXT-ACCESS (MEDIUM) ---
JAVA_EXT_ACCESS = re.compile(
    r"(?:SAX)?TransformerFactory\s*\.\s*newInstance|"
    r"SchemaFactory\s*\.\s*newInstance|"
    r"XPathFactory\s*\.\s*newInstance")

# truly global hardening — request/file-wide by nature, applies file-level
# (defusedxml silences python findings entirely via has_defused)
HARD_GLOBAL = re.compile(
    r"libxml_disable_entity_loader\s*\(\s*true|"
    r"forbid_dtd|forbid_external")

# hardening markers — only count within +-8 lines of the finding, so one
# hardened call site can't whitewash unhardened factories elsewhere
HARD_NEAR = re.compile(
    r"disallow-doctype-decl|"
    r"ACCESS_EXTERNAL_(?:DTD|SCHEMA|STYLESHEET)|"
    r"DtdProcessing\s*[=:.]\s*(?:DtdProcessing\.)?(?:Prohibit|Ignore)|"
    r"XmlSecureResolver|XmlResolver\s*=\s*null|"
    r"\.setFeature\s*\([^)]*(?:,\s*(?:false|False|FALSE)|"
    r"disallow-doctype-decl|SECURE_PROCESSING|secure-processing)|"
    r"(?<!System)\.setProperty\s*\([^)]*(?:,\s*(?:false|False|FALSE)|"
    r"ACCESS_EXTERNAL|disallow)|"
    r"setAttribute\s*\(\s*(?:XMLConstants|['\"]http)|"
    r"setXIncludeAware\s*\(\s*false|"
    r"setExpandEntityReferences\s*\(\s*false|"
    r"resolve_entities\s*=\s*False|resolve_entities\s*:\s*false|"
    r"no_network|nonet|NONET|harden|secure[ -]?process|"
    r"external-(?:general|parameter)-entities['\"\s,)]+false|"
    r"load-external-dtd['\"\s,)]+false")

HAS_LXML = re.compile(r"\blxml\b")
HAS_SAX = re.compile(r"xml\.sax|from\s+xml\s+import\s+sax|"
                     r"import\s+xml\.sax|from\s+xml\.sax")
HAS_ETREE = re.compile(r"xml\.etree|ElementTree|from\s+xml\s+import\s+"
                       r"etree|import\s+xml\.etree")
HAS_MINIDOM = re.compile(r"minidom|xml\.dom")
HAS_EXPAT = re.compile(r"parsers\.expat|import\s+expat|xml\.parsers")
HAS_LIBXMLJS = re.compile(
    r"require\s*\(\s*['\"]libxmljs|from\s+['\"]libxmljs|"
    r"import\s+[\w*\s{},]*libxmljs")


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"):
        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 = []
    seen = set()
    in_block = False
    codes = []          # stripped code per line index, for the window
    for i, raw in enumerate(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 "")

    # file markers read the comment-stripped text — a `// TODO: use
    # defusedxml` comment must not silence or downgrade findings
    stripped = "\n".join(codes)
    has_defused = "defused" in stripped
    has_lxml = bool(HAS_LXML.search(stripped))
    has_sax = bool(HAS_SAX.search(stripped))
    has_etree = bool(HAS_ETREE.search(stripped))
    has_minidom = bool(HAS_MINIDOM.search(stripped))
    has_expat = bool(HAS_EXPAT.search(stripped))
    has_libxmljs = bool(HAS_LIBXMLJS.search(stripped))
    has_domdoc = bool(HAS_DOMDOC.search(stripped))
    has_xml = bool(HAS_XML.search(stripped))
    file_hard = bool(HARD_GLOBAL.search(stripped))

    def near_hard(i):
        lo, hi = max(0, i - 8), min(len(codes), i + 9)
        return bool(HARD_NEAR.search("\n".join(codes[lo:hi])))

    for i, code in enumerate(codes):
        if not code.strip():
            continue
        is_import = IMPORT_LINE.search(code)

        def add(cls, sev, note, hardenable=True):
            if (cls, i + 1) in seen:
                return
            # an explicit dangerous option on this line subsumes the
            # softer parser-construction findings
            if cls != "XXE-EXT-OPT" and ("XXE-EXT-OPT", i + 1) in seen:
                return
            seen.add((cls, i + 1))
            if _is_test_path(rel) or (
                    hardenable and (file_hard or near_hard(i))):
                sev = "LOW" if sev != "LOW" else sev
            findings.append(Finding(cls, sev, rel, i + 1, lines[i], note))

        # explicit dangerous options — never downgraded by context
        ext_opt = bool(EXT_OPT.search(code))
        if ext_opt:
            add("XXE-EXT-OPT", "HIGH",
                "explicit entity/DTD-enabling option — external entities "
                "resolvable (file read / SSRF / DoS)", hardenable=False)
        if has_libxmljs and JS_LIBXML_OPT.search(code):
            add("XXE-EXT-OPT", "HIGH",
                "libxmljs noent:true — substitutes external entities",
                hardenable=False)

        if is_import:
            continue

        # HIGH default-resolving parsers
        if ext in (".java", ".kt", ".groovy"):
            if JAVA_DEFAULT.search(code):
                add("XXE-DEFAULT-PARSER", "HIGH",
                    "XML parser/factory resolves external entities by "
                    "default — needs disallow-doctype-decl / external-"
                    "entity feature-off")
            if JAVA_EXT_ACCESS.search(code):
                add("XXE-EXT-ACCESS", "MEDIUM",
                    "transformer/schema/xpath factory — external DTD/"
                    "stylesheet/schema fetch unless ACCESS_EXTERNAL_* "
                    "is locked down")
            if JAVA_UNMARSHAL.search(code):
                add("XXE-DTD-ENABLED", "MEDIUM",
                    "JAXB unmarshal — parses via SAX underneath; "
                    "external-entity exposure depends on the parser "
                    "behind it")

        if ext == ".py" and not has_defused:
            if has_sax and PY_SAX.search(code) and \
                    not re.match(r"\s*def\s", code):
                add("XXE-DEFAULT-PARSER", "HIGH",
                    "xml.sax resolves external entities by default — "
                    "needs feature_external_ges/pes disabled or "
                    "defusedxml")
            if has_lxml and LXML_SINK.search(code) and not ext_opt:
                add("XXE-DEFAULT-PARSER", "HIGH",
                    "lxml parser — entities resolvable without "
                    "resolve_entities=False / no_network / defusedxml")
            elif has_etree and ETREE_RX.search(code):
                # etree./ET. receivers already claimed by lxml when the
                # file imports lxml; ElementTree./xml.etree. always land
                add("XXE-DTD-ENABLED", "MEDIUM",
                    "stdlib etree parse — internal entity expansion "
                    "(billion laughs on old runtimes); external entities "
                    "not fetched")
            if has_minidom and MINIDOM_RX.search(code):
                add("XXE-DTD-ENABLED", "MEDIUM",
                    "minidom/pulldom parse — internal entity expansion; "
                    "external entities not fetched")
            if has_expat and EXPAT_RX.search(code):
                add("XXE-DTD-ENABLED", "MEDIUM",
                    "raw expat parser — internal entity expansion; "
                    "external entities only if a handler is set")
            if has_lxml and XINCLUDE.search(code):
                add("XXE-DTD-ENABLED", "MEDIUM",
                    "XInclude processing — can pull external documents "
                    "into the parse")

        if ext == ".php":
            if PHP_PARSE.search(code) or (
                    has_domdoc and PHP_DOM_LOAD.search(code)):
                add("XXE-DTD-ENABLED", "MEDIUM",
                    "libxml parse — internal entity expansion; external "
                    "entities only with LIBXML_NOENT (flagged separately)")

        if ext == ".rb" and RB_PARSE.search(code):
            add("XXE-DTD-ENABLED", "MEDIUM",
                "REXML/Nokogiri parse — internal entity expansion; "
                "external fetch needs NOENT (flagged separately)")

        if ext == ".cs":
            if CS_DEFAULT.search(code):
                add("XXE-DEFAULT-PARSER", "HIGH",
                    "XmlTextReader resolves external entities by "
                    "default — use XmlReader.Create with "
                    "DtdProcessing.Prohibit")
            if CS_PARSE.search(code) or (
                    has_xml and CS_LOAD.search(code)):
                add("XXE-DTD-ENABLED", "MEDIUM",
                    "XmlDocument/XmlReader parse — safe default only on "
                    ".NET >= 4.5.2; older frameworks resolve external "
                    "entities")
    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 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 = ["xxe_prescan report — %d file(s), %d finding(s)"
             % (len(targets), len(findings)), ""]
    for f in findings:
        lines.append("%-6s %-18s %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 parser "
                 "constructions/options for review; not an audit, "
                 "clean != XXE-safe")
    return "\n".join(lines)


SELFTEST = [
    # --- explicit dangerous options (HIGH, never downgraded) ---
    ("a.py", "from lxml import etree\n"
             "p = etree.XMLParser(resolve_entities=True)\n",
     {"XXE-EXT-OPT"}),
    ("a.php", "$x = simplexml_load_string($s, 'SimpleXMLElement', "
              "LIBXML_NOENT);\n", {"XXE-EXT-OPT"}),  # opt subsumes
    ("a.php", "libxml_disable_entity_loader(false);\n", {"XXE-EXT-OPT"}),
    ("a.php", "libxml_disable_entity_loader(true);\n", set()),
    ("a.cs", "settings.DtdProcessing = DtdProcessing.Parse;\n",
     {"XXE-EXT-OPT"}),
    ("a.cs", "doc.XmlResolver = new XmlUrlResolver();\n",
     {"XXE-EXT-OPT"}),
    ("a.java", "factory.setExpandEntityReferences(true);\n",
     {"XXE-EXT-OPT"}),
    ("a.java", "factory.setXIncludeAware(true);\n", {"XXE-EXT-OPT"}),
    ("a.java", "xif.setProperty(XMLInputFactory.SUPPORT_DTD, true);\n",
     {"XXE-EXT-OPT"}),
    ("a.java", "p.setFeature(\"http://xml.org/sax/features/"
              "external-general-entities\", true);\n", {"XXE-EXT-OPT"}),
    ("a.java", "tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, "
              "false);\n", {"XXE-EXT-OPT"}),
    ("a.rb", "Nokogiri::XML(doc, nil, nil, "
             "Nokogiri::XML::ParseOptions::NOENT)\n", {"XXE-EXT-OPT"}),
    ("a.js", "const libxmljs = require('libxmljs');\n"
             "libxmljs.parseXml(body, {noent: true});\n",
     {"XXE-EXT-OPT"}),
    ("a.js", "libxmljs.parseXml(body, {noent: true});\n",
     set()),   # no libxmljs import -> silent
    # safe variants -> silent
    ("a.py", "from lxml import etree\n"
             "p = etree.XMLParser(resolve_entities=False)\n",
     {"XXE-DEFAULT-PARSER"}),   # hardened in-window -> LOW for review
    ("a.cs", "settings.DtdProcessing = DtdProcessing.Prohibit;\n",
     set()),
    ("a.java", "factory.setExpandEntityReferences(false);\n", set()),
    # --- default-resolving parsers (HIGH) ---
    ("a.java", "DocumentBuilderFactory f = "
               "DocumentBuilderFactory.newInstance();\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.java", "SAXParserFactory f = SAXParserFactory.newInstance();\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.java", "XMLInputFactory x = XMLInputFactory.newInstance();\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.java", "XMLReader r = XMLReaderFactory.createXMLReader();\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.java", "SAXReader r = new SAXReader();\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.java", "SAXBuilder b = new SAXBuilder();\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.java", "Digester d = new Digester();\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.java", "Document d = DocumentHelper.parseText(body);\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.java", "DocumentBuilderFactory.newInstance()\n"
               "    .newDocumentBuilder().parse(is);\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.cs", "XmlTextReader r = new XmlTextReader(stream);\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.py", "import xml.sax\nxml.sax.parse(req.body, H())\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.py", "import xml.sax\nxml.sax.parseString(data, H())\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.py", "from xml.sax import make_parser\n"
             "p = make_parser()\n", {"XXE-DEFAULT-PARSER"}),
    ("a.py", "from lxml import etree\ndoc = etree.parse('in.xml')\n",
     {"XXE-DEFAULT-PARSER"}),
    ("a.py", "from lxml import etree\n"
             "doc = etree.fromstring(payload)\n", {"XXE-DEFAULT-PARSER"}),
    ("a.py", "from lxml import etree\n"
             "p = etree.XMLParser()\n", {"XXE-DEFAULT-PARSER"}),
    # hardening window -> LOW
    ("a.java", "DocumentBuilderFactory f = "
               "DocumentBuilderFactory.newInstance();\n"
               "f.setFeature(\"http://apache.org/xml/features/"
               "disallow-doctype-decl\", true);\n", {"XXE-DEFAULT-PARSER"}),
    ("a.py", "from lxml import etree\n"
             "p = etree.XMLParser(resolve_entities=False, "
             "no_network=True)\n"
             "doc = etree.parse('in.xml', p)\n", {"XXE-DEFAULT-PARSER"}),
    ("a.java", "XMLInputFactory x = XMLInputFactory.newInstance();\n"
               "x.setProperty(XMLInputFactory.SUPPORT_DTD, false);\n",
     {"XXE-DEFAULT-PARSER"}),
    # file-level hardening -> LOW
    ("a.py", "import xml.sax\n"
             "parser = xml.sax.make_parser()\n"
             "parser.setFeature(\n"
             "    xml.sax.handler.feature_external_ges, False)\n"
             "xml.sax.parse('f.xml', H())\n", {"XXE-DEFAULT-PARSER"}),
    ("a.py", "from defusedxml.ElementTree import parse\n"
             "doc = parse('in.xml')\n", set()),
    ("a.py", "import defusedxml.minidom\n"
             "from xml.dom import minidom\n"
             "minidom.parseString(s)\n", set()),
    ("a.java", "// XMLConstants.ACCESS_EXTERNAL_DTD locked in init\n"
               "DocumentBuilderFactory f = "
               "DocumentBuilderFactory.newInstance();\n",
     {"XXE-DEFAULT-PARSER"}),   # comment alone isn't hardening — line 2
    # --- internal-expansion parsers (MEDIUM) ---
    ("a.py", "import xml.etree.ElementTree as ET\n"
             "t = ET.parse('in.xml')\n", {"XXE-DTD-ENABLED"}),
    ("a.py", "import xml.etree.ElementTree as etree\n"
             "etree.fromstring(body)\n", {"XXE-DTD-ENABLED"}),
    ("a.py", "from xml.etree.ElementTree import ElementTree\n"
             "ElementTree().parse('f')\n", {"XXE-DTD-ENABLED"}),
    ("a.py", "from xml.dom import minidom\n"
             "d = minidom.parseString(data)\n", {"XXE-DTD-ENABLED"}),
    ("a.py", "from xml.dom import pulldom\n"
             "ev = pulldom.parse(stream)\n", {"XXE-DTD-ENABLED"}),
    ("a.py", "import xml.parsers.expat\n"
             "p = expat.ParserCreate()\n", {"XXE-DTD-ENABLED"}),
    ("a.php", "$d = new DOMDocument(); $d->loadXML($body);\n",
     {"XXE-DTD-ENABLED"}),
    ("a.php", "$x = simplexml_load_string($body);\n",
     {"XXE-DTD-ENABLED"}),
    ("a.php", "$p = xml_parser_create(); xml_parse($p, $data);\n",
     {"XXE-DTD-ENABLED"}),
    ("a.rb", "doc = REXML::Document.new(body)\n", {"XXE-DTD-ENABLED"}),
    ("a.rb", "doc = Nokogiri::XML(body)\n", {"XXE-DTD-ENABLED"}),
    ("a.cs", "XmlDocument d = new XmlDocument();\n",
     {"XXE-DTD-ENABLED"}),
    ("a.cs", "d.LoadXml(payload);\n", {"XXE-DTD-ENABLED"}),
    ("a.cs", "var r = XmlReader.Create(stream);\n",
     {"XXE-DTD-ENABLED"}),
    ("a.java", "u.unmarshal(new StreamSource(is));\n",
     {"XXE-DTD-ENABLED"}),
    # C# hardening downgrades
    ("a.cs", "XmlDocument d = new XmlDocument();\n"
             "d.XmlResolver = null;\n", {"XXE-DTD-ENABLED"}),
    # --- external-resource access (MEDIUM) ---
    ("a.java", "TransformerFactory tf = TransformerFactory.newInstance();\n",
     {"XXE-EXT-ACCESS"}),
    ("a.java", "SchemaFactory sf = SchemaFactory.newInstance(W3C);\n",
     {"XXE-EXT-ACCESS"}),
    ("a.java", "XPathFactory xp = XPathFactory.newInstance();\n",
     {"XXE-EXT-ACCESS"}),
    ("a.java", "TransformerFactory tf = TransformerFactory.newInstance();\n"
               "tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "
               "\"\");\n", {"XXE-EXT-ACCESS"}),   # hardened -> LOW
    # --- comments / clean / gates ---
    ("a.py", "import xml.sax\n# xml.sax.parse(x, H())\n", set()),
    ("a.java", "// DocumentBuilderFactory.newInstance();\n", set()),
    ("a.java", "/* SAXParserFactory.newInstance(); */\n", set()),
    ("a.py", "import xml.etree.ElementTree as ET\n", set()),
    ("a.js", "const doc = new DOMParser().parseFromString(s, 'text/xml');\n",
     set()),
    ("a.py", "tree.parse('f.xml')\n", set()),   # no xml import marker
    ("a.java", "DocumentBuilderFactory dbf;\n", set()),
    # test path downgrades one tier
    ("test/a.java", "SAXParserFactory.newInstance();\n",
     {"XXE-DEFAULT-PARSER"}),
    # multi-line: hardening several lines away still counts
    ("a.java", "DocumentBuilderFactory f =\n"
               "    DocumentBuilderFactory.newInstance();\n"
               "f.setNamespaceAware(true);\n"
               "f.setFeature(\"http://apache.org/xml/features/"
               "disallow-doctype-decl\", true);\n",
     {"XXE-DEFAULT-PARSER"}),
]


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())
