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"