From 66987a55f4e244c96f849ba78a92618f5304de51 Mon Sep 17 00:00:00 2001 From: Scott Robinson Date: Mon, 31 Aug 2026 04:30:36 +0000 Subject: [PATCH] ci: continuous drift verification of released artifacts A daily anonymous audit of everything the registries owe users: every released version tag resolves to its recorded digest, every signature cryptographically verifies against the committed key (transparency log included where entries exist), and latest points at the highest release shipped to each registry. Reachable-and-wrong (drift) is distinguished from unreachable (outage); alerting is a self-resolving registry-drift issue rather than author-only run emails. Logic lives in devtools/ and runs identically locally: the full 34-check matrix passes against the live registries, and a corrupted-digest negative test fails with DRIFT as required. --- .github/workflows/registry-drift.yml | 102 ++++++++++++++++++++++++ devtools/verify-released-artifacts | 114 +++++++++++++++++++++++++++ docker/released-artifacts.json | 30 +++++++ 3 files changed, 246 insertions(+) create mode 100644 .github/workflows/registry-drift.yml create mode 100755 devtools/verify-released-artifacts create mode 100644 docker/released-artifacts.json diff --git a/.github/workflows/registry-drift.yml b/.github/workflows/registry-drift.yml new file mode 100644 index 00000000..a6adc476 --- /dev/null +++ b/.github/workflows/registry-drift.yml @@ -0,0 +1,102 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +# +# Continuous drift verification for released container artifacts. +# +# Daily, and on demand: every released version tag, signature, and `latest` +# on every registry must still be exactly what was released, per +# docker/released-artifacts.json. All checks are ANONYMOUS reads — this +# verifies what users see, holds no credentials, and needs no environment. +# Logic lives in devtools/verify-released-artifacts so it runs identically +# on a workstation. +# +# Alerting: scheduled-run failure emails go only to the workflow author, so +# drift instead creates (or comments on) a 'registry-drift' issue with the +# failing report, and a clean run closes any open ones. DRIFT (reachable +# and wrong) and UNREACHABLE (registry errors after retries) are labeled +# distinctly — an outage is not a security event. + +name: registry-drift + +on: + schedule: + - cron: '17 6 * * *' + workflow_dispatch: + +permissions: + contents: read + issues: write # drift alerting only; no registry credentials exist here + +concurrency: + group: registry-drift + cancel-in-progress: false + +env: + COSIGN_VERSION: v3.1.3 + COSIGN_SHA256: 4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 + CRANE_VERSION: v0.21.9 + CRANE_SHA256: 5c16d8ddb971cb1d5e6ed8b1e743da8224414eeba2c2762d8f1a61b2f095699e + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Install pinned crane and cosign (checksum-verified) + run: | + set -euo pipefail + curl -sSfL -o /tmp/crane.tgz \ + "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" + echo "${CRANE_SHA256} /tmp/crane.tgz" | sha256sum --check --strict + tar -xzf /tmp/crane.tgz -C /tmp crane + sudo install -m 0755 /tmp/crane /usr/local/bin/crane + curl -sSfL -o /tmp/cosign \ + "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" + echo "${COSIGN_SHA256} /tmp/cosign" | sha256sum --check --strict + sudo install -m 0755 /tmp/cosign /usr/local/bin/cosign + + - name: Verify all released artifacts (anonymous) + id: verify + run: | + set +e + ./devtools/verify-released-artifacts | tee /tmp/report.txt + echo "rc=$?" >> "$GITHUB_OUTPUT" + + - name: Alert on drift or persistent unreachability + if: steps.verify.outputs.rc != '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RC: ${{ steps.verify.outputs.rc }} + run: | + set -euo pipefail + KIND=$([[ "$RC" == "1" ]] && echo "DRIFT DETECTED" || echo "registries unreachable") + TITLE="registry-drift: $KIND" + BODY_FILE=/tmp/issue.md + { + echo "Automated report from the registry-drift verification run: $KIND" + echo + echo '```' + cat /tmp/report.txt + echo '```' + echo + echo "Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } > "$BODY_FILE" + EXISTING=$(gh issue list --label registry-drift --state open --json number --jq '.[0].number // empty') + if [[ -n "$EXISTING" ]]; then + gh issue comment "$EXISTING" --body-file "$BODY_FILE" + else + gh issue create --title "$TITLE" --label registry-drift --body-file "$BODY_FILE" + fi + exit 1 + + - name: Close drift issues on a clean run + if: steps.verify.outputs.rc == '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + for n in $(gh issue list --label registry-drift --state open --json number --jq '.[].number'); do + gh issue close "$n" --comment "Clean verification run: all released artifacts intact again. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + done diff --git a/devtools/verify-released-artifacts b/devtools/verify-released-artifacts new file mode 100755 index 00000000..a30fa195 --- /dev/null +++ b/devtools/verify-released-artifacts @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +# +# Continuous drift verification: every released artifact, on every registry +# it shipped to, must still be exactly what was released. +# +# Reads docker/released-artifacts.json and asserts, entirely through +# ANONYMOUS reads (what users see, with nothing to steal): +# +# 1. every released version tag resolves to its recorded digest +# 2. the Cosign signature tag exists and cryptographically VERIFIES +# against the committed release public key (with the transparency log +# for releases that have entries; --insecure-ignore-tlog only for the +# two pre-Rekor releases) +# 3. `latest` on each registry points at the highest release shipped to +# that registry (the forward-only invariant, audited) +# +# Failure semantics: DRIFT (reachable and wrong) and UNREACHABLE (still +# failing after retries) both exit nonzero, but are labeled separately — +# a registry outage is not a security event. Transient errors are absorbed +# by bounded retries so a blip cannot cry wolf. +# +# Gates nothing, fixes nothing, writes nothing. Output is the signal. + +set -uo pipefail +exec < /dev/null + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$(cd "$SCRIPT_DIR/.." && pwd)" + +MANIFEST=docker/released-artifacts.json +for tool in crane cosign jq; do + command -v "$tool" >/dev/null || { echo "error: $tool is required" >&2; exit 2; } +done +PUBKEY=$(jq -r .public_key "$MANIFEST") +[[ -s "$PUBKEY" ]] || { echo "error: public key $PUBKEY missing" >&2; exit 2; } + +DRIFT=0 +UNREACHABLE=0 + +# Bounded-retry digest read. Prints digest; rc 0 = read, rc 9 = definitely +# absent, rc 1 = unreachable after retries. +read_digest() { + local ref="$1" out + for _ in 1 2 3; do + if out=$(crane digest "$ref" 2>&1); then + if [[ "$out" =~ ^sha256:[0-9a-f]{64}$ ]]; then printf '%s' "$out"; return 0; fi + elif grep -qiE 'manifest unknown|name unknown|not found' <<< "$out"; then + return 9 + fi + sleep 10 + done + return 1 +} + +check() { # check