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