purexml 1.0.0__tar.gz
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-1.0.0/LICENSE +21 -0
- purexml-1.0.0/PKG-INFO +158 -0
- purexml-1.0.0/README.md +124 -0
- purexml-1.0.0/pyproject.toml +87 -0
- purexml-1.0.0/setup.cfg +4 -0
- purexml-1.0.0/src/purexml/ElementTree.py +52 -0
- purexml-1.0.0/src/purexml/__init__.py +98 -0
- purexml-1.0.0/src/purexml/__main__.py +78 -0
- purexml-1.0.0/src/purexml/_expat_security.py +393 -0
- purexml-1.0.0/src/purexml/_parser.py +382 -0
- purexml-1.0.0/src/purexml/common.py +41 -0
- purexml-1.0.0/src/purexml/errors.py +123 -0
- purexml-1.0.0/src/purexml/expatreader.py +110 -0
- purexml-1.0.0/src/purexml/limits.py +81 -0
- purexml-1.0.0/src/purexml/minidom.py +176 -0
- purexml-1.0.0/src/purexml/py.typed +0 -0
- purexml-1.0.0/src/purexml/sax.py +69 -0
- purexml-1.0.0/src/purexml/xmlrpc.py +183 -0
- purexml-1.0.0/src/purexml.egg-info/PKG-INFO +158 -0
- purexml-1.0.0/src/purexml.egg-info/SOURCES.txt +41 -0
- purexml-1.0.0/src/purexml.egg-info/dependency_links.txt +1 -0
- purexml-1.0.0/src/purexml.egg-info/requires.txt +11 -0
- purexml-1.0.0/src/purexml.egg-info/top_level.txt +1 -0
- purexml-1.0.0/tests/test_attacks.py +71 -0
- purexml-1.0.0/tests/test_durability.py +49 -0
- purexml-1.0.0/tests/test_equivalence.py +73 -0
- purexml-1.0.0/tests/test_examples.py +45 -0
- purexml-1.0.0/tests/test_expat_security.py +63 -0
- purexml-1.0.0/tests/test_fo_contract.py +293 -0
- purexml-1.0.0/tests/test_fuzz_equivalence.py +233 -0
- purexml-1.0.0/tests/test_hardening_soak.py +142 -0
- purexml-1.0.0/tests/test_misc.py +105 -0
- purexml-1.0.0/tests/test_no_io.py +90 -0
- purexml-1.0.0/tests/test_public_contract.py +150 -0
- purexml-1.0.0/tests/test_v02_surface.py +187 -0
- purexml-1.0.0/tests/test_v03_iterparse.py +136 -0
- purexml-1.0.0/tests/test_v04_limits.py +182 -0
- purexml-1.0.0/tests/test_v05_security_report.py +221 -0
- purexml-1.0.0/tests/test_v07_cli.py +97 -0
- purexml-1.0.0/tests/test_v10_minidom.py +189 -0
- purexml-1.0.0/tests/test_v12_sax.py +196 -0
- purexml-1.0.0/tests/test_v13_xmlrpc.py +216 -0
- purexml-1.0.0/tests/test_v14_limits_breadth.py +130 -0
purexml-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Russell Pfister
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
purexml-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: purexml
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Safely parse untrusted XML using only the Python standard library — a zero-dependency defusedxml replacement.
|
|
5
|
+
Author-email: Russell Pfister <russalo@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/russalo/purexml
|
|
8
|
+
Project-URL: Repository, https://github.com/russalo/purexml
|
|
9
|
+
Project-URL: Issues, https://github.com/russalo/purexml/issues
|
|
10
|
+
Keywords: xml,security,defusedxml,xxe,billion-laughs,stdlib,hardening
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Classifier: Topic :: Text Processing :: Markup :: XML
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
Requires-Dist: defusedxml; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
29
|
+
Requires-Dist: mypy; extra == "dev"
|
|
30
|
+
Provides-Extra: fuzz
|
|
31
|
+
Requires-Dist: atheris; extra == "fuzz"
|
|
32
|
+
Requires-Dist: defusedxml; extra == "fuzz"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
<!-- Plain markdown image with an ABSOLUTE raw URL (not <picture>/<img>): the GitHub iOS
|
|
36
|
+
app doesn't render <picture>, and PyPI doesn't resolve relative paths — markdown ![]
|
|
37
|
+
with a raw.githubusercontent URL renders on all three (GitHub web + mobile, PyPI). -->
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
# purexml
|
|
41
|
+
|
|
42
|
+
> Safely parse untrusted XML using only the Python standard library.
|
|
43
|
+
|
|
44
|
+
[](https://github.com/russalo/purexml/actions/workflows/tests.yml)
|
|
45
|
+

|
|
46
|
+

|
|
47
|
+

|
|
48
|
+

|
|
49
|
+

|
|
50
|
+

|
|
51
|
+

|
|
52
|
+

|
|
53
|
+
|
|
54
|
+
**purexml is a zero-dependency, drop-in replacement for [`defusedxml`](https://pypi.org/project/defusedxml/).**
|
|
55
|
+
It hardens Python's standard-library XML parsers against the known attack classes —
|
|
56
|
+
entity-expansion bombs, external-entity resolution (XXE), and external-DTD retrieval —
|
|
57
|
+
and hands back the same standard `xml.etree` / `minidom` / SAX objects your code already
|
|
58
|
+
expects. Migrating is a literal find-and-replace: `s/defusedxml/purexml/`.
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
## Why purexml
|
|
63
|
+
|
|
64
|
+
- **Zero runtime dependencies.** Pure standard library — drop a third-party dependency
|
|
65
|
+
with no loss of protection (same parses succeed, same attacks blocked).
|
|
66
|
+
- **A real drop-in.** Mirrors `defusedxml`'s API and defaults across the surface the
|
|
67
|
+
ecosystem actually imports, validated **oracle-gated against `defusedxml` itself**.
|
|
68
|
+
- **Maintained.** `defusedxml` has been frozen since 2021 — the XML threat landscape
|
|
69
|
+
hasn't. purexml tracks the moving libexpat / CPython mitigations and can report what
|
|
70
|
+
your runtime is actually protected against.
|
|
71
|
+
- **Opt-in hardening.** Structural-DoS caps and a posture report that `defusedxml`
|
|
72
|
+
lacks — **off by default**, so the drop-in promise is never broken.
|
|
73
|
+
|
|
74
|
+
## Install & migrate
|
|
75
|
+
|
|
76
|
+
Until purexml is published to PyPI, depend on it via a git or path reference. Then the
|
|
77
|
+
only change to your code is the import path:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# before: from defusedxml.ElementTree import fromstring
|
|
81
|
+
from purexml.ElementTree import fromstring
|
|
82
|
+
|
|
83
|
+
root = fromstring(untrusted_xml) # raises on bomb / XXE / external DTD; returns a standard Element
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Defaults match `defusedxml` exactly (`forbid_entities=True`, `forbid_external=True`,
|
|
87
|
+
`forbid_dtd=False`), so nothing else changes.
|
|
88
|
+
|
|
89
|
+
> Runnable examples for every surface: **[`examples/`](examples/)** ·
|
|
90
|
+
> Module-by-module migration guide: **[`docs/MIGRATING.md`](docs/MIGRATING.md)**
|
|
91
|
+
|
|
92
|
+
## What it covers
|
|
93
|
+
|
|
94
|
+
Drop-in replacements for the `defusedxml` modules the ecosystem imports:
|
|
95
|
+
|
|
96
|
+
| purexml module | replaces | surface |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `purexml.ElementTree` | `defusedxml.ElementTree` | `fromstring`, `parse`, `iterparse`, `XML`, `XMLParser`, `tostring`, `forbid_*` |
|
|
99
|
+
| `purexml.minidom` | `defusedxml.minidom` | `parse`, `parseString` |
|
|
100
|
+
| `purexml.sax` / `.expatreader` | `defusedxml.sax` | `make_parser`, `parse`, `parseString` |
|
|
101
|
+
| `purexml.xmlrpc` | `defusedxml.xmlrpc` | `monkey_patch`, `unmonkey_patch` |
|
|
102
|
+
| `purexml.common` | `defusedxml.common` | exception catch-site aliases |
|
|
103
|
+
|
|
104
|
+
`defusedxml.pulldom` is deferred (low measured demand); `defusedxml.lxml` is excluded by
|
|
105
|
+
the zero-dependency contract (it wraps the third-party `lxml`).
|
|
106
|
+
|
|
107
|
+
## Opt-in defense-in-depth
|
|
108
|
+
|
|
109
|
+
Bounded protections `defusedxml` never had — **default-off**, so the strict mirror is
|
|
110
|
+
unchanged until you ask:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import purexml
|
|
114
|
+
from purexml import fromstring, RECOMMENDED_LIMITS
|
|
115
|
+
|
|
116
|
+
# Bound structural DoS (deep nesting / attribute floods / giant documents):
|
|
117
|
+
root = fromstring(untrusted, limits=RECOMMENDED_LIMITS) # raises LimitExceeded past the caps
|
|
118
|
+
|
|
119
|
+
# See what THIS runtime is actually protected against (read-only; no parse):
|
|
120
|
+
print(purexml.security_report())
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
There's also a posture CLI — `python -m purexml` (`--json` for machine output, or
|
|
124
|
+
`--check --min-expat X.Y.Z` as an opt-in CI gate).
|
|
125
|
+
|
|
126
|
+
## Status
|
|
127
|
+
|
|
128
|
+
**v1.0.0 — the public contract is frozen and binding.** The `defusedxml`-mirror surface
|
|
129
|
+
won't move under you without a 2.0 (see [`PUBLIC_CONTRACT.md`](PUBLIC_CONTRACT.md)).
|
|
130
|
+
|
|
131
|
+
- Runs on **CPython 3.10–3.13**, zero runtime dependencies.
|
|
132
|
+
- Correctness is **oracle-gated against `defusedxml` every release** — C14N + event-stream
|
|
133
|
+
equivalence over a real corpus, an adversarial attack battery, and differential fuzzing
|
|
134
|
+
(see [`docs/EQUIVALENCE.md`](docs/EQUIVALENCE.md)).
|
|
135
|
+
- The contract freeze was ratified with the anchor consumer (file-observer) — see
|
|
136
|
+
[`docs/v1.0.0_RFC_Specification.md`](docs/v1.0.0_RFC_Specification.md). The opt-in
|
|
137
|
+
defense-in-depth (`Limits`, `security_report()`) stays **provisional** (it tracks the
|
|
138
|
+
moving libexpat threat landscape).
|
|
139
|
+
- **Not yet published to PyPI**, and the `purexml` name is not yet claimed — distribution
|
|
140
|
+
is a separate, deliberate step. Depend on it via git/path until then.
|
|
141
|
+
- **License: MIT.**
|
|
142
|
+
|
|
143
|
+
## Documentation
|
|
144
|
+
|
|
145
|
+
- **[`examples/`](examples/)** — runnable, copy-paste examples for every surface
|
|
146
|
+
- **[`docs/MIGRATING.md`](docs/MIGRATING.md)** — the `s/defusedxml/purexml/` migration guide
|
|
147
|
+
- [`COMPATIBILITY.md`](COMPATIBILITY.md) — drop-in compatibility contract + exception edge cases
|
|
148
|
+
- [`LIMITATIONS.md`](LIMITATIONS.md) — what purexml deliberately does **not** do
|
|
149
|
+
- [`SECURITY.md`](SECURITY.md) — security policy and how to report issues
|
|
150
|
+
- [`PUBLIC_CONTRACT.md`](PUBLIC_CONTRACT.md) — consumer stability commitments (binds at 1.0)
|
|
151
|
+
- [`CHANGELOG.md`](CHANGELOG.md) — release notes · [`HISTORY.md`](HISTORY.md) — full per-release record
|
|
152
|
+
- [`STACK.md`](STACK.md) — language, runtime, dependencies · [`CONVENTIONS.md`](CONVENTIONS.md) — project conventions
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
[MIT](LICENSE) — purexml is give-it-away, zero-dependency infrastructure: pure open
|
|
157
|
+
source, maximum reuse. Publishing to PyPI and claiming the `purexml` name remain a
|
|
158
|
+
separate, deliberate call.
|
purexml-1.0.0/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<!-- Plain markdown image with an ABSOLUTE raw URL (not <picture>/<img>): the GitHub iOS
|
|
2
|
+
app doesn't render <picture>, and PyPI doesn't resolve relative paths — markdown ![]
|
|
3
|
+
with a raw.githubusercontent URL renders on all three (GitHub web + mobile, PyPI). -->
|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
# purexml
|
|
7
|
+
|
|
8
|
+
> Safely parse untrusted XML using only the Python standard library.
|
|
9
|
+
|
|
10
|
+
[](https://github.com/russalo/purexml/actions/workflows/tests.yml)
|
|
11
|
+

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+

|
|
15
|
+

|
|
16
|
+

|
|
17
|
+

|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
**purexml is a zero-dependency, drop-in replacement for [`defusedxml`](https://pypi.org/project/defusedxml/).**
|
|
21
|
+
It hardens Python's standard-library XML parsers against the known attack classes —
|
|
22
|
+
entity-expansion bombs, external-entity resolution (XXE), and external-DTD retrieval —
|
|
23
|
+
and hands back the same standard `xml.etree` / `minidom` / SAX objects your code already
|
|
24
|
+
expects. Migrating is a literal find-and-replace: `s/defusedxml/purexml/`.
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
## Why purexml
|
|
29
|
+
|
|
30
|
+
- **Zero runtime dependencies.** Pure standard library — drop a third-party dependency
|
|
31
|
+
with no loss of protection (same parses succeed, same attacks blocked).
|
|
32
|
+
- **A real drop-in.** Mirrors `defusedxml`'s API and defaults across the surface the
|
|
33
|
+
ecosystem actually imports, validated **oracle-gated against `defusedxml` itself**.
|
|
34
|
+
- **Maintained.** `defusedxml` has been frozen since 2021 — the XML threat landscape
|
|
35
|
+
hasn't. purexml tracks the moving libexpat / CPython mitigations and can report what
|
|
36
|
+
your runtime is actually protected against.
|
|
37
|
+
- **Opt-in hardening.** Structural-DoS caps and a posture report that `defusedxml`
|
|
38
|
+
lacks — **off by default**, so the drop-in promise is never broken.
|
|
39
|
+
|
|
40
|
+
## Install & migrate
|
|
41
|
+
|
|
42
|
+
Until purexml is published to PyPI, depend on it via a git or path reference. Then the
|
|
43
|
+
only change to your code is the import path:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# before: from defusedxml.ElementTree import fromstring
|
|
47
|
+
from purexml.ElementTree import fromstring
|
|
48
|
+
|
|
49
|
+
root = fromstring(untrusted_xml) # raises on bomb / XXE / external DTD; returns a standard Element
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Defaults match `defusedxml` exactly (`forbid_entities=True`, `forbid_external=True`,
|
|
53
|
+
`forbid_dtd=False`), so nothing else changes.
|
|
54
|
+
|
|
55
|
+
> Runnable examples for every surface: **[`examples/`](examples/)** ·
|
|
56
|
+
> Module-by-module migration guide: **[`docs/MIGRATING.md`](docs/MIGRATING.md)**
|
|
57
|
+
|
|
58
|
+
## What it covers
|
|
59
|
+
|
|
60
|
+
Drop-in replacements for the `defusedxml` modules the ecosystem imports:
|
|
61
|
+
|
|
62
|
+
| purexml module | replaces | surface |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `purexml.ElementTree` | `defusedxml.ElementTree` | `fromstring`, `parse`, `iterparse`, `XML`, `XMLParser`, `tostring`, `forbid_*` |
|
|
65
|
+
| `purexml.minidom` | `defusedxml.minidom` | `parse`, `parseString` |
|
|
66
|
+
| `purexml.sax` / `.expatreader` | `defusedxml.sax` | `make_parser`, `parse`, `parseString` |
|
|
67
|
+
| `purexml.xmlrpc` | `defusedxml.xmlrpc` | `monkey_patch`, `unmonkey_patch` |
|
|
68
|
+
| `purexml.common` | `defusedxml.common` | exception catch-site aliases |
|
|
69
|
+
|
|
70
|
+
`defusedxml.pulldom` is deferred (low measured demand); `defusedxml.lxml` is excluded by
|
|
71
|
+
the zero-dependency contract (it wraps the third-party `lxml`).
|
|
72
|
+
|
|
73
|
+
## Opt-in defense-in-depth
|
|
74
|
+
|
|
75
|
+
Bounded protections `defusedxml` never had — **default-off**, so the strict mirror is
|
|
76
|
+
unchanged until you ask:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import purexml
|
|
80
|
+
from purexml import fromstring, RECOMMENDED_LIMITS
|
|
81
|
+
|
|
82
|
+
# Bound structural DoS (deep nesting / attribute floods / giant documents):
|
|
83
|
+
root = fromstring(untrusted, limits=RECOMMENDED_LIMITS) # raises LimitExceeded past the caps
|
|
84
|
+
|
|
85
|
+
# See what THIS runtime is actually protected against (read-only; no parse):
|
|
86
|
+
print(purexml.security_report())
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
There's also a posture CLI — `python -m purexml` (`--json` for machine output, or
|
|
90
|
+
`--check --min-expat X.Y.Z` as an opt-in CI gate).
|
|
91
|
+
|
|
92
|
+
## Status
|
|
93
|
+
|
|
94
|
+
**v1.0.0 — the public contract is frozen and binding.** The `defusedxml`-mirror surface
|
|
95
|
+
won't move under you without a 2.0 (see [`PUBLIC_CONTRACT.md`](PUBLIC_CONTRACT.md)).
|
|
96
|
+
|
|
97
|
+
- Runs on **CPython 3.10–3.13**, zero runtime dependencies.
|
|
98
|
+
- Correctness is **oracle-gated against `defusedxml` every release** — C14N + event-stream
|
|
99
|
+
equivalence over a real corpus, an adversarial attack battery, and differential fuzzing
|
|
100
|
+
(see [`docs/EQUIVALENCE.md`](docs/EQUIVALENCE.md)).
|
|
101
|
+
- The contract freeze was ratified with the anchor consumer (file-observer) — see
|
|
102
|
+
[`docs/v1.0.0_RFC_Specification.md`](docs/v1.0.0_RFC_Specification.md). The opt-in
|
|
103
|
+
defense-in-depth (`Limits`, `security_report()`) stays **provisional** (it tracks the
|
|
104
|
+
moving libexpat threat landscape).
|
|
105
|
+
- **Not yet published to PyPI**, and the `purexml` name is not yet claimed — distribution
|
|
106
|
+
is a separate, deliberate step. Depend on it via git/path until then.
|
|
107
|
+
- **License: MIT.**
|
|
108
|
+
|
|
109
|
+
## Documentation
|
|
110
|
+
|
|
111
|
+
- **[`examples/`](examples/)** — runnable, copy-paste examples for every surface
|
|
112
|
+
- **[`docs/MIGRATING.md`](docs/MIGRATING.md)** — the `s/defusedxml/purexml/` migration guide
|
|
113
|
+
- [`COMPATIBILITY.md`](COMPATIBILITY.md) — drop-in compatibility contract + exception edge cases
|
|
114
|
+
- [`LIMITATIONS.md`](LIMITATIONS.md) — what purexml deliberately does **not** do
|
|
115
|
+
- [`SECURITY.md`](SECURITY.md) — security policy and how to report issues
|
|
116
|
+
- [`PUBLIC_CONTRACT.md`](PUBLIC_CONTRACT.md) — consumer stability commitments (binds at 1.0)
|
|
117
|
+
- [`CHANGELOG.md`](CHANGELOG.md) — release notes · [`HISTORY.md`](HISTORY.md) — full per-release record
|
|
118
|
+
- [`STACK.md`](STACK.md) — language, runtime, dependencies · [`CONVENTIONS.md`](CONVENTIONS.md) — project conventions
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
[MIT](LICENSE) — purexml is give-it-away, zero-dependency infrastructure: pure open
|
|
123
|
+
source, maximum reuse. Publishing to PyPI and claiming the `purexml` name remain a
|
|
124
|
+
separate, deliberate call.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=65", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "purexml"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Safely parse untrusted XML using only the Python standard library — a zero-dependency defusedxml replacement."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
# MIT (decided 2026-06-28) — pure open source, maximum reuse; one-way compatible with
|
|
12
|
+
# AGPL so it's clean as a future file-observer dependency. See LICENSE. Table form
|
|
13
|
+
# (`{text="MIT"}`) + classifier is used rather than the bare SPDX `license = "MIT"`
|
|
14
|
+
# string: the string form needs setuptools>=77 and (per PEP 639) deprecates the license
|
|
15
|
+
# classifier — this form keeps the classifier (which PyPI's UI surfaces) with no warning
|
|
16
|
+
# on the current setuptools>=65 floor.
|
|
17
|
+
license = {text = "MIT"}
|
|
18
|
+
authors = [
|
|
19
|
+
{name = "Russell Pfister", email = "russalo@gmail.com"},
|
|
20
|
+
]
|
|
21
|
+
keywords = ["xml", "security", "defusedxml", "xxe", "billion-laughs", "stdlib", "hardening"]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 5 - Production/Stable",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Topic :: Security",
|
|
27
|
+
"Topic :: Text Processing :: Markup :: XML",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
]
|
|
34
|
+
# Start empty. Add real runtime deps as the project grows; prefer stdlib first.
|
|
35
|
+
dependencies = []
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
# defusedxml is the dev/test ORACLE only (RFC §3/§5) — never a runtime dependency,
|
|
39
|
+
# never imported under src/. It is the incumbent purexml is validated against.
|
|
40
|
+
# ruff (lint) + pytest-cov (coverage gate) are dev-only quality tooling, run in CI;
|
|
41
|
+
# never runtime deps. (mypy is intentionally NOT here yet — see the typed-badge note
|
|
42
|
+
# in scratch/publish_prep_checklist.md: the surface needs annotating first.)
|
|
43
|
+
dev = ["pytest", "defusedxml", "ruff", "pytest-cov", "mypy"]
|
|
44
|
+
|
|
45
|
+
# Coverage-guided differential fuzzing (v0.5 RFC §3/§4). DEV-ONLY, opt-in — Atheris
|
|
46
|
+
# is heavier tooling (libFuzzer/clang) for on-demand deep sweeps; the always-run
|
|
47
|
+
# seeded differential gate (tests/test_fuzz_equivalence.py) needs NO extra. Atheris
|
|
48
|
+
# is NEVER a runtime dependency and is never imported under src/.
|
|
49
|
+
fuzz = ["atheris", "defusedxml"]
|
|
50
|
+
|
|
51
|
+
# URLs PROVISIONAL — repo home not confirmed (adoption model open; may be vendored
|
|
52
|
+
# rather than published). russalo convention path shown; revisit when publishing is decided.
|
|
53
|
+
[project.urls]
|
|
54
|
+
Homepage = "https://github.com/russalo/purexml"
|
|
55
|
+
Repository = "https://github.com/russalo/purexml"
|
|
56
|
+
Issues = "https://github.com/russalo/purexml/issues"
|
|
57
|
+
|
|
58
|
+
# If/when this project ships a CLI, declare it here:
|
|
59
|
+
# [project.scripts]
|
|
60
|
+
# purexml = "purexml.cli:main"
|
|
61
|
+
|
|
62
|
+
[tool.setuptools.packages.find]
|
|
63
|
+
where = ["src"]
|
|
64
|
+
|
|
65
|
+
# Ship the PEP 561 marker so downstream type-checkers consume purexml's types (v0.8).
|
|
66
|
+
[tool.setuptools.package-data]
|
|
67
|
+
purexml = ["py.typed"]
|
|
68
|
+
|
|
69
|
+
[tool.pytest.ini_options]
|
|
70
|
+
# Only collect the project's own tests. Prevents `pytest` from walking the repo
|
|
71
|
+
# root and importing test files inside scratch/ or any harvested corpora — that
|
|
72
|
+
# both errors on collection AND writes __pycache__/*.pyc into shared dirs.
|
|
73
|
+
testpaths = ["tests"]
|
|
74
|
+
pythonpath = ["src"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff]
|
|
77
|
+
target-version = "py310"
|
|
78
|
+
line-length = 100
|
|
79
|
+
|
|
80
|
+
[tool.coverage.run]
|
|
81
|
+
source = ["purexml"]
|
|
82
|
+
# CI runs: pytest --cov=purexml --cov-fail-under=90 (enforced floor; currently ~94%).
|
|
83
|
+
|
|
84
|
+
[tool.mypy]
|
|
85
|
+
files = ["src/purexml"]
|
|
86
|
+
python_version = "3.10"
|
|
87
|
+
strict = true # v0.8.1: full strict — internals annotated, Any leaks resolved.
|
purexml-1.0.0/setup.cfg
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
|
+
]
|
|
@@ -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
|
+
]
|
|
@@ -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())
|