diff --git a/collector/jq_collector/github.py b/collector/jq_collector/github.py
index b276431..408f6da 100644
--- a/collector/jq_collector/github.py
+++ b/collector/jq_collector/github.py
@@ -24,10 +24,15 @@
import zipfile
from datetime import datetime
from typing import Any
-from xml.etree import ElementTree
import httpx
+# coverage.xml comes out of another repo's CI artifact, so it is untrusted
+# input. defusedxml refuses entity declarations and external references, and
+# raises a ValueError subclass for them, which coverage_percent already treats
+# as a malformed report rather than a failed refresh.
+from defusedxml import ElementTree
+
from .config import Config
from .forge import GOOD_CONCLUSIONS, INCONCLUSIVE_CONCLUSIONS
from .state import MergedPull, PullRequest, RemoteRepo, WorkflowRun
diff --git a/collector/pyproject.toml b/collector/pyproject.toml
index 490ffe1..9400b41 100644
--- a/collector/pyproject.toml
+++ b/collector/pyproject.toml
@@ -7,6 +7,7 @@ dependencies = [
"prometheus-client>=0.20",
"httpx>=0.27",
"PyYAML>=6.0",
+ "defusedxml>=0.7",
]
[build-system]
@@ -17,7 +18,7 @@ build-backend = "hatchling.build"
packages = ["jq_collector"]
[dependency-groups]
-dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.16", "mypy>=1.10", "types-PyYAML>=6.0"]
+dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.16", "mypy>=1.10", "types-PyYAML>=6.0", "types-defusedxml>=0.7"]
[tool.pytest.ini_options]
# The package too, for its doctests: an example in a docstring is a claim,
diff --git a/collector/tests/test_coverage.py b/collector/tests/test_coverage.py
index 8dcbbd7..6df2cfc 100644
--- a/collector/tests/test_coverage.py
+++ b/collector/tests/test_coverage.py
@@ -119,8 +119,12 @@ def test_an_expired_artifact_download_is_survivable(make_client):
zipped(""), # no line-rate
zipped("not xml", "coverage.xml"), # unparseable
zipped(REPORT, "something-else.txt"), # no coverage.xml inside
+ # Well-formed, and the stdlib parser would read it as 100%: the entity
+ # expands to a valid rate. Entities are how an XML bomb is built, so a
+ # report that declares one is refused rather than expanded.
+ zipped(']>'),
],
- ids=["not-a-zip", "no-line-rate", "bad-xml", "no-coverage-xml"],
+ ids=["not-a-zip", "no-line-rate", "bad-xml", "no-coverage-xml", "declares-an-entity"],
)
def test_a_malformed_report_is_ci_s_problem_not_a_failed_refresh(make_client, blob):
"""Everything else in the refresh has already been gathered by this point."""
diff --git a/collector/uv.lock b/collector/uv.lock
index 1f7ec9e..5f925cd 100644
--- a/collector/uv.lock
+++ b/collector/uv.lock
@@ -220,6 +220,15 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -280,6 +289,7 @@ name = "jq-collector"
version = "0.1.0"
source = { editable = "." }
dependencies = [
+ { name = "defusedxml" },
{ name = "httpx" },
{ name = "prometheus-client" },
{ name = "pyyaml" },
@@ -291,11 +301,13 @@ dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
+ { name = "types-defusedxml" },
{ name = "types-pyyaml" },
]
[package.metadata]
requires-dist = [
+ { name = "defusedxml", specifier = ">=0.7" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "prometheus-client", specifier = ">=0.20" },
{ name = "pyyaml", specifier = ">=6.0" },
@@ -307,6 +319,7 @@ dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=5.0" },
{ name = "ruff", specifier = ">=0.16" },
+ { name = "types-defusedxml", specifier = ">=0.7" },
{ name = "types-pyyaml", specifier = ">=6.0" },
]
@@ -704,6 +717,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
+[[package]]
+name = "types-defusedxml"
+version = "0.7.0.20260504"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/d2/4553c8fa9cdebfe1e84b950cf790a4d2376a97fa016e43f7c65323fa6c7d/types_defusedxml-0.7.0.20260504.tar.gz", hash = "sha256:2ab2828a3f97111ba1c16cee273ad4124a831fc9198c41bf8368ff6ea48ad300", size = 10729, upload-time = "2026-05-04T05:22:50.192Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/2d/8a4b5ba1d732b8bc691f0efbb1c6f77cb5f6f473b2afe181d83d910773c5/types_defusedxml-0.7.0.20260504-py3-none-any.whl", hash = "sha256:a959e3a0a43b93e464bd625d91ec9193a2703ddc10443bdd627792485fae0b10", size = 13467, upload-time = "2026-05-04T05:22:49.319Z" },
+]
+
[[package]]
name = "types-pyyaml"
version = "6.0.12.20260906"