Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions cmax/minimum_refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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))
Expand All @@ -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; "
Expand Down
28 changes: 27 additions & 1 deletion tests/audit/test_minimum_refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 = (
Expand Down
Loading