purexml 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- purexml/ElementTree.py +52 -0
- purexml/__init__.py +98 -0
- purexml/__main__.py +78 -0
- purexml/_expat_security.py +393 -0
- purexml/_parser.py +382 -0
- purexml/common.py +41 -0
- purexml/errors.py +123 -0
- purexml/expatreader.py +110 -0
- purexml/limits.py +81 -0
- purexml/minidom.py +176 -0
- purexml/py.typed +0 -0
- purexml/sax.py +69 -0
- purexml/xmlrpc.py +183 -0
- purexml-1.0.0.dist-info/METADATA +158 -0
- purexml-1.0.0.dist-info/RECORD +18 -0
- purexml-1.0.0.dist-info/WHEEL +5 -0
- purexml-1.0.0.dist-info/licenses/LICENSE +21 -0
- purexml-1.0.0.dist-info/top_level.txt +1 -0
purexml/ElementTree.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""purexml.ElementTree — canonical namespace mirroring ``defusedxml.ElementTree``.
|
|
2
|
+
|
|
3
|
+
The migration off defusedxml is a literal ``s/defusedxml/purexml/`` for the
|
|
4
|
+
implemented surface::
|
|
5
|
+
|
|
6
|
+
# was: from defusedxml.ElementTree import fromstring, parse, XML, XMLParser
|
|
7
|
+
from purexml.ElementTree import fromstring, parse, XML, XMLParser
|
|
8
|
+
|
|
9
|
+
As of v0.3 this covers defusedxml.ElementTree's full surface: ``fromstring``,
|
|
10
|
+
``parse``, ``iterparse``, ``XML``, ``XMLParser`` (+ ``XMLParse``/``XMLTreeBuilder``
|
|
11
|
+
aliases), ``tostring``, ``ParseError``.
|
|
12
|
+
|
|
13
|
+
``ParseError`` and ``tostring`` are re-exported from the stdlib exactly as
|
|
14
|
+
defusedxml does. ``Element`` is intentionally NOT re-exported (defusedxml doesn't
|
|
15
|
+
either); import it from ``xml.etree.ElementTree`` if needed. ``fromstringlist`` is
|
|
16
|
+
a stdlib-parity extra beyond defusedxml's surface.
|
|
17
|
+
"""
|
|
18
|
+
from xml.etree.ElementTree import ParseError, tostring
|
|
19
|
+
|
|
20
|
+
from ._parser import XMLParser, fromstring, fromstringlist, iterparse, parse
|
|
21
|
+
from .errors import (
|
|
22
|
+
DTDForbidden,
|
|
23
|
+
EntitiesForbidden,
|
|
24
|
+
ExternalReferenceForbidden,
|
|
25
|
+
PureXMLError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
#: ``XML`` is defusedxml's/stdlib's alias for ``fromstring``.
|
|
29
|
+
XML = fromstring
|
|
30
|
+
|
|
31
|
+
#: defusedxml back-compat aliases for the parser class.
|
|
32
|
+
XMLParse = XMLTreeBuilder = XMLParser
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# mirrors defusedxml.ElementTree's __all__ (for the implemented surface)
|
|
36
|
+
"ParseError",
|
|
37
|
+
"XML",
|
|
38
|
+
"XMLParse",
|
|
39
|
+
"XMLParser",
|
|
40
|
+
"XMLTreeBuilder",
|
|
41
|
+
"fromstring",
|
|
42
|
+
"parse",
|
|
43
|
+
"iterparse",
|
|
44
|
+
"tostring",
|
|
45
|
+
# stdlib-parity extra (not in defusedxml)
|
|
46
|
+
"fromstringlist",
|
|
47
|
+
# purexml exception hierarchy
|
|
48
|
+
"PureXMLError",
|
|
49
|
+
"DTDForbidden",
|
|
50
|
+
"EntitiesForbidden",
|
|
51
|
+
"ExternalReferenceForbidden",
|
|
52
|
+
]
|
purexml/__init__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""purexml — safely parse untrusted XML using only the Python standard library.
|
|
2
|
+
|
|
3
|
+
A maintained, zero-dependency, stdlib-only replacement for ``defusedxml``: returns
|
|
4
|
+
standard ``xml.etree`` objects while blocking the known XML attack classes
|
|
5
|
+
(entity-expansion bombs, XXE, external reference resolution), behaviorally
|
|
6
|
+
equivalent to defusedxml's defaults.
|
|
7
|
+
|
|
8
|
+
The canonical namespace mirrors ``defusedxml.ElementTree`` (migration is a literal
|
|
9
|
+
``s/defusedxml/purexml/``)::
|
|
10
|
+
|
|
11
|
+
from purexml.ElementTree import fromstring, parse, XML, XMLParser
|
|
12
|
+
|
|
13
|
+
The common entry points are also re-exported at the top level for convenience::
|
|
14
|
+
|
|
15
|
+
from purexml import fromstring
|
|
16
|
+
root = fromstring("<r><a>x</a></r>") # raises on bomb / XXE / malformed
|
|
17
|
+
"""
|
|
18
|
+
from xml.etree.ElementTree import ParseError, tostring
|
|
19
|
+
|
|
20
|
+
from . import ElementTree, common, expatreader, minidom, sax, xmlrpc
|
|
21
|
+
from ._expat_security import (
|
|
22
|
+
BLOCKED,
|
|
23
|
+
EXPAT_MITIGATED,
|
|
24
|
+
EXPAT_PARTIAL,
|
|
25
|
+
EXPAT_VERSION,
|
|
26
|
+
LIVE,
|
|
27
|
+
OPT_IN,
|
|
28
|
+
RECOMMENDED_EXPAT_VERSION,
|
|
29
|
+
SAFE_EXPAT_VERSION,
|
|
30
|
+
SecurityReport,
|
|
31
|
+
assert_expat_secure,
|
|
32
|
+
expat_is_secure,
|
|
33
|
+
security_report,
|
|
34
|
+
)
|
|
35
|
+
from ._parser import XMLParser, fromstring, fromstringlist, iterparse, parse
|
|
36
|
+
from .errors import (
|
|
37
|
+
AttributesExceeded,
|
|
38
|
+
DepthExceeded,
|
|
39
|
+
DTDForbidden,
|
|
40
|
+
EntitiesForbidden,
|
|
41
|
+
ExternalReferenceForbidden,
|
|
42
|
+
LimitExceeded,
|
|
43
|
+
NotSupportedError,
|
|
44
|
+
PureXMLError,
|
|
45
|
+
SizeExceeded,
|
|
46
|
+
)
|
|
47
|
+
from .limits import RECOMMENDED_LIMITS, Limits
|
|
48
|
+
|
|
49
|
+
XML = fromstring
|
|
50
|
+
|
|
51
|
+
__version__ = "1.0.0"
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
# the ElementTree family (also at purexml.ElementTree)
|
|
55
|
+
"ElementTree",
|
|
56
|
+
"fromstring",
|
|
57
|
+
"parse",
|
|
58
|
+
"iterparse",
|
|
59
|
+
"fromstringlist",
|
|
60
|
+
"XML",
|
|
61
|
+
"XMLParser",
|
|
62
|
+
"tostring",
|
|
63
|
+
"ParseError",
|
|
64
|
+
# other defusedxml-surface modules — import-compatible submodules
|
|
65
|
+
"minidom", # v0.10
|
|
66
|
+
"common", # v0.10
|
|
67
|
+
"sax", # v0.12
|
|
68
|
+
"expatreader", # v0.12 (sax engine)
|
|
69
|
+
"xmlrpc", # v0.13 (monkeypatch shim — lazy)
|
|
70
|
+
# exception hierarchy
|
|
71
|
+
"PureXMLError",
|
|
72
|
+
"DTDForbidden",
|
|
73
|
+
"EntitiesForbidden",
|
|
74
|
+
"ExternalReferenceForbidden",
|
|
75
|
+
"NotSupportedError",
|
|
76
|
+
# opt-in structural-DoS limits (v0.4 mirror-plus)
|
|
77
|
+
"Limits",
|
|
78
|
+
"RECOMMENDED_LIMITS",
|
|
79
|
+
"LimitExceeded",
|
|
80
|
+
"DepthExceeded",
|
|
81
|
+
"AttributesExceeded",
|
|
82
|
+
"SizeExceeded",
|
|
83
|
+
# libexpat version awareness (v0.1.2) — opt-in
|
|
84
|
+
"EXPAT_VERSION",
|
|
85
|
+
"SAFE_EXPAT_VERSION",
|
|
86
|
+
"RECOMMENDED_EXPAT_VERSION",
|
|
87
|
+
"expat_is_secure",
|
|
88
|
+
"assert_expat_secure",
|
|
89
|
+
# security-posture report (v0.5 trust surface) — read-only introspection
|
|
90
|
+
"security_report",
|
|
91
|
+
"SecurityReport",
|
|
92
|
+
"BLOCKED",
|
|
93
|
+
"EXPAT_MITIGATED",
|
|
94
|
+
"EXPAT_PARTIAL",
|
|
95
|
+
"OPT_IN",
|
|
96
|
+
"LIVE",
|
|
97
|
+
"__version__",
|
|
98
|
+
]
|
purexml/__main__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""CLI: ``python -m purexml`` — report this runtime's XML-security posture.
|
|
2
|
+
|
|
3
|
+
Read-only introspection over `purexml.security_report()`. **Informs by default
|
|
4
|
+
(exit 0)**; ``--check`` is the *caller* opting into a CI gate (the inform-by-default
|
|
5
|
+
version-assertion stance). This module is the package's one **I/O boundary** — it
|
|
6
|
+
prints, and imports ``argparse``/``json``/``sys`` on top of purexml — but nothing
|
|
7
|
+
that reaches the network / filesystem / subprocess. `tests/test_no_io` enforces both
|
|
8
|
+
halves: the parse surface stays strict stdlib-`xml`, and this file may add only those
|
|
9
|
+
CLI-output modules, never a forbidden one.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from . import __version__, security_report
|
|
18
|
+
from . import _expat_security as _es
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ver(t: tuple[int, ...]) -> str:
|
|
22
|
+
return ".".join(map(str, t))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
p = argparse.ArgumentParser(
|
|
27
|
+
prog="python -m purexml",
|
|
28
|
+
description="Report this runtime's XML-security posture (purexml).")
|
|
29
|
+
p.add_argument("--json", action="store_true",
|
|
30
|
+
help="emit the posture as JSON (machine-readable; PROVISIONAL shape)")
|
|
31
|
+
p.add_argument("--check", action="store_true",
|
|
32
|
+
help="exit non-zero if libexpat is below the floor (opt-in CI gate)")
|
|
33
|
+
p.add_argument("--min-expat", metavar="X.Y.Z", default=None,
|
|
34
|
+
help="floor for --check (default: the recommended-latest floor)")
|
|
35
|
+
p.add_argument("--version", action="store_true",
|
|
36
|
+
help="print purexml and libexpat versions, then exit")
|
|
37
|
+
return p
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv: list[str] | None = None) -> int:
|
|
41
|
+
parser = _build_parser()
|
|
42
|
+
args = parser.parse_args(argv)
|
|
43
|
+
|
|
44
|
+
# --min-expat is meaningless without --check; silently ignoring it would let a
|
|
45
|
+
# user who meant to gate (`--min-expat X`) get a silent exit-0 pass — a security
|
|
46
|
+
# footgun in CI. Fail loudly instead (PR#15 Gemini).
|
|
47
|
+
if args.min_expat is not None and not args.check:
|
|
48
|
+
parser.error("--min-expat requires --check")
|
|
49
|
+
|
|
50
|
+
if args.version:
|
|
51
|
+
print("purexml %s (libexpat %s)" % (__version__, _ver(_es.EXPAT_VERSION)))
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
report = security_report()
|
|
55
|
+
if args.json:
|
|
56
|
+
print(json.dumps({"purexml_version": __version__, **report.as_dict()}))
|
|
57
|
+
else:
|
|
58
|
+
print(report)
|
|
59
|
+
|
|
60
|
+
if args.check:
|
|
61
|
+
if args.min_expat is not None:
|
|
62
|
+
try:
|
|
63
|
+
floor = _es._as_version_tuple(args.min_expat)
|
|
64
|
+
except (ValueError, TypeError):
|
|
65
|
+
parser.error("--min-expat must be a version like 2.8.1, got %r"
|
|
66
|
+
% args.min_expat)
|
|
67
|
+
else:
|
|
68
|
+
floor = _es.RECOMMENDED_EXPAT_VERSION
|
|
69
|
+
if _es.EXPAT_VERSION < floor:
|
|
70
|
+
print("FAIL: libexpat %s is below the required floor %s"
|
|
71
|
+
% (_ver(_es.EXPAT_VERSION), _ver(floor)), file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
sys.exit(main())
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""libexpat version awareness — purexml's value-add over (abandoned) defusedxml.
|
|
2
|
+
|
|
3
|
+
Resource-bound protection against XML DoS — billion-laughs, quadratic blowup,
|
|
4
|
+
large-tokens (CVE-2023-52425), disproportionate dynamic memory — lives in
|
|
5
|
+
**libexpat**, not the Python wrapper, so it depends entirely on the bundled/system
|
|
6
|
+
expat version. defusedxml (frozen 2021) never checked this; purexml exposes it so
|
|
7
|
+
a consumer can verify its runtime is actually safe.
|
|
8
|
+
|
|
9
|
+
Floor is **provisional** (it moves as expat ships fixes: 2.4.0 billion-laughs cap,
|
|
10
|
+
2.6.0 reparse-deferral / large-tokens, 2.7.2, and a 2.7.4–2.8.1 DoS train in 2026 —
|
|
11
|
+
recommended-latest is 2.8.1). The enforce-vs-warn *policy* is a 1.0-freeze decision;
|
|
12
|
+
this module only exposes the data + an explicit opt-in check, with **no import-time
|
|
13
|
+
behavior change.**
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import xml.parsers.expat as _expat
|
|
18
|
+
from collections import namedtuple
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from types import MappingProxyType
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .limits import RECOMMENDED_LIMITS
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"EXPAT_VERSION",
|
|
27
|
+
"SAFE_EXPAT_VERSION",
|
|
28
|
+
"RECOMMENDED_EXPAT_VERSION",
|
|
29
|
+
"expat_is_secure",
|
|
30
|
+
"assert_expat_secure",
|
|
31
|
+
"security_report",
|
|
32
|
+
"SecurityReport",
|
|
33
|
+
"BLOCKED",
|
|
34
|
+
"EXPAT_MITIGATED",
|
|
35
|
+
"EXPAT_PARTIAL",
|
|
36
|
+
"OPT_IN",
|
|
37
|
+
"LIVE",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: Runtime libexpat version as a ``(major, minor, micro)`` tuple.
|
|
41
|
+
EXPAT_VERSION = _expat.version_info
|
|
42
|
+
|
|
43
|
+
#: Functional floor: 2.6.0 covers billion-laughs (since 2.4.0) AND the large-tokens
|
|
44
|
+
#: / CVE-2023-52425 reparse-deferral mitigation (2.6.0). The major DoS classes in our
|
|
45
|
+
#: mitigation map are covered here, but the *disproportionate dynamic memory* class
|
|
46
|
+
#: (fixed 2.7.2) and the newer 2.7.4–2.8.1 DoS fixes stay live below RECOMMENDED. Opt
|
|
47
|
+
#: into this looser bar explicitly. Provisional.
|
|
48
|
+
SAFE_EXPAT_VERSION = (2, 6, 0)
|
|
49
|
+
|
|
50
|
+
#: Conservative "recommended" floor = the **latest stable libexpat** with all known
|
|
51
|
+
#: XML-parsing security fixes. Bumped to 2.8.2 (2026-06-25) after a large 2026 release
|
|
52
|
+
#: train shipped DoS / memory-safety fixes, several reachable through purexml's normal
|
|
53
|
+
#: parse paths: 2.7.4 (CVE-2026-25210, doContent integer overflow), 2.8.0
|
|
54
|
+
#: (CVE-2026-41080, hash-salt entropy), 2.8.1 (CVE-2026-45186, quadratic attribute-name
|
|
55
|
+
#: collision check), and **2.8.2 (a batch of integer-overflow / memory-corruption fixes
|
|
56
|
+
#: in storeAtts / addBinding / getAttributeId / textLen / copyString / doProlog /
|
|
57
|
+
#: XML_ParseBuffer — reachable via ordinary attribute / namespace / text / DOCTYPE
|
|
58
|
+
#: parsing).** (NULL-deref / xmlwf-only / suspend-resume classes purexml does not reach
|
|
59
|
+
#: are not mapped, but the floor stays at latest-stable: fail-safe, and a consumer's
|
|
60
|
+
#: expat is shared.) **The default** for the secure checks below — under-reporting
|
|
61
|
+
#: security is worse than over-reporting. Provisional; the precise floor + enforce-vs-warn
|
|
62
|
+
#: policy is a 1.0-freeze decision. Per-class fix versions drive the mitigation map.
|
|
63
|
+
RECOMMENDED_EXPAT_VERSION = (2, 8, 2)
|
|
64
|
+
|
|
65
|
+
#: Per-class fix versions — each attack class in the mitigation map gates on the
|
|
66
|
+
#: expat release that fixed IT, NOT the moving recommended-latest floor, so the map
|
|
67
|
+
#: stays accurate as RECOMMENDED advances. (See v0.6.0 / v0.9.0 RFCs.)
|
|
68
|
+
_DISPROPORTIONATE_MEMORY_FIXED = (2, 7, 2)
|
|
69
|
+
_CONTENT_TOKEN_OVERFLOW_FIXED = (2, 7, 4) # CVE-2026-25210 (doContent integer overflow)
|
|
70
|
+
_HASH_FLOODING_FIXED = (2, 8, 0) # CVE-2026-41080 (SipHash salt entropy; hardening)
|
|
71
|
+
_ATTRIBUTE_COLLISION_FIXED = (2, 8, 1) # CVE-2026-45186 (quadratic attr-name checks)
|
|
72
|
+
_INTEGER_OVERFLOW_BATCH_FIXED = (2, 8, 2) # the reachable 2.8.2 integer-overflow batch (7 CVEs)
|
|
73
|
+
|
|
74
|
+
# As of v0.11.0 every expat fix REACHABLE through purexml's parse paths is individually tracked
|
|
75
|
+
# again, so there is no generic "untracked-gap" advisory (the v0.10.1 interim `_HIGHEST_UNMAPPED_FIX`
|
|
76
|
+
# was retired when the 2.8.2 integer-overflow batch was grounded + mapped below). Unmapped-and-
|
|
77
|
+
# unreachable: the NULL-deref classes (CVE-2026-24515 @ 2.7.4 / -32776 @ 2.7.5); the reentrant-
|
|
78
|
+
# handler / suspend-resume trio (CVE-2026-50219 / -56131 / -56412 — purexml does one-shot parsing
|
|
79
|
+
# only, never reenters the parser from a handler); and the xmlwf-only trio (CVE-2026-56409 / -56410 /
|
|
80
|
+
# -56411 — the CLI utility, not the library). If a future *reachable* fix lands, add a per-class
|
|
81
|
+
# constant + map entry (the v0.6 / v0.9 / v0.11 pattern).
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _as_version_tuple(v: str | Sequence[int]) -> tuple[int, ...]:
|
|
85
|
+
"""Normalize a version to a tuple of ints. Accepts a ``"x.y.z"`` string or a
|
|
86
|
+
sequence of ints, so callers can pass either form (PR#3 Gemini)."""
|
|
87
|
+
if isinstance(v, str):
|
|
88
|
+
return tuple(int(p) for p in v.split("."))
|
|
89
|
+
return tuple(int(p) for p in v)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def expat_is_secure(minimum: str | Sequence[int] = RECOMMENDED_EXPAT_VERSION) -> bool:
|
|
93
|
+
"""Return ``True`` if the runtime libexpat meets *minimum*.
|
|
94
|
+
|
|
95
|
+
Defaults to the **conservative** floor (`RECOMMENDED_EXPAT_VERSION`, fail-safe).
|
|
96
|
+
Pass `SAFE_EXPAT_VERSION` for the looser functional bar, or any
|
|
97
|
+
``(maj, min, micro)`` tuple / ``"x.y.z"`` string. Opt-in — purexml does not
|
|
98
|
+
enforce this automatically.
|
|
99
|
+
"""
|
|
100
|
+
return EXPAT_VERSION >= _as_version_tuple(minimum)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def assert_expat_secure(minimum: str | Sequence[int] = RECOMMENDED_EXPAT_VERSION) -> None:
|
|
104
|
+
"""Raise ``RuntimeError`` if the runtime libexpat is below *minimum* (default
|
|
105
|
+
the conservative `RECOMMENDED_EXPAT_VERSION`).
|
|
106
|
+
|
|
107
|
+
For consumers that want to fail-closed on an unsafe runtime. Not called
|
|
108
|
+
automatically; the enforce-vs-warn policy is deferred to the 1.0 freeze.
|
|
109
|
+
"""
|
|
110
|
+
floor = _as_version_tuple(minimum)
|
|
111
|
+
if EXPAT_VERSION < floor:
|
|
112
|
+
cur = ".".join(map(str, EXPAT_VERSION))
|
|
113
|
+
need = ".".join(map(str, floor))
|
|
114
|
+
# Keep the message floor-agnostic: do NOT enumerate a fixed class list (it goes
|
|
115
|
+
# stale on every floor bump and would misname the gap — e.g. at 2.8.1 the old list
|
|
116
|
+
# was all-mitigated while the real gap was the 2.8.2 batch; PR#29 Codex). Point at
|
|
117
|
+
# security_report() for the accurate per-runtime, per-class posture instead.
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
"libexpat %s is below the required floor %s — the runtime may be missing "
|
|
120
|
+
"libexpat-layer security fixes (XML DoS / memory-safety) up to that floor. "
|
|
121
|
+
"Call purexml.security_report() for the per-class posture on this runtime, "
|
|
122
|
+
"then upgrade Python or the system expat." % (cur, need)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# -- v0.5 trust surface: the security-posture report ------------------------
|
|
127
|
+
#
|
|
128
|
+
# Per-class mitigation *status* values. Each attack class in a SecurityReport's
|
|
129
|
+
# ``mitigations`` map carries exactly one of these, answering "where, on THIS
|
|
130
|
+
# runtime, is this class handled?" They are plain strings (printable, stable to
|
|
131
|
+
# compare) — an adopter can branch on them without importing an enum type.
|
|
132
|
+
|
|
133
|
+
#: Blocked by purexml's own expat handlers, default-on — independent of libexpat
|
|
134
|
+
#: version (e.g. entity-decl refusal, external-ref refusal).
|
|
135
|
+
BLOCKED = "blocked-by-purexml"
|
|
136
|
+
|
|
137
|
+
#: Mitigated by the libexpat layer on this runtime (the runtime's expat version
|
|
138
|
+
#: is recent enough to cover the class). Not purexml's doing — reported so the
|
|
139
|
+
#: adopter knows the protection rides on the expat version.
|
|
140
|
+
EXPAT_MITIGATED = "mitigated-by-libexpat"
|
|
141
|
+
|
|
142
|
+
#: PARTIALLY mitigated by the libexpat layer: the defense for this class is PRESENT on
|
|
143
|
+
#: this runtime, but a later expat release HARDENS it (e.g. a stronger hash-flood salt).
|
|
144
|
+
#: Distinct from `EXPAT_MITIGATED` (fully hardened) and `LIVE` (no mitigation at all) —
|
|
145
|
+
#: used for a *hardening-not-hole* class where bare `LIVE` would overstate exposure and
|
|
146
|
+
#: bare `EXPAT_MITIGATED` would understate it. (v0.9: CVE-2026-41080 hash-salt entropy.)
|
|
147
|
+
EXPAT_PARTIAL = "partial-by-libexpat (defense present; upgrade hardens it)"
|
|
148
|
+
|
|
149
|
+
#: Covered only if the caller opts in (passes a ``Limits`` to the parse entry
|
|
150
|
+
#: point). Default-off — the strict defusedxml mirror does not bound this.
|
|
151
|
+
OPT_IN = "opt-in (pass Limits)"
|
|
152
|
+
|
|
153
|
+
#: NOT mitigated on this runtime: the class is real here and nothing covers it
|
|
154
|
+
#: (the libexpat version is below the floor that fixed it). An adopter should
|
|
155
|
+
#: upgrade expat and/or opt into the relevant control.
|
|
156
|
+
LIVE = "LIVE (not mitigated on this runtime)"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SecurityReport(namedtuple("SecurityReport", [
|
|
160
|
+
"expat_version",
|
|
161
|
+
"expat_meets_safe_floor",
|
|
162
|
+
"expat_meets_recommended",
|
|
163
|
+
"recommended_limits",
|
|
164
|
+
"mitigations",
|
|
165
|
+
"notes",
|
|
166
|
+
])):
|
|
167
|
+
"""The runtime's XML-security posture, as computed by `security_report`.
|
|
168
|
+
|
|
169
|
+
A frozen, printable value object (a ``namedtuple`` subclass — typed fields +
|
|
170
|
+
a readable ``__str__``). The tuple slots are read-only, and ``mitigations`` is
|
|
171
|
+
a read-only mapping (``MappingProxyType``) so the whole report is genuinely
|
|
172
|
+
immutable — appropriate for a trust surface an adopter logs and passes around.
|
|
173
|
+
Fields:
|
|
174
|
+
|
|
175
|
+
- ``expat_version`` — the runtime libexpat ``(maj, min, micro)`` tuple.
|
|
176
|
+
- ``expat_meets_safe_floor`` — bool vs `SAFE_EXPAT_VERSION` (2.6.0).
|
|
177
|
+
- ``expat_meets_recommended`` — bool vs `RECOMMENDED_EXPAT_VERSION` (2.8.1).
|
|
178
|
+
- ``recommended_limits`` — the `RECOMMENDED_LIMITS` preset (opt-in caps).
|
|
179
|
+
- ``mitigations`` — a mapping ``{attack_class: status}`` where status is one
|
|
180
|
+
of `BLOCKED` / `EXPAT_MITIGATED` / `EXPAT_PARTIAL` / `OPT_IN` / `LIVE`.
|
|
181
|
+
- ``notes`` — a tuple of human-readable advisory strings (empty when the
|
|
182
|
+
runtime is fully covered).
|
|
183
|
+
|
|
184
|
+
**PROVISIONAL at 1.0** (per the ROADMAP freeze strategy): this is novel
|
|
185
|
+
defense-in-depth over a moving libexpat landscape, so the report's shape and
|
|
186
|
+
contents may evolve in a MINOR. The defusedxml-*mirror* surface is the frozen
|
|
187
|
+
part; this introspection is not. Evolution rule for adopters: new fields are
|
|
188
|
+
appended at the end — **access by attribute, not by position/index** — so a
|
|
189
|
+
later field addition doesn't break you.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
__slots__ = ()
|
|
193
|
+
|
|
194
|
+
# Class-level field annotations so consumers' type-checkers learn the field types
|
|
195
|
+
# (a namedtuple() subclass alone exposes them as Any — PR#21 Codex/Gemini).
|
|
196
|
+
expat_version: tuple[int, ...]
|
|
197
|
+
expat_meets_safe_floor: bool
|
|
198
|
+
expat_meets_recommended: bool
|
|
199
|
+
recommended_limits: Any
|
|
200
|
+
mitigations: Mapping[str, str]
|
|
201
|
+
notes: Sequence[str]
|
|
202
|
+
|
|
203
|
+
def __new__(cls, expat_version: tuple[int, ...], expat_meets_safe_floor: bool,
|
|
204
|
+
expat_meets_recommended: bool, recommended_limits: Any,
|
|
205
|
+
mitigations: Mapping[str, str],
|
|
206
|
+
notes: Sequence[str]) -> SecurityReport:
|
|
207
|
+
# Normalize the container fields so EVERY construction path — direct
|
|
208
|
+
# construction AND ``_replace()`` — yields a genuinely immutable report,
|
|
209
|
+
# not just ``security_report()``'s output (PR#8 Codex P2). ``mitigations``
|
|
210
|
+
# becomes a defensive read-only view; ``notes`` a tuple.
|
|
211
|
+
return super().__new__(
|
|
212
|
+
cls, expat_version, expat_meets_safe_floor, expat_meets_recommended,
|
|
213
|
+
recommended_limits, MappingProxyType(dict(mitigations)), tuple(notes))
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def _make(cls, iterable: Any) -> SecurityReport: # type: ignore[override]
|
|
217
|
+
# Route _make (used by _replace) through __new__ so it normalizes too;
|
|
218
|
+
# the default _make bypasses __new__ via tuple.__new__. (Deliberate override
|
|
219
|
+
# of namedtuple's generic _make — the signature differs but the intent stands.)
|
|
220
|
+
return cls(*iterable)
|
|
221
|
+
|
|
222
|
+
def as_dict(self) -> dict[str, Any]:
|
|
223
|
+
"""A JSON-safe ``dict`` of the report (version tuples → ``"x.y.z"`` strings,
|
|
224
|
+
``mitigations`` → plain dict, ``recommended_limits`` → dict-or-None, ``notes``
|
|
225
|
+
→ list). Backs `python -m purexml --json`. PROVISIONAL with the report."""
|
|
226
|
+
rl = self.recommended_limits
|
|
227
|
+
return {
|
|
228
|
+
"expat_version": ".".join(map(str, self.expat_version)),
|
|
229
|
+
"expat_meets_safe_floor": self.expat_meets_safe_floor,
|
|
230
|
+
"expat_meets_recommended": self.expat_meets_recommended,
|
|
231
|
+
"recommended_limits": (None if rl is None else {
|
|
232
|
+
"max_depth": rl.max_depth,
|
|
233
|
+
"max_attributes": rl.max_attributes,
|
|
234
|
+
"max_bytes": rl.max_bytes,
|
|
235
|
+
}),
|
|
236
|
+
"mitigations": dict(self.mitigations),
|
|
237
|
+
"notes": list(self.notes),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
def __str__(self) -> str:
|
|
241
|
+
ver = ".".join(map(str, self.expat_version))
|
|
242
|
+
lines = [
|
|
243
|
+
"purexml security posture",
|
|
244
|
+
" libexpat version: %s" % ver,
|
|
245
|
+
" meets safe floor (%s): %s"
|
|
246
|
+
% (".".join(map(str, SAFE_EXPAT_VERSION)),
|
|
247
|
+
"yes" if self.expat_meets_safe_floor else "NO"),
|
|
248
|
+
" meets recommended (%s): %s"
|
|
249
|
+
% (".".join(map(str, RECOMMENDED_EXPAT_VERSION)),
|
|
250
|
+
"yes" if self.expat_meets_recommended else "NO"),
|
|
251
|
+
" mitigations (where each attack class is handled on this runtime):",
|
|
252
|
+
]
|
|
253
|
+
# default=0 guards str() on a (constructible) empty mitigations map.
|
|
254
|
+
width = max((len(k) for k in self.mitigations), default=0)
|
|
255
|
+
for cls in sorted(self.mitigations):
|
|
256
|
+
lines.append(" %-*s : %s" % (width, cls, self.mitigations[cls]))
|
|
257
|
+
rl = self.recommended_limits
|
|
258
|
+
if rl is None:
|
|
259
|
+
lines.append(" recommended opt-in limits: None")
|
|
260
|
+
else:
|
|
261
|
+
lines.append(
|
|
262
|
+
" recommended opt-in limits: max_depth=%s max_attributes=%s "
|
|
263
|
+
"max_bytes=%s" % (rl.max_depth, rl.max_attributes, rl.max_bytes))
|
|
264
|
+
if self.notes:
|
|
265
|
+
lines.append(" notes:")
|
|
266
|
+
for n in self.notes:
|
|
267
|
+
lines.append(" - %s" % n)
|
|
268
|
+
return "\n".join(lines)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def security_report() -> SecurityReport:
|
|
272
|
+
"""Return a `SecurityReport` describing this runtime's XML-security posture.
|
|
273
|
+
|
|
274
|
+
Read-only and deterministic: it reads only ``pyexpat`` constants and
|
|
275
|
+
purexml's own floors — no parse, no I/O, no side effects, safe to call
|
|
276
|
+
repeatedly or at import. It does **not** change parse behavior or hard-fail;
|
|
277
|
+
it *informs* (the enforce-vs-warn policy stays a 1.0 decision).
|
|
278
|
+
|
|
279
|
+
The ``mitigations`` map records, per attack class, where it is handled **on
|
|
280
|
+
this runtime** — purexml's handlers (`BLOCKED`), the libexpat layer at this
|
|
281
|
+
version (`EXPAT_MITIGATED`), an opt-in cap (`OPT_IN`), or nowhere (`LIVE`).
|
|
282
|
+
"""
|
|
283
|
+
meets_safe = EXPAT_VERSION >= SAFE_EXPAT_VERSION
|
|
284
|
+
meets_recommended = EXPAT_VERSION >= RECOMMENDED_EXPAT_VERSION
|
|
285
|
+
|
|
286
|
+
mitigations = {
|
|
287
|
+
# Always blocked by purexml's own default-on handlers, version-independent.
|
|
288
|
+
"billion_laughs": BLOCKED,
|
|
289
|
+
"quadratic_blowup": BLOCKED,
|
|
290
|
+
"external_entity_xxe": BLOCKED,
|
|
291
|
+
"external_dtd_retrieval": BLOCKED,
|
|
292
|
+
# libexpat reparse-deferral (CVE-2023-52425), fixed in expat 2.6.0.
|
|
293
|
+
"large_tokens_cve_2023_52425":
|
|
294
|
+
EXPAT_MITIGATED if meets_safe else LIVE,
|
|
295
|
+
# disproportionate dynamic memory — fixed in expat 2.7.2 (its own fix
|
|
296
|
+
# version, NOT the moving recommended-latest floor).
|
|
297
|
+
"disproportionate_memory":
|
|
298
|
+
EXPAT_MITIGATED if EXPAT_VERSION >= _DISPROPORTIONATE_MEMORY_FIXED else LIVE,
|
|
299
|
+
# doContent integer overflow on tag-buffer realloc — expat-layer, fixed 2.7.4.
|
|
300
|
+
"content_token_overflow_cve_2026_25210":
|
|
301
|
+
EXPAT_MITIGATED if EXPAT_VERSION >= _CONTENT_TOKEN_OVERFLOW_FIXED else LIVE,
|
|
302
|
+
# quadratic attribute-name collision check (CWE-407) — expat-layer, fixed 2.8.1.
|
|
303
|
+
# Opt-in max_attributes also bounds the count this is quadratic in (see notes).
|
|
304
|
+
"attribute_collision_dos_cve_2026_45186":
|
|
305
|
+
EXPAT_MITIGATED if EXPAT_VERSION >= _ATTRIBUTE_COLLISION_FIXED else LIVE,
|
|
306
|
+
# the reachable libexpat 2.8.2 integer-overflow / memory-corruption batch (7 CVEs, one
|
|
307
|
+
# aggregate class — they share the 2.8.2 fix version + status + nature): storeAtts
|
|
308
|
+
# (CVE-2026-56403), addBinding (-56404), getAttributeId (-56405), XML_ParseBuffer
|
|
309
|
+
# (-56406), textLen (-56407), copyString (-56408), doProlog (-56132). Reached via
|
|
310
|
+
# ordinary attribute / namespace / text / DOCTYPE parsing; expat-layer (purexml can't
|
|
311
|
+
# block, only report). NOT mapped (grounded unreachable): the reentrant-handler /
|
|
312
|
+
# suspend-resume trio (50219/56131/56412) + the xmlwf trio (56409-56411). (v0.11.)
|
|
313
|
+
"integer_overflow_dos_expat_2_8_2":
|
|
314
|
+
EXPAT_MITIGATED if EXPAT_VERSION >= _INTEGER_OVERFLOW_BATCH_FIXED else LIVE,
|
|
315
|
+
# hash flooding via weak SipHash salt entropy — NEVER LIVE (hash-flood protection is
|
|
316
|
+
# present on every supported expat). But FULL 16-byte-salt hardening needs BOTH layers:
|
|
317
|
+
# expat >=2.8.0 (CVE-2026-41080 — adds XML_SetHashSalt16Bytes) AND CPython's pyexpat
|
|
318
|
+
# actually calling it (CVE-2026-7210 / gh-149018; pyexpat sets the salt itself and kept
|
|
319
|
+
# calling the 4-8-byte XML_SetHashSalt until patched). purexml drives parsers through
|
|
320
|
+
# pyexpat and CANNOT verify the wrapper at runtime, so it conservatively reports PARTIAL
|
|
321
|
+
# (fail-safe; never a false MITIGATED on the expat version alone). (CWE-331, LOW; v0.9,
|
|
322
|
+
# refined per PR#27 Codex.)
|
|
323
|
+
"hash_flooding_cve_2026_41080": EXPAT_PARTIAL,
|
|
324
|
+
# structural DoS (depth / attributes / size) — purexml opt-in caps only.
|
|
325
|
+
"structural_dos_depth_attrs_size": OPT_IN,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
notes = []
|
|
329
|
+
if not meets_recommended:
|
|
330
|
+
cur = ".".join(map(str, EXPAT_VERSION))
|
|
331
|
+
rec = ".".join(map(str, RECOMMENDED_EXPAT_VERSION))
|
|
332
|
+
live = sorted(k for k, v in mitigations.items() if v == LIVE)
|
|
333
|
+
# Below the recommended-latest floor. As of v0.11.0 every expat fix REACHABLE through
|
|
334
|
+
# purexml's paths is individually tracked in the map (the 2.8.2 batch became the mapped
|
|
335
|
+
# `integer_overflow_dos_expat_2_8_2` class), so there is no generic "untracked-gap"
|
|
336
|
+
# clause — it would be false. The mapped LIVE classes are always named so a runtime below
|
|
337
|
+
# the floor never under-reports (PR#10 Codex P2).
|
|
338
|
+
msg = "libexpat %s is below the recommended-latest floor %s" % (cur, rec)
|
|
339
|
+
if live:
|
|
340
|
+
msg += ("; the tracked class(es) %s are live on this runtime"
|
|
341
|
+
% ", ".join(live))
|
|
342
|
+
notes.append(msg + " — upgrade Python or the system expat.")
|
|
343
|
+
if mitigations["integer_overflow_dos_expat_2_8_2"] == LIVE:
|
|
344
|
+
notes.append(
|
|
345
|
+
"integer_overflow_dos_expat_2_8_2 is live on this expat (<2.8.2): a batch of "
|
|
346
|
+
"libexpat integer-overflow / memory-corruption bugs reachable via ordinary "
|
|
347
|
+
"attribute / namespace / text / DOCTYPE parsing — CVE-2026-56403 (storeAtts), "
|
|
348
|
+
"-56404 (addBinding), -56405 (getAttributeId), -56406 (XML_ParseBuffer), -56407 "
|
|
349
|
+
"(textLen), -56408 (copyString), -56132 (doProlog). Fixed in expat 2.8.2; these "
|
|
350
|
+
"live below the Python layer (purexml cannot block them) — upgrade expat to 2.8.2+.")
|
|
351
|
+
# hash_flooding is reported PARTIAL on EVERY runtime (never LIVE, never a version-only
|
|
352
|
+
# MITIGATED): the 16-byte-salt hardening needs both expat>=2.8.0 AND CPython's pyexpat
|
|
353
|
+
# fix (CVE-2026-7210), and purexml can't verify the wrapper at runtime. Tailor the note
|
|
354
|
+
# to whether expat even exposes the 16-byte API yet.
|
|
355
|
+
if EXPAT_VERSION >= _HASH_FLOODING_FIXED:
|
|
356
|
+
notes.append(
|
|
357
|
+
"hash_flooding_cve_2026_41080 is PARTIAL: libexpat >=2.8.0 has the 16-byte "
|
|
358
|
+
"hash-salt API (CVE-2026-41080 fixed at the expat layer), but full mitigation "
|
|
359
|
+
"also requires CPython's pyexpat to call XML_SetHashSalt16Bytes (CVE-2026-7210, "
|
|
360
|
+
"gh-149018) — which purexml cannot verify at runtime, so it is reported "
|
|
361
|
+
"conservatively as PARTIAL. If your Python includes that fix, the class is fully "
|
|
362
|
+
"mitigated (LOW severity).")
|
|
363
|
+
else:
|
|
364
|
+
notes.append(
|
|
365
|
+
"hash_flooding_cve_2026_41080 is PARTIAL: libexpat's SipHash hash-flood defense "
|
|
366
|
+
"is present but seeded with weaker salt entropy (4-8 bytes vs 16; CVE-2026-41080, "
|
|
367
|
+
"CWE-331, LOW). Full mitigation needs expat >=2.8.0 AND CPython's pyexpat fix "
|
|
368
|
+
"(CVE-2026-7210) — upgrade both.")
|
|
369
|
+
if mitigations["attribute_collision_dos_cve_2026_45186"] == LIVE:
|
|
370
|
+
notes.append(
|
|
371
|
+
"attribute_collision_dos is live on this expat (<2.8.1): opt-in "
|
|
372
|
+
"max_attributes (e.g. RECOMMENDED_LIMITS) bounds the attribute count it "
|
|
373
|
+
"is quadratic in, reducing exposure until expat is upgraded.")
|
|
374
|
+
notes.append(
|
|
375
|
+
"structural DoS (deep nesting / attribute floods / giant documents) is "
|
|
376
|
+
"opt-in: pass RECOMMENDED_LIMITS (or your own Limits) to the parse entry "
|
|
377
|
+
"point's limits= parameter to bound it.")
|
|
378
|
+
notes.append(
|
|
379
|
+
"external_dtd_retrieval=blocked means no fetch/resolution is attempted "
|
|
380
|
+
"(default-on) — NOT that external-DTD/entity *declarations* are rejected: "
|
|
381
|
+
"an unresolved SYSTEM/PUBLIC declaration still parses (no I/O), matching "
|
|
382
|
+
"defusedxml. Pass forbid_dtd=True to reject the DOCTYPE outright.")
|
|
383
|
+
|
|
384
|
+
# __new__ normalizes mitigations → read-only view and notes → tuple, so a plain
|
|
385
|
+
# dict/list is fine here (see SecurityReport.__new__).
|
|
386
|
+
return SecurityReport(
|
|
387
|
+
expat_version=EXPAT_VERSION,
|
|
388
|
+
expat_meets_safe_floor=meets_safe,
|
|
389
|
+
expat_meets_recommended=meets_recommended,
|
|
390
|
+
recommended_limits=RECOMMENDED_LIMITS,
|
|
391
|
+
mitigations=mitigations,
|
|
392
|
+
notes=notes,
|
|
393
|
+
)
|