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.
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWy1RyNllxNrNXsU1Ld/muB/EEpdg
|
||||||
|
uOPDHZfqYGC6eZgFT79LdG05syTHTFS84MMNcMN0pBikc3n9rwImKeEb+A==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -6,18 +6,31 @@
|
|||||||
# (or, if you don't have it locally yet)
|
# (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
|
# 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 --
|
# ADR-0051 (argus-appliance repo): this pulls a specific, pinned version --
|
||||||
# never :latest. :latest is three independently-mutable registry tags with
|
# never :latest. :latest is three independently-mutable registry tags with
|
||||||
# no cross-image transaction; a version resolved from latest-version.json
|
# no cross-image transaction; a version resolved from latest-version.json
|
||||||
# is a single atomically-committed pointer that the release pipeline only
|
# is a single atomically-committed pointer that the release pipeline only
|
||||||
# ever writes AFTER every image it names has finished promoting. Reading
|
# ever writes AFTER every image it names has finished promoting AND passed
|
||||||
# :latest directly (the old behavior here) could observe a torn set mid-
|
# scripts/verify-release.sh's full release-gate (argus-appliance repo's
|
||||||
# promotion; reading the manifest first cannot, because by the time it
|
# release.yml `release-gate` job) -- so the mere presence of a version in
|
||||||
# names a version, that version's images are already guaranteed complete.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
INSTALL_DIR="${ARGUS_INSTALL_DIR:-/opt/argus}"
|
INSTALL_DIR="${ARGUS_INSTALL_DIR:-/opt/argus}"
|
||||||
MANIFEST_URL="https://git-cloud.weboria.eu/weboria/argus-installer/raw/branch/main/latest-version.json"
|
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"; }
|
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
||||||
info() { printf ' %s\n' "$1"; }
|
info() { printf ' %s\n' "$1"; }
|
||||||
@@ -49,6 +62,79 @@ if [ "$BEFORE_VERSION" = "$TARGET_VERSION" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
# Pinned, not :latest -- see the header comment. Exported only for this
|
||||||
# script's own `docker compose` invocations below, not written to .env, so
|
# script's own `docker compose` invocations below, not written to .env, so
|
||||||
# a later manual `docker compose pull` by the operator still defaults to
|
# a later manual `docker compose pull` by the operator still defaults to
|
||||||
|
|||||||
Reference in New Issue
Block a user