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())