From 65b4781bd29be206eb248cd4d0744d8ce54a03fa Mon Sep 17 00:00:00 2001 From: purpleclay Date: Sat, 1 Aug 2026 07:27:26 +0100 Subject: [PATCH] fix: require zig mirrors to be both live upstream and reviewed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #31 `compose_build_matrix` previously allowlisted every host `community-mirrors.txt` currently listed, straight from a live fetch at build time — mutable, externally-maintained content with zero review on our side. A compromised or maliciously-edited upstream entry would have been automatically trusted as a build-script egress target. Now takes the intersection of that live list with a reviewed, repository-owned .github/zig-mirrors.txt: a mirror disappearing upstream drops out with no PR needed; a new one only becomes trusted once someone reviews and commits it here. Signed-off-by: purpleclay --- .github/workflows/release-rust.yml | 124 ++++++++++++++++++++++++----- .github/zig-mirrors.md | 40 ++++++++++ .github/zig-mirrors.txt | 16 ++++ .github/zizmor.yml | 8 +- .gitignore | 3 + flake.nix | 1 + typos.toml | 12 +++ 7 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 .github/zig-mirrors.md create mode 100644 .github/zig-mirrors.txt create mode 100644 typos.toml diff --git a/.github/workflows/release-rust.yml b/.github/workflows/release-rust.yml index e3a6cb3..4f92e1d 100644 --- a/.github/workflows/release-rust.yml +++ b/.github/workflows/release-rust.yml @@ -71,8 +71,49 @@ jobs: uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: block + # The reviewed mirror hosts below (all of .github/zig-mirrors.txt) + # must be probed for liveness before one can be pinned into the + # build matrix (see compose_build_matrix) — this list can't be + # derived from that file at runtime, since harden_runner is the + # very first step and runs before checkout_release_workflows. + # Keep in sync by hand: adding a mirror to zig-mirrors.txt means + # adding its host here too, or the probe can never reach it. allowed-endpoints: > + github.com:443 ziglang.org:443 + fs.liujiacai.net:443 + pkg.earth:443 + pkg.hexops.org:443 + zig-mirror.tsimnet.eu:443 + zig.bcr.ist:443 + zig.chainsafe.dev:443 + zig.karearl.com:443 + zig.linus.dev:443 + zig.mirror.mschae23.de:443 + zig.savalione.com:443 + zig.squirl.dev:443 + zig.tilok.dev:443 + zig.vortan.dev:443 + ziglang.freetls.fastly.net:443 + zigmirror.com:443 + zigmirror.hryx.net:443 + + - name: checkout_release_workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # This reusable workflow's own repo content (the reviewed mirror + # list, see #31) — not the caller's, which is what a bare + # checkout would default to, and not github.workflow_sha either + # (that identifies the *caller's* workflow file, which won't + # exist as a commit in this repo at all). job.workflow_repository + # + job.workflow_sha are the ones documented for a reusable + # workflow checking out its own source. + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + persist-credentials: false + sparse-checkout: | + .github/zig-mirrors.txt + sparse-checkout-cone-mode: false - name: require_tag_ref env: @@ -99,20 +140,67 @@ jobs: # zigbuild legs additionally need taiki-e/install-action fetching # the cargo-zigbuild binary from GitHub releases, and zig itself. - # mlugg/setup-zig picks a mirror at random per run (confirmed: - # two real runs picked two different ones), so rather than - # allowlisting whichever one we happened to observe, fetch the - # complete, authoritative list it draws from and allow all of it, - # plus ziglang.org as the documented last-resort fallback. - zig_mirrors=$(curl -fsSL https://ziglang.org/download/community-mirrors.txt \ - | sed -E 's#^https://##; s#/.*$##' \ - | sed 's/$/:443/' \ - | tr '\n' ' ') - zigbuild_endpoints="${common_endpoints} release-assets.githubusercontent.com:443 ziglang.org:443 ${zig_mirrors}" + # community-mirrors.txt is mutable, externally maintained content, + # not something we've reviewed; trusting it directly would let an + # upstream compromise silently authorize a new build-time egress + # target with no review on our side (#31). Only trust a mirror + # that's BOTH currently live upstream AND already reviewed into + # .github/zig-mirrors.txt: a mirror going away upstream drops out + # with no PR needed; a new one only becomes trusted once someone + # reviews and commits it. Compare full base URLs, not just + # hostnames — a path change on an already-approved host (e.g. a + # hijacked /zig subpath) is exactly the kind of unreviewed change + # this is meant to catch, and it would slip through a + # hostname-only comparison. + live_mirrors=$(curl -fsSL https://ziglang.org/download/community-mirrors.txt \ + | sed -E 's#/+$##' | sort -u) + reviewed_mirrors=$(sed -E 's#/+$##' .github/zig-mirrors.txt | sort -u) + approved_mirrors=$(comm -12 <(echo "$live_mirrors") <(echo "$reviewed_mirrors")) + + if [ -z "$approved_mirrors" ]; then + echo "::error::no zig mirrors are both live upstream and reviewed in .github/zig-mirrors.txt — the reviewed list has likely gone stale, update it before retrying" + exit 1 + fi + + # Pin one specific, reviewed mirror for setup_zig's `mirror:` + # override rather than letting it pick at random from the live + # list (which would bypass the review above entirely). Probe + # candidates in random order and commit to the first that actually + # responds for the exact tarball this run needs: when `mirror:` is + # set, setup-zig does not retry or fall back to any other mirror + # on failure (confirmed against its source), so pinning one we + # haven't confirmed is reachable risks failing the whole leg with + # no in-run recovery. Both zigbuild legs run on ubuntu-24.04 + # runners regardless of the cross-compile target, so there's only + # ever one filename to check. Reviewed mirrors serve it flat, at + # / — most do NOT mirror ziglang.org/download's own + # // layout, confirmed by probing all of + # them (9 of 16 404 with a version segment). HEAD, not a ranged + # GET: one reviewed mirror (zigmirror.com) rejects HEAD with 405, + # but a ranged GET fails on more (mirrors that ignore Range and + # stream the whole ~55MB file, timing out) — HEAD has fewer + # false negatives overall across the reviewed set. + zig_tarball="zig-x86_64-linux-${ZIG_VERSION}.tar.xz" + zig_mirror="" + while IFS= read -r candidate; do + if curl -fsSL --head --max-time 10 "${candidate}/${zig_tarball}" >/dev/null 2>&1; then + zig_mirror="$candidate" + break + fi + done <<<"$(printf '%s\n' "$approved_mirrors" | shuf)" + + if [ -z "$zig_mirror" ]; then + echo "::error::none of the approved zig mirrors responded for ${zig_tarball} — check mirror availability or review .github/zig-mirrors.txt" + exit 1 + fi + + zig_mirror_host=$(printf '%s' "$zig_mirror" | sed -E 's#^https://##; s#/.*$##') + zigbuild_endpoints="${common_endpoints} release-assets.githubusercontent.com:443 ${zig_mirror_host}:443" if ! matrix=$(jq -c \ --arg common "$common_endpoints" \ --arg zigbuild_endpoints "$zigbuild_endpoints" \ + --arg zig_mirror "$zig_mirror" \ ' if type != "array" then error("targets must be a JSON array of strings") @@ -125,8 +213,8 @@ jobs: else . end | [ .[] | . as $t | { target: $t } + - ( { "x86_64-unknown-linux-musl": { "runs-on": "ubuntu-24.04", "zigbuild": true, "allowed-endpoints": $zigbuild_endpoints }, - "aarch64-unknown-linux-musl": { "runs-on": "ubuntu-24.04", "zigbuild": true, "allowed-endpoints": $zigbuild_endpoints }, + ( { "x86_64-unknown-linux-musl": { "runs-on": "ubuntu-24.04", "zigbuild": true, "allowed-endpoints": $zigbuild_endpoints, "zig-mirror": $zig_mirror }, + "aarch64-unknown-linux-musl": { "runs-on": "ubuntu-24.04", "zigbuild": true, "allowed-endpoints": $zigbuild_endpoints, "zig-mirror": $zig_mirror }, "x86_64-apple-darwin": { "runs-on": "macos-15", "zigbuild": false, "allowed-endpoints": $common }, "aarch64-apple-darwin": { "runs-on": "macos-15", "zigbuild": false, "allowed-endpoints": $common } }[$t] // error("unsupported target: \($t)") ) @@ -179,12 +267,12 @@ jobs: with: version: ${{ env.ZIG_VERSION }} use-cache: false - # No mirror override: ziglang.org is explicitly disallowed as one - # by the action itself (protects the official site from being - # hammered by CI). Default behaviour — a randomised community - # mirror, falling back to ziglang.org only as a last resort — is - # correct here; see #23 for tracking a move to a pinned mirror + - # egress-policy: block once we have enough audit data. + # Pinned to the specific mirror the plan job already probed and + # reviewed (#31) — without this, setup-zig fetches the live + # community-mirrors.txt itself and picks at random, which neither + # this workflow's review process nor its egress allowlist would + # ever see. + mirror: ${{ matrix.zig-mirror }} - name: install_cargo_zigbuild if: ${{ matrix.zigbuild }} diff --git a/.github/zig-mirrors.md b/.github/zig-mirrors.md new file mode 100644 index 0000000..fab612e --- /dev/null +++ b/.github/zig-mirrors.md @@ -0,0 +1,40 @@ +# Keeping `zig-mirrors.txt` in sync + +`zig-mirrors.txt` is the reviewed set of Zig community mirrors trusted as a build-job egress target for zigbuild legs. `release-rust.yml` only allows a mirror through if it's *both* in this file *and* currently live upstream (see [issue #31](https://github.com/purpleclay/release-workflows/issues/31) for why it isn't just fetched from upstream directly). + +This is a deliberately manual process — a bot opening a routine PR risks training reviewers to rubber-stamp it, which defeats the point of requiring review at all. Automating this is tracked as a possible future issue if the manual upkeep ever becomes a real burden; it isn't expected to. + +## When to check + +There's no fixed schedule. Worth checking when: + +- A zigbuild release fails with "no zig mirrors are both live upstream and reviewed" (`compose_build_matrix`'s hard-failure path) — this means the two lists have diverged enough to share nothing at all, and needs attention immediately. +- Roughly every few months, or whenever you're touching this workflow for another reason anyway. + +## How to check + +```bash +diff <(curl -fsSL https://ziglang.org/download/community-mirrors.txt | sort) \ + <(sort .github/zig-mirrors.txt) +``` + +Lines prefixed `<` are new upstream entries — this is the part that needs actual scrutiny, not a rubber stamp. Lines prefixed `>` are in the committed file but no longer live upstream — safe to drop, no review needed, they can't be reached anyway. + +## Reviewing a new entry + +For each newly-added host, actually verify it before adding it — don't just accept it because it's on the official list. At minimum: + +1. Use the mirror's exact base URL as listed upstream, including its path — the path is part of what's being trusted, not incidental, and `release-rust.yml` compares the full URL when deciding what to approve (see #31). Most reviewed mirrors serve the archive flat, at `/` — unlike `ziglang.org/download` itself, which nests it under `//`; try the flat form first (only a minority of mirrors need the version segment). Download the archive *and* its `.minisig` sibling, then verify against the [Zig Software Foundation's public key](https://ziglang.org/download/) — a `200` response alone proves nothing, since a malicious mirror could serve any tarball it likes: + + ```sh + archive=zig-x86_64-linux-.tar.xz + curl -fsSL "https:///$archive" -o "$archive" + curl -fsSL "https:///$archive.minisig" -o "$archive.minisig" + minisign -Vm "$archive" -P RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U + ``` + + `minisign` only checks that the signature matches the downloaded bytes — it does not check that the signed file is the one you actually asked for. A mirror could substitute the signature and archive for a *different*, still-genuinely-signed release. Verify that manually: the command above prints a `Trusted comment` line containing a `file:` field — confirm it reads exactly `file:zig-x86_64-linux-.tar.xz`, matching the archive name you requested. + +2. Check who operates it, if that's discoverable (project README, DNS WHOIS, whether it's referenced elsewhere in the Zig community). Prefer mirrors run by identifiable people/organizations over anonymous ones. + +Then update `.github/zig-mirrors.txt` to match upstream, **and** add the new host to the `plan` job's `harden_runner` `allowed-endpoints` in `release-rust.yml` — `compose_build_matrix` probes reviewed mirrors for liveness before pinning one, and that probe can't reach a host harden-runner hasn't been told about. Commit both together as a normal PR, reviewed the same as any other change to this workflow. diff --git a/.github/zig-mirrors.txt b/.github/zig-mirrors.txt new file mode 100644 index 0000000..d43254d --- /dev/null +++ b/.github/zig-mirrors.txt @@ -0,0 +1,16 @@ +https://fs.liujiacai.net/zigbuilds +https://pkg.earth/zig +https://pkg.hexops.org/zig +https://zig-mirror.tsimnet.eu/zig +https://zig.bcr.ist +https://zig.chainsafe.dev +https://zig.karearl.com/zig +https://zig.linus.dev/zig +https://zig.mirror.mschae23.de/zig +https://zig.savalione.com +https://zig.squirl.dev +https://zig.tilok.dev +https://zig.vortan.dev/zig +https://ziglang.freetls.fastly.net +https://zigmirror.com +https://zigmirror.hryx.net/zig diff --git a/.github/zizmor.yml b/.github/zizmor.yml index a88cfdc..0174135 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,13 +1,13 @@ rules: superfluous-actions: ignore: - # release-rust.yml:169 — dtolnay/rust-toolchain: explicit toolchain + + # release-rust.yml:250 — dtolnay/rust-toolchain: explicit toolchain + # cross-target install; hand-rolling rustup here would just reimplement # this action with less auditability. - - release-rust.yml:169 + - release-rust.yml:250 stale-action-refs: ignore: - # release-rust.yml:169 — dtolnay/rust-toolchain intentionally ships no + # release-rust.yml:250 — dtolnay/rust-toolchain intentionally ships no # tags: master/stable/beta/nightly are branch aliases by design, so # this pin is the tip of "master", not a stale reference. - - release-rust.yml:169 + - release-rust.yml:250 diff --git a/.gitignore b/.gitignore index ff91a0f..8a37c15 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ !.envrc !.github/renovate.json !.github/workflows/*.yml +!.github/zig-mirrors.md +!.github/zig-mirrors.txt !.github/zizmor.yml !.gitignore !.zed/settings.json @@ -15,6 +17,7 @@ !README.md !RELEASE.md !SECURITY.md +!typos.toml # Recurse through sub-directories applying the same patterns !*/ diff --git a/flake.nix b/flake.nix index 090ffb4..3b319a9 100644 --- a/flake.nix +++ b/flake.nix @@ -52,6 +52,7 @@ buildInputs = [ alejandra + minisign nil typos zizmor diff --git a/typos.toml b/typos.toml new file mode 100644 index 0000000..df4df60 --- /dev/null +++ b/typos.toml @@ -0,0 +1,12 @@ +[files] +# A list of third-party hostnames, not prose — spellcheck will keep +# producing false positives here as mirrors are added/removed over time +# (e.g. "zig.bcr.ist", "zig.squirl.dev" are real domains, not typos). +extend-exclude = [".github/zig-mirrors.txt"] + +[default.extend-words] +# The same mirror hostnames also appear in release-rust.yml's harden_runner +# allowed-endpoints (can't be excluded like zig-mirrors.txt above, since +# that's a real code file). Keep in sync with zig-mirrors.txt. +ist = "ist" +squirl = "squirl"