From 537b26c09433f717963f867a0f5a82adf7a398ee Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Sat, 22 Aug 2026 15:43:25 -0500 Subject: [PATCH] ci: validate the feed against the channel it describes The feed is a set of claims about published artifacts and nothing checked them. On 2026-08-21 a merge of the updates-test branch into this one replaced the GA bundle checksums with the testing channel's and added upgrade hops through releases that never shipped. Nothing here went red. The break surfaced hours later in simplerisk/docker, whose image build verifies the bundle against this feed and fails closed -- blocking every PR in that repo. Asserts the claims against reality: - every manifest parses, and carries no DTD (these are generated files; a DOCTYPE would only ever be an entity-expansion attack on the runner) - Current_Version names a release that exists and publishes a checksum - every release publishing a bundle_sha256 has a bundle actually fetchable on this channel, hashing to exactly that value - upgrade_path routes only to releases that exist, and agrees with each release's next_release The checked set is deliberately "entries making a claim", not "all entries". A historical release carries no checksum and its bundle is long gone from the channel -- that is correct and unremarkable. Publishing a checksum is what makes a claim checkable, so that is the trigger. Only 2 of 111 entries qualify today, so this downloads two bundles. Verified against both states: FAILS on a657849e with exactly the 4 real defects and no false positives, PASSES on the #141 fix. Worth making a required status check on both feed branches. Unlike a branch restriction it catches a bad write however it arrives -- branch merge, hand edit, or a broken generator run. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/validate-feed.yml | 61 ++++++++ scripts/validate_feed.py | 215 ++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 .github/workflows/validate-feed.yml create mode 100755 scripts/validate_feed.py diff --git a/.github/workflows/validate-feed.yml b/.github/workflows/validate-feed.yml new file mode 100644 index 0000000..2034db1 --- /dev/null +++ b/.github/workflows/validate-feed.yml @@ -0,0 +1,61 @@ +name: Validate feed + +# The feed is a set of CLAIMS about published artifacts, and until now nothing +# checked them. On 2026-08-21 a merge of the updates-test branch into this one +# replaced the GA bundle checksums with the testing channel's, and added upgrade +# hops through releases that never shipped. Nothing here went red; the break +# surfaced hours later in simplerisk/docker, whose image build verifies the +# bundle against this feed and fails closed. +# +# This asserts the claims against the channel the branch describes: +# - every manifest parses (and carries no DTD) +# - Current_Version names a release that exists and publishes a checksum +# - every release publishing a bundle_sha256 has a bundle that is actually +# fetchable on this channel, hashing to exactly that value +# - upgrade_path only routes to releases that exist, and agrees with the +# per-release next_release +# +# Cheap by construction: only the handful of currently-served releases publish a +# checksum, so this downloads two bundles, not a hundred. +# +# Make this a REQUIRED status check on both feed branches — it is the control +# that catches a bad write regardless of how it arrived (branch merge, hand +# edit, or a broken generator run). + +on: + pull_request: + branches: [updates.simplerisk.com, updates-test.simplerisk.com] + push: + branches: [updates.simplerisk.com, updates-test.simplerisk.com] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: validate-feed-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate_feed: + name: Validate feed against its channel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Resolve channel from the target branch + env: + # On a PR this must be the BASE branch: the head is a topic branch + # whose name says nothing about which channel it will land on. + TARGET: ${{ github.event_name == 'pull_request' && github.base_ref || github.ref_name }} + run: | + set -euo pipefail + case "$TARGET" in + updates.simplerisk.com) echo "CHANNEL=prod" >> "$GITHUB_ENV" ;; + updates-test.simplerisk.com) echo "CHANNEL=test" >> "$GITHUB_ENV" ;; + *) echo "::error::unexpected target branch '$TARGET'"; exit 1 ;; + esac + echo "validating against the $TARGET channel" + + - name: Validate + run: python3 scripts/validate_feed.py "$CHANNEL" diff --git a/scripts/validate_feed.py b/scripts/validate_feed.py new file mode 100755 index 0000000..c34e544 --- /dev/null +++ b/scripts/validate_feed.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Validate the update feed against the channel it describes. + +The feed is a set of CLAIMS about published artifacts. Nothing checked those +claims, so a bad write went unnoticed until a consumer that verifies (the +simplerisk/docker image build, which fails closed) hit it hours later. This +asserts the claims against reality. + +The invariant, stated precisely: + + Every release REACHABLE as an upgrade target must have a bundle that is + actually fetchable on this channel, and the feed's checksum for it must + match those bytes. + +"Reachable" = Current_Version, plus every hop target in upgrade_path.xml. A +superseded release that nothing routes to may legitimately have no bundle left +on the channel — each channel serves only what it currently needs — so it is not +checked. What must never happen is routing an upgrade at a bundle that is not +there, or publishing a checksum that does not match the bytes served. + +Usage: validate_feed.py # channel: prod | test +Exit 0 = valid, 1 = invalid, 2 = could not complete the check. +""" +import hashlib +import re +import sys +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET + +FILES = ["releases.xml", "Current_Version.xml", "upgrade_path.xml", + "announcements.xml", "extra_compatibility.xml"] + +BUNDLE_URL = { + "prod": "https://simplerisk-downloads.s3.amazonaws.com/public/bundles/simplerisk-{v}.tgz", + "test": "https://bundles-test.simplerisk.com/simplerisk-{v}.tgz", +} + +VERSION_RE = re.compile(r"^\d{8}-\d{3}$") +SHA_RE = re.compile(r"^[0-9a-f]{64}$") + +errors: list[str] = [] +notes: list[str] = [] + + +def err(msg: str) -> None: + errors.append(msg) + print(f"::error::{msg}") + + +def note(msg: str) -> None: + notes.append(msg) + print(f" {msg}") + + +DOCTYPE_RE = re.compile(rb" None: + """Every manifest must parse. A truncated write breaks every consumer. + + ElementTree resolves no external entities, but it does expand internal ones, + so a DOCTYPE could hang the runner (billion laughs). These manifests are + generated and never legitimately carry a DTD, so reject one outright rather + than take a dependency on defusedxml, which runners do not ship. + """ + for f in FILES: + try: + with open(f, "rb") as fh: + head = fh.read(65536) + except FileNotFoundError: + err(f"{f} is missing") + continue + if DOCTYPE_RE.search(head): + err(f"{f} contains a DOCTYPE/ENTITY declaration; these manifests must not") + continue + try: + ET.parse(f) + note(f"OK {f} parses") + except ET.ParseError as e: + err(f"{f} is not well-formed XML: {e}") + + +def releases() -> dict[str, dict]: + """version -> {sha256, md5, next_release} for every entry.""" + out = {} + root = ET.parse("releases.xml").getroot() + for rel in root.findall("release"): + v = rel.get("version", "") + text = lambda t: (rel.findtext(t) or "").strip() + out[v] = { + "sha256": text("bundle_sha256"), + "md5": text("bundle_md5"), + "next_release": text("next_release"), + } + return out + + +def upgrade_hops() -> dict[str, str]: + """from-version -> to-version, from upgrade_path.xml.""" + hops = {} + root = ET.parse("upgrade_path.xml").getroot() + for child in root: + m = re.fullmatch(r"simplerisk-(\d{8}-\d{3})", child.tag) + if not m: + err(f"upgrade_path.xml: unexpected element <{child.tag}>") + continue + target = (child.text or "").strip() + if target: # an empty element marks the newest release (no next hop) + hops[m.group(1)] = target + return hops + + +def fetch(url: str): + """-> (bytes, None) or (None, reason). Never raises.""" + try: + with urllib.request.urlopen(url, timeout=300) as r: + return r.read(), None + except urllib.error.HTTPError as e: + # S3 returns 403, not 404, for a missing object under an + # anonymous-list-denied bucket. Both mean "not published here". + return None, f"HTTP {e.code}" + (" (absent)" if e.code in (403, 404) else "") + except Exception as e: # noqa: BLE001 - network is the point of failure here + return None, str(e) + + +def main() -> int: + if len(sys.argv) != 2 or sys.argv[1] not in BUNDLE_URL: + print("usage: validate_feed.py ", file=sys.stderr) + return 2 + channel = sys.argv[1] + + print(f"== well-formedness") + wellformed() + if errors: + return 1 # nothing else is meaningful against unparseable XML + + rel = releases() + hops = upgrade_hops() + current = (ET.parse("Current_Version.xml").getroot().findtext("appversion") or "").strip() + + print(f"\n== structure ({len(rel)} releases, {len(hops)} upgrade hops, current={current})") + if not VERSION_RE.match(current): + err(f"Current_Version/appversion '{current}' is not a YYYYMMDD-NNN version") + elif current not in rel: + err(f"Current_Version {current} has no entry in releases.xml") + else: + note(f"OK Current_Version {current} has a release entry") + + # Referential integrity: never route an upgrade at a version we do not describe. + for src, dst in sorted(hops.items()): + if dst not in rel: + err(f"upgrade_path routes {src} -> {dst}, which has no entry") + if not any(d not in rel for d in hops.values()): + note("OK every upgrade_path target has a release entry") + + # next_release must agree with upgrade_path, or consumers disagree about + # where to go depending on which file they read. + mismatched = 0 + for src, dst in sorted(hops.items()): + declared = rel.get(src, {}).get("next_release", "") + if declared and declared != dst: + err(f"{src}: next_release={declared} but upgrade_path says {dst}") + mismatched += 1 + if not mismatched: + note("OK next_release agrees with upgrade_path everywhere") + + # WHAT GETS CHECKED AGAINST THE CHANNEL, and why it is this set: + # + # A release entry publishing a real bundle_sha256 is making a checkable + # claim -- "this bundle exists here and hashes to this". Every such claim is + # verified. Historical entries carry no hash (only a handful ever do, since + # a channel serves only what it currently needs) and make no claim, so there + # is nothing to check and their bundles being long gone is expected. + # + # The current release is checked additionally for the PRESENCE of a hash: it + # is the one release consumers actually download, so an unverifiable current + # release is a defect even though an unverifiable 2015 release is not. + claimants = sorted((v for v, d in rel.items() if SHA_RE.match(d["sha256"])), reverse=True) + if current and current not in claimants and current in rel: + err(f"the current release {current} publishes no bundle_sha256 — " + f"consumers cannot verify the bundle they are told to download") + print(f"\n== bundles claimed by the feed ({len(claimants)}: {', '.join(claimants) or 'none'})") + + for v in claimants: + want = rel[v]["sha256"] + url = BUNDLE_URL[channel].format(v=v) + blob, why = fetch(url) + if blob is None: + err(f"{v} publishes a bundle_sha256 but its bundle is not fetchable " + f"on the {channel} channel ({why}): {url}") + continue + got = hashlib.sha256(blob).hexdigest() + if got != want: + err(f"{v} bundle_sha256 mismatch on {channel}: feed says {want}, " + f"the served bytes hash to {got}") + continue + md5_want = rel[v]["md5"] + if re.fullmatch(r"[0-9a-f]{32}", md5_want): + md5_got = hashlib.md5(blob).hexdigest() # noqa: S324 - feed field, not security + if md5_got != md5_want: + err(f"{v} bundle_md5 mismatch on {channel}: feed says {md5_want}, got {md5_got}") + continue + note(f"OK {v} bundle verified against the {channel} channel ({got[:16]}…)") + + print() + if errors: + print(f"FAILED — {len(errors)} problem(s)") + return 1 + print(f"PASSED — {len(notes)} checks") + return 0 + + +if __name__ == "__main__": + sys.exit(main())