From dc52c6d32207b11c4cc5ae31cc547a9d69603f98 Mon Sep 17 00:00:00 2001 From: aliencaocao Date: Thu, 24 Sep 2026 08:52:55 +0000 Subject: [PATCH] fix(refresh): skip hardening-only Docker Security subsections Docker 29.8.0 ships a ### Security subsection that only adds AppArmor and SELinux policy rules and names no CVE or GHSA identifier. The extractor treated that as a wording change and failed closed, which has held the daily minimum-versions-refresh job red since 2026-09-01 while the real minimum (29.7.0) sat unchanged one release below. A Security subsection without an identifier fixes no tracked vulnerability, so it now neither moves the minimum nor stops the refresh. The page-level guard stays: if no security release on the page names an identifier, the extractor still fails closed and lists the releases it skipped. --- cmax/minimum_refresh.py | 41 ++++++++++++++++++++--------- tests/audit/test_minimum_refresh.py | 28 +++++++++++++++++++- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/cmax/minimum_refresh.py b/cmax/minimum_refresh.py index ca5ac44..7f967bb 100644 --- a/cmax/minimum_refresh.py +++ b/cmax/minimum_refresh.py @@ -1304,12 +1304,16 @@ def docker_minimums( ) -> dict: """Derive the Docker Engine minimum from the official release notes. - The minimum is the highest stable release whose notes carry a `### Security` - subsection. A release without one ships no security fix, so it does not - move the minimum. A release candidate never becomes the minimum. The notes are - a text convention, so a page that yields no releases, a page that yields - no security release, or a security release that names no CVE or GHSA - identifier stops the refresh instead of publishing a weakened minimum. + The minimum is the highest stable release whose `### Security` subsection + names a CVE or GHSA identifier. A release without a Security subsection + ships no security fix, so it does not move the minimum. A Security + subsection that names no identifier is a hardening-only release (Docker + 29.8.0 added AppArmor and SELinux policy rules this way); it fixes no + tracked vulnerability, so it does not move the minimum either. A release + candidate never becomes the minimum. The notes are a text convention, so a + page that yields no releases, a page that yields no security release, or a + page on which no security release names an identifier stops the refresh + instead of publishing a weakened minimum. The `existing` table is not read here. The shared checks in `verify()` compare the rebuild against it and reject any lowered minimum. @@ -1319,6 +1323,7 @@ def docker_minimums( if majors is None: majors = DOCKER_ENGINE_MAJORS candidates: list[tuple[tuple, int, dict]] = [] + hardening_only: list[str] = [] for major in majors: url = docker_release_notes_url(major) releases = parse_docker_release_notes(client.get_text(url)) @@ -1332,20 +1337,32 @@ def docker_minimums( continue if not release["security"]: continue + if not release["cves"] and not release["advisories"]: + # A Security subsection with no identifier is hardening, not a + # fix for a tracked vulnerability. It cannot move the minimum, + # and it must not stop the refresh either: Docker 29.8.0 held + # the daily job red for weeks this way while the real minimum + # sat unchanged one release below it. + hardening_only.append(release["version"]) + continue candidates.append((version_key(release["version"]), major, release)) if not candidates: + pages = ", ".join(docker_release_notes_url(major) for major in majors) + if hardening_only: + raise MinimumRefreshError( + "docker: every Security subsection on " + + pages + + " names no CVE or GHSA identifier (" + + ", ".join(hardening_only) + + "); the wording probably changed" + ) raise MinimumRefreshError( "docker: no release carries a Security subsection on " - + ", ".join(docker_release_notes_url(major) for major in majors) + + pages + "; the heading convention probably changed" ) _, major, release = max(candidates, key=lambda item: item[0]) version = release["version"] - if not release["cves"] and not release["advisories"]: - raise MinimumRefreshError( - f"docker: release {version} carries a Security subsection that " - f"names no CVE or GHSA identifier; the wording probably changed" - ) if not release["date"]: raise MinimumRefreshError( f"docker: release {version} carries no release-date shortcode; " diff --git a/tests/audit/test_minimum_refresh.py b/tests/audit/test_minimum_refresh.py index e16be15..5be91d2 100644 --- a/tests/audit/test_minimum_refresh.py +++ b/tests/audit/test_minimum_refresh.py @@ -763,7 +763,32 @@ def test_a_page_without_any_security_subsection_fails_closed(self) -> None: fr.docker_minimums(fetch=self.stub) self.assertIn("Security subsection", str(caught.exception)) - def test_a_security_release_without_identifiers_fails_closed(self) -> None: + def test_a_hardening_only_security_release_does_not_move_the_minimum(self) -> None: + # Docker 29.8.0 shipped a Security subsection that only added AppArmor + # and SELinux policy rules and named no CVE or GHSA. It fixes no tracked + # vulnerability, so the minimum stays at the newest release that does, + # and the refresh keeps running. + page = ( + '## 29.8.0\n\n{{< release-date date="2026-09-03" >}}\n\n' + "### Security\n\n" + "- Add daemon support for configuring the default container AppArmor " + "profile template. [moby/moby#52771](https://github.com/moby/moby/pull/52771)\n" + "- Prevent containers from using the 32-bit `socketcall(2)` path to " + "create `AF_VSOCK` sockets by adding AppArmor and SELinux policy rules. " + "[moby/moby#53551](https://github.com/moby/moby/pull/53551)\n\n" + "### Networking\n\n- Fix a Swarm lookup.\n\n" + + self.docker_page() + ) + self.stub.text_by_url[fr.docker_release_notes_url(29)] = page + block = fr.docker_minimums(fetch=self.stub) + self.assertEqual(block["minimum"], "29.7.0") + self.assertEqual(block["cves"], ["CVE-2026-17106"]) + + def test_a_page_where_no_security_release_names_an_identifier_fails_closed(self) -> None: + # Hardening-only releases are skipped, but a page on which every + # Security subsection lacks an identifier means the wording changed and + # the extractor is reading nothing. Fail closed rather than publish a + # minimum from a page it no longer understands. page = ( '## 29.9.0\n\n{{< release-date date="2026-09-01" >}}\n\n' "### Security\n\n- Hardening only, identifiers withheld.\n" @@ -772,6 +797,7 @@ def test_a_security_release_without_identifiers_fails_closed(self) -> None: with self.assertRaises(fr.MinimumRefreshError) as caught: fr.docker_minimums(fetch=self.stub) self.assertIn("names no CVE or GHSA identifier", str(caught.exception)) + self.assertIn("29.9.0", str(caught.exception)) def test_a_security_release_without_a_date_fails_closed(self) -> None: page = (