Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ class SensitiveLoggingPlugin(SymbolCallDetector):
message_template: ClassVar[str] = "call to {symbol} may log sensitive data"
```

The shipped `dangerous-eval` and `weak-crypto` plugins are of this kind.
The shipped `dangerous-eval` and `weak-crypto` plugins are of this kind. `accepts(call, symbol, defs)`
keeps only some calls; `describe(ctx, call, symbol, defs)` returns the `CallReport` (message and
confidence) of one call, the rule's `message_template` and `confidence` by default, for a rule that
tells its calls apart, as `unsafe-xml` does.

### A detector of taint flows: `TaintDetector`

Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ reads no template.
| `debug-enabled` | `app.run(debug=True)` on a Flask application. |
| `missing-timeout` | A Requests or httpx call without a `timeout`. |
| `insecure-tls` | `verify=False` on a Requests or httpx call, or `ssl._create_unverified_context()`. |
| `unsafe-xml` | A standard-library or lxml XML parser, which expands entities; `defusedxml` is the safe replacement. |
| `unsafe-xml` | An XML parser of the standard library or of lxml, reported for what it risks; `defusedxml` is the safe replacement. The standard library's parsers run on Expat, which reads no local file but expands the entities a document declares: a crafted document can exhaust memory with Expat older than 2.7.2, which depends on the Python build at run time (`pyexpat.EXPAT_VERSION`). lxml resolves external entities by default before 5.0, and in `iterparse` and `ETCompatXMLParser` before 6.1 (CVE-2026-41066), which lets a crafted document read local files; `resolve_entities=True` does so whatever the version, and `'internal'` keeps external entities out only from lxml 5.0 on, an earlier lxml reading the string as true; a parser given `resolve_entities=False` is not reported. XML content the project wrote itself, a string constant or a module-level string bound once passed to a function that parses its argument as the document (`fromstring`, `parseString`, `XML`), is reported at low confidence; a constant path keeps its confidence, since whoever can write the file chooses the document. |
| `unsafe-archive-extraction` | `tarfile` or `zipfile` extraction, or `shutil.unpack_archive`, without a member `filter`. |
| `insecure-temp-file` | `tempfile.mktemp`, which names a file without creating it. |
| `weak-random` | A `random` function whose result is assigned to a credential-like name, or produced by a function named like one. Medium confidence. |
Expand Down
2 changes: 1 addition & 1 deletion src/coretrace_python/bundled/syntax/unsafe_xml/plugin.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "unsafe-xml"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["interprocedural.callgraph", "ir.ssa"]
requires = ["interprocedural.callgraph", "ir.ssa", "abstract.module_strings"]
provides = ["vulnerability.unsafe-xml"]

[entrypoint]
Expand Down
104 changes: 92 additions & 12 deletions src/coretrace_python/bundled/syntax/unsafe_xml/unsafe_xml.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,115 @@
"""XML parsers of the standard library and lxml expand entities: a crafted document reads
local files or exhausts memory. ``defusedxml`` offers the same functions safely."""
"""XML parsers of the standard library and of lxml, each reported for what it risks.

The standard library's parsers run on Expat, which never reads a local file or opens a
connection, but expands the entities a document declares: a crafted document can exhaust
memory with Expat older than 2.7.2, a fact of run time (``pyexpat.EXPAT_VERSION``). lxml
resolves external entities by default before 5.0, and in ``iterparse`` and
``ETCompatXMLParser`` before 6.1 (CVE-2026-41066), which lets a crafted document read
local files; ``resolve_entities=True`` does so whatever the version, ``False`` never.
``'internal'`` keeps external entities out only from lxml 5.0 on: an earlier lxml reads
the string as true. ``defusedxml`` offers the same functions safely. XML content the
project wrote itself, a string constant or a module-level string bound once given to a
function that parses its argument as the document, is no attacker's: its finding is kept
at low confidence. A path is not the document: whoever can write the file chooses it."""

from __future__ import annotations

from collections.abc import Mapping
from typing import ClassVar

from coretrace_python.abstract.strings import ModuleStringsAnalysis
from coretrace_python.analysis import AnyAnalysis
from coretrace_python.findings import Severity
from coretrace_python.findings import Confidence, Severity
from coretrace_python.interprocedural import CallGraphAnalysis
from coretrace_python.ir.model import Call, Constant, Global, Instruction, Value
from coretrace_python.ir.ssa import SSAAnalysis
from coretrace_python.plugins import SymbolCallDetector
from coretrace_python.plugins import CallReport, PluginContext, SymbolCallDetector
from coretrace_python.semantic.symbols import SymbolId

_PARSERS = (

def _symbols(*paths: str) -> frozenset[SymbolId]:
return frozenset(SymbolId(f"python.{path}") for path in paths)


_STANDARD_LIBRARY = _symbols(
"xml.etree.ElementTree.parse", "xml.etree.ElementTree.fromstring", "xml.etree.ElementTree.iterparse",
"xml.etree.ElementTree.XMLParser", "xml.etree.ElementTree.fromstringlist",
"xml.dom.minidom.parse", "xml.dom.minidom.parseString",
"xml.dom.pulldom.parse", "xml.dom.pulldom.parseString",
"xml.sax.parse", "xml.sax.parseString", "xml.sax.make_parser",
"xml.dom.expatbuilder.parse", "xml.dom.expatbuilder.parseString",
"lxml.etree.parse", "lxml.etree.fromstring", "lxml.etree.XMLParser", "lxml.etree.iterparse",
"lxml.etree.XML",
)
_LXML = _symbols("lxml.etree.parse", "lxml.etree.fromstring", "lxml.etree.XMLParser", "lxml.etree.XML")
# Their default kept ``resolve_entities=True`` until lxml 6.1 (CVE-2026-41066).
_LXML_UNTIL_6_1 = _symbols("lxml.etree.iterparse", "lxml.etree.ETCompatXMLParser")
# The functions whose first argument is the XML itself, not a file, a URL or a stream.
_CONTENT = _symbols(
"xml.etree.ElementTree.fromstring", "xml.etree.ElementTree.fromstringlist",
"xml.dom.minidom.parseString", "xml.dom.pulldom.parseString", "xml.sax.parseString",
"xml.dom.expatbuilder.parseString", "lxml.etree.fromstring", "lxml.etree.XML",
)
# A keyword that is not a constant: its value is unknown.
_UNKNOWN = object()


def _resolve_entities(call: Call, defs: Mapping[Value, Instruction]) -> object:
"""The constant ``resolve_entities=`` of ``call``, ``None`` when it is not passed."""

for name, value in call.keywords:
if name == "resolve_entities":
made = defs.get(value)
return made.value if isinstance(made, Constant) else _UNKNOWN
return None


class UnsafeXmlPlugin(SymbolCallDetector):
name: ClassVar[str] = "unsafe-xml"
rule_id: ClassVar[str] = "unsafe-xml"
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({SSAAnalysis, CallGraphAnalysis})
symbols: ClassVar[frozenset[SymbolId]] = frozenset(SymbolId(f"python.{p}") for p in _PARSERS)
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({SSAAnalysis, CallGraphAnalysis, ModuleStringsAnalysis})
symbols: ClassVar[frozenset[SymbolId]] = _STANDARD_LIBRARY | _LXML | _LXML_UNTIL_6_1
severity: ClassVar[Severity] = Severity.MEDIUM
message_template: ClassVar[str] = (
"call to {symbol} parses XML with entity expansion enabled; use defusedxml"
)
message_template: ClassVar[str] = "call to {symbol} parses XML unsafely; use defusedxml"

def accepts(self, call: Call, symbol: SymbolId, defs: Mapping[Value, Instruction]) -> bool:
# ``resolve_entities`` is an lxml option; the standard library has none.
return _resolve_entities(call, defs) is not False

def describe(
self, ctx: PluginContext, call: Call, symbol: SymbolId, defs: Mapping[Value, Instruction]
) -> CallReport:
if symbol in _STANDARD_LIBRARY:
risk = "expands the entities a document declares, which can exhaust memory with Expat older than 2.7.2"
advice = "use defusedxml"
elif _resolve_entities(call, defs) is True:
risk = "resolves external entities (resolve_entities=True), which lets a crafted document read local files"
advice = "use defusedxml"
elif _resolve_entities(call, defs) == "internal":
risk = (
"sets resolve_entities='internal', which keeps external entities out only from lxml 5.0 on: "
"before lxml 5.0 the string reads as true and external entities are resolved, which lets a "
"crafted document read local files"
)
advice = "use defusedxml or resolve_entities=False"
elif symbol in _LXML_UNTIL_6_1:
risk = (
"resolves external entities by default before lxml 6.1 (CVE-2026-41066), "
"which lets a crafted document read local files"
)
advice = "use defusedxml or resolve_entities=False"
else:
risk = "resolves external entities by default before lxml 5.0, which lets a crafted document read local files"
advice = "use defusedxml"
written = symbol in _CONTENT and self.written_by_the_project(ctx, call, defs)
confidence = Confidence.LOW if written else self.confidence
return CallReport(f"call to {symbol} {risk}; {advice}", confidence)

def written_by_the_project(self, ctx: PluginContext, call: Call, defs: Mapping[Value, Instruction]) -> bool:
"""Whether the first argument of ``call``, which a ``_CONTENT`` function parses as
the document, is a string constant or a module-level name bound once to one."""

if not call.arguments:
return False
made = defs.get(call.arguments[0])
if isinstance(made, Constant):
return isinstance(made.value, str | bytes)
return isinstance(made, Global) and made.name in ctx.get(ModuleStringsAnalysis)
8 changes: 7 additions & 1 deletion src/coretrace_python/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
apply_refinement,
run_plugins,
)
from coretrace_python.plugins.detectors import Assessment, SymbolCallDetector, TaintDetector
from coretrace_python.plugins.detectors import (
Assessment,
CallReport,
SymbolCallDetector,
TaintDetector,
)
from coretrace_python.plugins.loader import (
IncompatiblePluginError,
LoadedPlugin,
Expand All @@ -36,6 +41,7 @@
__all__ = [
"PLUGIN_API_VERSION",
"Assessment",
"CallReport",
"Entrypoint",
"IncompatiblePluginError",
"LoadedPlugin",
Expand Down
24 changes: 21 additions & 3 deletions src/coretrace_python/plugins/detectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
detector is a few class attributes on top of it, and a rule that knows more about its
sinks refines the verdict (``judge``) or the severity too (``assess``). ``SymbolCallDetector`` reports calls
whose callee resolves to a canonical symbol, through imports and local aliases alike,
by reading the SSA form.
by reading the SSA form; a rule that tells its calls apart words each finding itself
(``describe``).
"""

from __future__ import annotations
Expand Down Expand Up @@ -37,6 +38,14 @@ class Assessment:
metadata: Mapping[str, str] = field(default_factory=dict)


@dataclass(frozen=True)
class CallReport:
"""What a ``SymbolCallDetector`` reports of one call: its message and confidence."""

message: str
confidence: Confidence


class TaintDetector(Plugin):
"""Report every taint flow carrying ``kind`` into a sink."""

Expand Down Expand Up @@ -128,6 +137,14 @@ class SymbolCallDetector(Plugin):
def accepts(self, call: Call, symbol: SymbolId, defs: Mapping[Value, Instruction]) -> bool:
return True

def describe(
self, ctx: PluginContext, call: Call, symbol: SymbolId, defs: Mapping[Value, Instruction]
) -> CallReport:
"""The message and confidence of the finding for ``call``: the rule's own, unless
the rule tells its calls apart."""

return CallReport(self.message_template.format(symbol=symbol), self.confidence)

def analyze(self, ctx: PluginContext) -> Sequence[Finding]:
findings: list[Finding] = []
graph = ctx.get(CallGraphAnalysis) if CallGraphAnalysis in self.requires else None
Expand All @@ -150,12 +167,13 @@ def analyze(self, ctx: PluginContext) -> Sequence[Finding]:
continue
if not self.accepts(instruction, symbol, defs):
continue
described = self.describe(ctx, instruction, symbol, defs)
findings.append(
Finding(
rule_id=self.rule_id,
message=self.message_template.format(symbol=symbol),
message=described.message,
severity=self.severity,
confidence=self.confidence,
confidence=described.confidence,
span=instruction.location,
function=function.name,
metadata={"symbol": str(symbol)},
Expand Down
120 changes: 120 additions & 0 deletions tests/test_unsafe_xml_risk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Acceptance tests for issue #242: ``unsafe-xml`` says what each parser family risks.

The standard library's parsers run on Expat, which never reads a local file; they expand
the entities a document declares, which can exhaust memory with Expat older than 2.7.2.
lxml resolves external entities by default before 5.0, and for ``iterparse`` and
``ETCompatXMLParser`` before 6.1 (CVE-2026-41066); ``resolve_entities=True`` resolves them
whatever the version, ``False`` never. ``'internal'`` keeps them out only from lxml 5.0 on:
an earlier lxml reads the string as true. XML content the project wrote itself is no
attacker's: it is reported at low confidence; a constant path is not the document it
names, which anyone able to write the file chooses.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from coretrace_python import engine
from coretrace_python.findings import Confidence, Finding, Severity
from coretrace_python.source import SourceManager

PLUGINS = Path(__file__).resolve().parent.parent / "src" / "coretrace_python" / "bundled"


def check(text: str) -> tuple[Finding, ...]:
findings = engine.check(SourceManager().add_source("m.py", text), [PLUGINS])
return tuple(f for f in findings if f.rule_id == "unsafe-xml")


def only(text: str) -> Finding:
(finding,) = check(text)
assert finding.severity is Severity.MEDIUM
return finding


@pytest.mark.parametrize(
"call",
[
"xml.etree.ElementTree.fromstring(data)",
"xml.dom.minidom.parseString(data)",
"xml.sax.parseString(data, None)",
],
)
def test_a_standard_library_parser_is_reported_for_entity_amplification(call: str) -> None:
finding = only(f"import xml.etree.ElementTree\nimport xml.dom.minidom\nimport xml.sax\n\ndef f(data):\n {call}\n")

assert "Expat older than 2.7.2" in finding.message
assert "local file" not in finding.message
assert finding.confidence is Confidence.HIGH


@pytest.mark.parametrize("function", ["fromstring", "parse", "XML", "XMLParser"])
def test_an_lxml_parser_with_its_defaults_is_reported_for_versions_before_5(function: str) -> None:
finding = only(f"import lxml.etree\n\ndef f(data):\n lxml.etree.{function}(data)\n")

assert "before lxml 5.0" in finding.message
assert "local files" in finding.message


@pytest.mark.parametrize("function", ["iterparse", "ETCompatXMLParser"])
def test_iterparse_and_the_compatibility_parser_are_reported_for_versions_before_6_1(function: str) -> None:
finding = only(f"import lxml.etree\n\ndef f(data):\n lxml.etree.{function}(data)\n")

assert "before lxml 6.1" in finding.message
assert "CVE-2026-41066" in finding.message


def test_resolving_entities_explicitly_is_reported_whatever_the_version() -> None:
finding = only("import lxml.etree\n\ndef f(data):\n lxml.etree.XMLParser(resolve_entities=True)\n")

assert "resolve_entities=True" in finding.message
assert "before lxml" not in finding.message


def test_a_parser_that_resolves_no_external_entity_is_not_reported() -> None:
assert check("import lxml.etree\n\ndef f(data):\n lxml.etree.XMLParser(resolve_entities=False)\n") == ()
assert check("import lxml.etree\n\ndef f(data):\n lxml.etree.iterparse(data, resolve_entities=False)\n") == ()


@pytest.mark.parametrize("function", ["XMLParser", "iterparse"])
def test_internal_entities_only_are_reported_for_versions_before_5(function: str) -> None:
finding = only(f"import lxml.etree\n\ndef f(data):\n lxml.etree.{function}(data, resolve_entities='internal')\n")

assert "resolve_entities='internal'" in finding.message
assert "before lxml 5.0" in finding.message


@pytest.mark.parametrize(
"text",
[
"from xml.dom import minidom\n\ndef f():\n minidom.parseString('<report/>')\n",
"from xml.dom import minidom\nEXPECTED = '<report/>'\n\ndef f():\n minidom.parseString(EXPECTED)\n",
"import lxml.etree\n\ndef f():\n lxml.etree.fromstring('<report/>')\n",
],
)
def test_a_document_the_project_wrote_is_reported_at_low_confidence(text: str) -> None:
assert only(text).confidence is Confidence.LOW


@pytest.mark.parametrize(
"call",
[
"lxml.etree.parse('/tmp/upload.xml')",
"lxml.etree.iterparse('/tmp/upload.xml')",
"xml.etree.ElementTree.parse('/tmp/upload.xml')",
"xml.dom.minidom.parse('/tmp/upload.xml')",
"xml.sax.parse('/tmp/upload.xml', None)",
],
)
def test_a_constant_path_keeps_high_confidence(call: str) -> None:
text = f"import lxml.etree\nimport xml.dom.minidom\nimport xml.etree.ElementTree\nimport xml.sax\n\ndef f():\n {call}\n"

assert only(text).confidence is Confidence.HIGH


def test_a_document_of_unknown_origin_keeps_high_confidence() -> None:
text = "from xml.dom import minidom\nEXPECTED = '<report/>'\nEXPECTED = load()\n\ndef f():\n minidom.parseString(EXPECTED)\n"

assert only(text).confidence is Confidence.HIGH
Loading