From f00352a659a0cb680431bbe4bd7f699a5f974af9 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 15 Aug 2026 01:37:32 +0000 Subject: [PATCH] fix: verify digest + cosign signature before pulling/restarting anything Companion to argus-appliance issue #88. update.sh no longer trusts the version manifest alone -- before pulling or restarting any service, it independently resolves each image's live registry digest and verifies it matches what the release pipeline recorded, then cosign-verifies the signature against the committed public key. Aborts (installation untouched) on any mismatch, missing tool, or ambiguity. --- argus-images.pub | 4 +++ update.sh | 94 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 argus-images.pub diff --git a/argus-images.pub b/argus-images.pub new file mode 100644 index 0000000..c793b23 --- /dev/null +++ b/argus-images.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWy1RyNllxNrNXsU1Ld/muB/EEpdg +uOPDHZfqYGC6eZgFT79LdG05syTHTFS84MMNcMN0pBikc3n9rwImKeEb+A== +-----END PUBLIC KEY----- diff --git a/update.sh b/update.sh index b030144..ead4cc5 100755 --- a/update.sh +++ b/update.sh @@ -6,18 +6,31 @@ # (or, if you don't have it locally yet) # curl -fsSL https://git-cloud.weboria.eu/weboria/argus-installer/raw/branch/main/update.sh | bash # +# Requires: docker (with the buildx plugin, bundled by default in current +# Docker/docker.io packages) and cosign (https://docs.sigstore.dev/cosign/ +# installation/) on PATH. Both are used only to VERIFY what's about to be +# installed, before anything is pulled or restarted -- see below. +# # ADR-0051 (argus-appliance repo): this pulls a specific, pinned version -- # never :latest. :latest is three independently-mutable registry tags with # no cross-image transaction; a version resolved from latest-version.json # is a single atomically-committed pointer that the release pipeline only -# ever writes AFTER every image it names has finished promoting. Reading -# :latest directly (the old behavior here) could observe a torn set mid- -# promotion; reading the manifest first cannot, because by the time it -# names a version, that version's images are already guaranteed complete. +# ever writes AFTER every image it names has finished promoting AND passed +# scripts/verify-release.sh's full release-gate (argus-appliance repo's +# release.yml `release-gate` job) -- so the mere presence of a version in +# this manifest already means it passed CI-side verification. This script +# does NOT re-trust that alone: 2026-08-15 incident (see argus-appliance's +# docs/adr/0051-...md incident note) established that neither this script +# nor the Appliance GUI may ever treat a version as installable without +# independently re-verifying it here, at the point of actual install -- +# a bug in the gate, a compromised pointer, or a torn registry push must +# still be caught here, not merely trusted upstream. set -euo pipefail INSTALL_DIR="${ARGUS_INSTALL_DIR:-/opt/argus}" MANIFEST_URL="https://git-cloud.weboria.eu/weboria/argus-installer/raw/branch/main/latest-version.json" +COSIGN_PUBKEY_URL="https://git-cloud.weboria.eu/weboria/argus-installer/raw/branch/main/argus-images.pub" +IMAGE_PREFIX="git-cloud.weboria.eu/weboria/argus" bold() { printf '\033[1m%s\033[0m\n' "$1"; } info() { printf ' %s\n' "$1"; } @@ -49,6 +62,79 @@ if [ "$BEFORE_VERSION" = "$TARGET_VERSION" ]; then exit 0 fi +# ------------------------------------------------------------------ +# Fail-safe re-verification, BEFORE anything is pulled or restarted. +# +# The manifest's mere existence already implies it passed the CI-side +# release gate (see header comment) -- but this script does not stop +# there. It independently re-derives, from the registry and the committed +# public key alone (no Gitea credentials needed or used), that what it's +# about to install is really what the release pipeline promoted: the +# live image digest must match what the manifest recorded, AND that +# digest must carry a valid cosign signature. Any failure, missing tool, +# missing data, or ambiguity aborts here -- nothing is pulled, nothing is +# restarted, the running installation is untouched. +# ------------------------------------------------------------------ +bold "Verifying ${TARGET_VERSION} before installing anything..." + +command -v docker >/dev/null 2>&1 || fail "docker not found on PATH -- cannot verify the release. Nothing was changed." +command -v cosign >/dev/null 2>&1 || fail "cosign not found on PATH (see https://docs.sigstore.dev/cosign/installation/) -- signature verification is required before install, not optional. Nothing was changed." + +COSIGN_PUBKEY_FILE="$(mktemp)" +trap 'rm -f "$COSIGN_PUBKEY_FILE"' EXIT +curl -fsS "$COSIGN_PUBKEY_URL" -o "$COSIGN_PUBKEY_FILE" 2>/dev/null \ + || fail "Could not fetch the image signing public key ($COSIGN_PUBKEY_URL). Nothing was changed -- your installation is untouched." +[ -s "$COSIGN_PUBKEY_FILE" ] || fail "Fetched signing public key was empty. Nothing was changed." + +manifest_image_digest() { + printf '%s' "$MANIFEST" | python3 -c " +import json, sys +try: + print(json.load(sys.stdin).get('images', {}).get('$1', '')) +except Exception: + print('') +" 2>/dev/null +} + +VERIFY_FAILED=false +for svc_component in "api:api" "ui:ui" "nginx:proxy"; do + svc="${svc_component%%:*}" + component="${svc_component##*:}" + IMG="${IMAGE_PREFIX}-${component}:${TARGET_VERSION}" + + RECORDED_DIGEST="$(manifest_image_digest "$component")" + if [ -z "$RECORDED_DIGEST" ] || [ "$RECORDED_DIGEST" = "null" ]; then + info " ✗ ${svc}: manifest has no recorded digest for ${TARGET_VERSION} -- cannot verify" + VERIFY_FAILED=true + continue + fi + + ACTUAL_DIGEST="$(docker buildx imagetools inspect "$IMG" --format '{{json .Manifest.Digest}}' 2>/dev/null | tr -d '"' || true)" + if [ -z "$ACTUAL_DIGEST" ] || [ "$ACTUAL_DIGEST" = "null" ]; then + info " ✗ ${svc}: could not resolve ${IMG} in the registry" + VERIFY_FAILED=true + continue + fi + if [ "$ACTUAL_DIGEST" != "$RECORDED_DIGEST" ]; then + info " ✗ ${svc}: registry digest ${ACTUAL_DIGEST} does not match manifest-recorded ${RECORDED_DIGEST}" + VERIFY_FAILED=true + continue + fi + + if ! cosign verify --key "$COSIGN_PUBKEY_FILE" "${IMAGE_PREFIX}-${component}@${ACTUAL_DIGEST}" >/dev/null 2>&1; then + info " ✗ ${svc}: signature verification failed for ${ACTUAL_DIGEST}" + VERIFY_FAILED=true + continue + fi + + info " ✓ ${svc}: digest ${ACTUAL_DIGEST} matches manifest and signature verified" +done + +if [ "$VERIFY_FAILED" = "true" ]; then + fail "Release verification failed for ${TARGET_VERSION} -- treating this update as unavailable. Nothing was pulled or changed. This is not a transient error to retry blindly; investigate before trying again." +fi +ok "Release ${TARGET_VERSION} independently verified (digest + signature) for every service." + # Pinned, not :latest -- see the header comment. Exported only for this # script's own `docker compose` invocations below, not written to .env, so # a later manual `docker compose pull` by the operator still defaults to