diff --git a/.github/actions/deploy-core/tests/check-image-pin.test.sh b/.github/actions/deploy-core/tests/check-image-pin.test.sh new file mode 100755 index 00000000..d363c2b9 --- /dev/null +++ b/.github/actions/deploy-core/tests/check-image-pin.test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Unit tests for the build-container digest-pin validator (spec §3.6/§5). The +# validator must accept a sha256-digest-pinned image.json and FAIL CLOSED on a +# tag, a missing digest, or malformed JSON. +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +CHECK="$DIR/../../../docker/build-app-cli/check-image-pin.sh" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" + fail=$((fail + 1)) +} +run() { bash "$CHECK" "$1" >/dev/null 2>&1; } + +echo "== build container image.json digest-pin validator ==" + +printf '{"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/ok.json" +if run "$WORK/ok.json"; then ok "a digest-pinned reference passes"; else no "a digest-pinned reference passes"; fi + +printf '{"repository":"ghcr.io/x","tag":"v1","digest":"v1"}\n' >"$WORK/tag.json" +if run "$WORK/tag.json"; then no "a non-digest (tag) reference is rejected"; else ok "a non-digest (tag) reference is rejected"; fi + +printf '{"repository":"ghcr.io/x","tag":"v1","digest":"sha256:deadbeef"}\n' >"$WORK/short.json" +if run "$WORK/short.json"; then no "a short/invalid digest is rejected"; else ok "a short/invalid digest is rejected"; fi + +printf '{"repository":"ghcr.io/x","tag":"v1"}\n' >"$WORK/nodigest.json" +if run "$WORK/nodigest.json"; then no "a missing digest is rejected"; else ok "a missing digest is rejected"; fi + +printf '{"tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/norepo.json" +if run "$WORK/norepo.json"; then no "a missing repository is rejected"; else ok "a missing repository is rejected"; fi + +printf 'not json\n' >"$WORK/bad.json" +if run "$WORK/bad.json"; then no "malformed JSON fails closed"; else ok "malformed JSON fails closed"; fi + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/.github/docker/build-app-cli/check-image-pin.sh b/.github/docker/build-app-cli/check-image-pin.sh new file mode 100755 index 00000000..be6e068b --- /dev/null +++ b/.github/docker/build-app-cli/check-image-pin.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Fail-closed: the build container reference must be pinned by a sha256 manifest +# digest, never a mutable tag (spec docs/specs/edgezero-deploy-build-caching.md +# §3.6/§5). image.json records the canonical repository, tag, and pinned digest; +# the rest of the build-caching feature keys `platform-id` on that digest, so a +# non-digest or malformed pin must never pass. +# +# Usage: check-image-pin.sh +set -euo pipefail + +file="${1:?usage: check-image-pin.sh }" + +if ! command -v jq >/dev/null 2>&1; then + echo "::error::check-image-pin.sh requires jq" >&2 + exit 2 +fi + +# FAIL CLOSED on unreadable JSON: a file jq cannot parse must be rejected, never +# silently passed. +if ! json=$(jq -e . "$file" 2>/dev/null); then + echo "::error::$file is not valid JSON — refusing to pass an unreadable image pin" >&2 + exit 1 +fi + +repo=$(jq -r '.repository // empty' <<<"$json") +tag=$(jq -r '.tag // empty' <<<"$json") +digest=$(jq -r '.digest // empty' <<<"$json") + +if [[ -z "$repo" || -z "$tag" ]]; then + echo "::error::$file must set a non-empty string 'repository' and 'tag'" >&2 + exit 1 +fi + +# A sha256 manifest digest, never a tag. +if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::$file 'digest' must be a sha256 manifest digest (sha256:<64-hex>), not a tag: '$digest'" >&2 + exit 1 +fi + +echo "build container reference is pinned: $repo@$digest"