Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/scripts/recover-app-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Restore missing version aliases only after authenticating the whole original pair.
set -euo pipefail
umask 077
fail() { echo "::error::application recovery refused: $*" >&2; exit 1; }
[[ "${ENABLE_SIGNED_PROMOTION:-}" == true && "${ENABLE_CALLER_PIN:-}" == true ]] || fail 'signed promotion and caller pinning are required'
[[ "${REGISTRY:-}" =~ ^[a-z0-9.-]+(:[0-9]+)?$ ]] || fail 'invalid registry host'
[[ "${VERSION:-}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || fail 'invalid normalized version'
[[ "${RECOVERY_IMAGE_DIGEST:-}" =~ ^sha256:[0-9a-f]{64}$ && "${RECOVERY_MANIFESTS_DIGEST:-}" =~ ^sha256:[0-9a-f]{64}$ ]] || fail 'invalid original digests'
[[ "${RECOVERY_WORKFLOW_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || fail 'original producer must be immutable'
[[ "${RECOVERY_RUN_ID:-}" =~ ^[0-9]+$ && "${RECOVERY_RUN_ATTEMPT:-}" =~ ^[0-9]+$ ]] || fail 'invalid original run identity'
[[ "${SHA:-}" =~ ^[0-9a-f]{40}$ && -n "${REF_NAME:-}" && -n "${REPOSITORY:-}" ]] || fail 'missing source identity'
[[ "${WORKFLOW_REPOSITORY:-}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail 'invalid workflow repository'
[[ "${JOB_WORKFLOW_REF:-}" =~ ^[^@]+@[0-9a-f]{40}$ ]] || fail 'current caller must be immutable'
[[ "${JOB_WORKFLOW_REF%@*}" == "$WORKFLOW_REPOSITORY/.github/workflows/publish-app.yaml" ]] || fail 'unexpected current producer'
[[ "${SERVER_URL:-}" == https://github.com ]] || fail 'unsupported signature authority'
[[ -n "${ACTOR:-}" && -n "${GH_TOKEN:-}" ]] || fail 'missing registry credentials'
name="$(printf '%s' "${IMAGE_NAME:-$REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
[[ "$name/manifests" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$ && "${#name}" -lt 246 ]] || fail 'invalid OCI repositories'
image="$REGISTRY/$name"
artifact="$image/manifests"
original_workflow="$WORKFLOW_REPOSITORY/.github/workflows/publish-app.yaml@$RECOVERY_WORKFLOW_SHA"
annotations=(
-a "devantler.repository=$REPOSITORY"
-a "devantler.source-sha=$SHA"
-a "devantler.source-ref=$REF_NAME"
-a "devantler.version=$VERSION"
-a "devantler.run-id=$RECOVERY_RUN_ID"
-a "devantler.run-attempt=$RECOVERY_RUN_ATTEMPT"
-a "devantler.workflow-ref=$original_workflow"
-a "devantler.image-digest=$RECOVERY_IMAGE_DIGEST"
-a "devantler.manifests-digest=$RECOVERY_MANIFESTS_DIGEST"
)
for target in "$image@$RECOVERY_IMAGE_DIGEST" "$artifact@$RECOVERY_MANIFESTS_DIGEST"; do
cosign verify --certificate-identity "$SERVER_URL/$original_workflow" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com "${annotations[@]}" \
"$target" >/dev/null || fail 'original signature or paired publication claims failed verification'
done
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
accept='application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json'
read_version() {
local repository="$1" expected="$2" status token observed
status="$(curl -q --silent --show-error --proto '=https' --tlsv1.2 \
--connect-timeout 10 --max-time 30 --max-filesize 1048576 \
--output "$work/token.json" --write-out '%{http_code}' --get \
--data-urlencode "service=$REGISTRY" --data-urlencode "scope=repository:$repository:pull" \
--user "$ACTOR:$GH_TOKEN" "https://$REGISTRY/token")" || fail 'registry authentication request failed'
[[ "$status" == 200 ]] || fail "registry authentication returned HTTP $status"
token="$(jq -ser 'select(length == 1) | .[0] | select(type == "object") |
select(.token == null or .access_token == null or .token == .access_token) |
(.token // .access_token) | select(type == "string" and length > 0)' "$work/token.json" 2>/dev/null)" || fail 'invalid registry token response'
[[ "$token" =~ ^[A-Za-z0-9._~+/-]+=*$ ]] || fail 'invalid registry bearer token'
status="$(curl -q --silent --show-error --proto '=https' --tlsv1.2 \
--connect-timeout 10 --max-time 30 --max-filesize 1048576 \
--output "$work/manifest" --write-out '%{http_code}' \
--header "Authorization: Bearer $token" --header "Accept: $accept" \
"https://$REGISTRY/v2/$repository/manifests/$VERSION")" || fail 'registry version read failed'
unset token
case "$status" in
200)
observed="$(shasum -a 256 "$work/manifest" | cut -d ' ' -f1)" || fail 'manifest hashing failed'
[[ "sha256:$observed" == "$expected" ]] || fail 'version has a conflicting digest'
printf 'matching\n' ;;
404)
jq -se 'length == 1 and (.[0] | type == "object" and (.errors | type == "array" and length > 0 and
all(.[]; type == "object" and (.code == "MANIFEST_UNKNOWN" or .code == "NAME_UNKNOWN"))))' \
"$work/manifest" >/dev/null 2>&1 || fail 'registry did not establish version absence'
printf 'missing\n' ;;
*) fail "registry version read returned HTTP $status" ;;
esac
}
# Both complete observations must agree with the authenticated pair before any tag is written.
image_state="$(read_version "$name" "$RECOVERY_IMAGE_DIGEST")" || exit 1
manifests_state="$(read_version "$name/manifests" "$RECOVERY_MANIFESTS_DIGEST")" || exit 1
# Registry V2 has no compare-and-set or cross-repository transaction. The shared
# queue serializes cooperating publishers; readback detects other writers' races.
if [[ "$image_state" == missing ]]; then
docker buildx imagetools create --prefer-index=false --tag "$image:$VERSION" "$image@$RECOVERY_IMAGE_DIGEST"
[[ "$(read_version "$name" "$RECOVERY_IMAGE_DIGEST")" == matching ]] || fail 'recovered image version is not present'
fi
if [[ "$manifests_state" == missing ]]; then
flux tag artifact "oci://$artifact@$RECOVERY_MANIFESTS_DIGEST" --tag "$VERSION" --creds "$ACTOR:$GH_TOKEN"
fi
[[ "$(read_version "$name" "$RECOVERY_IMAGE_DIGEST")" == matching &&
"$(read_version "$name/manifests" "$RECOVERY_MANIFESTS_DIGEST")" == matching ]] || fail 'recovered pair is not present'
echo 'Original signed application version is complete; latest tags are unchanged.'
173 changes: 173 additions & 0 deletions .github/tests/test-app-recovery.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# Execute paired recovery with controlled external failures and exclusive workflow admission.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
workflow="$root/.github/workflows/publish-app.yaml"
fail() { echo "app-recovery: $*" >&2; exit 1; }
yq -o=json '.' "$workflow" | jq -e '
.on.workflow_call.inputs as $inputs |
$inputs["enable-signed-recovery"].default == false and
(["recovery-image-digest", "recovery-manifests-digest", "recovery-run-id", "recovery-run-attempt", "recovery-workflow-sha"] |
all(. as $name | $inputs[$name].type == "string" and $inputs[$name].default == "" and $inputs[$name].required == false)) and
(.jobs.publish.steps | map(select(.name == "🔏 Recover original application version")) | length == 1)
' >/dev/null || fail 'default-off paired recovery is absent'
# A recovery request cannot reach normal artifact creation or signing.
# shellcheck disable=SC2016 # Literal GitHub expressions are the admission contract.
yq -o=json '.' "$workflow" | jq -e '
.jobs.publish.steps as $steps |
(["🔍 Validate the deployment manifest", "🏷️ Derive image tags", "🐳 Build & push image", "📌 Pin image digest in manifests"] |
all(. as $name | ($steps | map(select(.name == $name)) | length == 1) and
($steps | map(select(.name == $name))[0].if == "${{ !inputs.enable-signed-recovery }}"))) and
(["🔒 Prepare signed image staging", "📦 Sign & promote image and manifests"] |
all(. as $name | ($steps | map(select(.name == $name))[0].if == "${{ !inputs.enable-signed-recovery && (inputs.enable-signed-promotion == true || inputs.enable-signed-promotion == '\''true'\'') }}"))) and
($steps | map(select(.name == "🔏 Recover original application version"))[0] |
.if == "${{ inputs.enable-signed-recovery }}" and
.env.RECOVERY_IMAGE_DIGEST == "${{ inputs.recovery-image-digest }}" and
.env.RECOVERY_MANIFESTS_DIGEST == "${{ inputs.recovery-manifests-digest }}" and
.env.RECOVERY_RUN_ID == "${{ inputs.recovery-run-id }}" and
.env.RECOVERY_RUN_ATTEMPT == "${{ inputs.recovery-run-attempt }}" and
.env.RECOVERY_WORKFLOW_SHA == "${{ inputs.recovery-workflow-sha }}" and
.env.JOB_WORKFLOW_REF == "${{ steps.caller.outputs.ref }}" and
.env.WORKFLOW_REPOSITORY == "${{ job.workflow_repository }}" and
.env.REPOSITORY == "${{ github.repository }}" and .env.SHA == "${{ github.sha }}" and
.env.REF_NAME == "${{ github.ref_name }}" and .env.VERSION == "${{ steps.version.outputs.version }}" and
.env.SERVER_URL == "${{ github.server_url }}" and
.env.ENABLE_SIGNED_PROMOTION == "${{ inputs.enable-signed-promotion }}" and
.env.ENABLE_CALLER_PIN == "${{ inputs.enable-caller-pin }}" and
.run == "bash \"$RUNNER_TEMP/recover-app-version.sh\"")
' >/dev/null || fail 'recovery routing or source binding is unsafe'
prepare="$(yq -r '.jobs.publish.steps[] | select(.name == "🔒 Prepare immutable publication guard") | .run' "$workflow")"
# shellcheck disable=SC2016 # Match the runner variable literally in the shipped step.
grep -qxF 'cp .devantler-tech-publisher/.github/scripts/recover-app-version.sh "$RUNNER_TEMP/recover-app-version.sh"' <<<"$prepare" || fail 'recovery helper is not copied from immutable publisher checkout'
grep -qxF 'rm -rf -- .devantler-tech-publisher' <<<"$prepare" || fail 'publisher helpers remain in the consumer build context'
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir "$work/bin"
yq -r '.jobs.publish.steps[] | select(.name == "🔒 Admit explicit recovery") | .run' "$workflow" >"$work/admit.sh"
for promotion in true false; do
for pin in true false; do
status=0
ENABLE_SIGNED_PROMOTION="$promotion" ENABLE_CALLER_PIN="$pin" bash -euo pipefail "$work/admit.sh" >"$work/log" 2>&1 || status=$?
if [[ "$promotion" == true && "$pin" == true ]]; then [[ "$status" == 0 ]]; else [[ "$status" != 0 ]]; fi
done
done
printf '{"schemaVersion":2,"manifests":[]}' >"$work/image"
printf '{"schemaVersion":2,"manifests":[],"annotations":{"fixture":"manifests"}}' >"$work/manifests"
image_digest="sha256:$(shasum -a 256 "$work/image" | cut -d ' ' -f1)"
manifests_digest="sha256:$(shasum -a 256 "$work/manifests" | cut -d ' ' -f1)"
cat >"$work/bin/cosign" <<'COSIGN'
#!/usr/bin/env bash
set -euo pipefail
kind=image
[[ "${!#}" != *'/manifests@'* ]] || kind=manifests
printf 'verify-%s\n' "$kind" >>"$TRACE"
expected=(verify --certificate-identity https://github.com/devantler-tech/.github/.github/workflows/publish-app.yaml@0123456789abcdef0123456789abcdef01234567 --certificate-oidc-issuer https://token.actions.githubusercontent.com)
for claim in 'repository=devantler-tech/app' 'source-sha=fedcba9876543210fedcba9876543210fedcba98' 'source-ref=v1.2.3+proof' "version=$VERSION" 'run-id=123' 'run-attempt=2' 'workflow-ref=devantler-tech/.github/.github/workflows/publish-app.yaml@0123456789abcdef0123456789abcdef01234567' "image-digest=$RECOVERY_IMAGE_DIGEST" "manifests-digest=$RECOVERY_MANIFESTS_DIGEST"; do expected+=(-a "devantler.$claim"); done
target="ghcr.io/devantler-tech/app@$RECOVERY_IMAGE_DIGEST"
[[ "$kind" != manifests ]] || target="ghcr.io/devantler-tech/app/manifests@$RECOVERY_MANIFESTS_DIGEST"
expected+=("$target")
[[ "$#" == "${#expected[@]}" ]] || exit 94
index=0
for arg in "$@"; do [[ "$arg" == "${expected[index]}" ]] || exit 95; index=$((index + 1)); done
[[ "$SCENARIO" != "signature-$kind" ]] || exit 1
COSIGN
cat >"$work/bin/curl" <<'CURL'
#!/usr/bin/env bash
set -euo pipefail
[[ "$(head -2 "$TRACE")" == $'verify-image\nverify-manifests' ]] || exit 90
output='' previous='' url='' kind=image
for arg in "$@"; do
[[ "$previous" != --output ]] || output="$arg"
case "$arg" in https://*) url="$arg" ;; scope=repository:*/manifests:pull) kind=manifests ;; esac
previous="$arg"
done
[[ -n "$output" && "$*" == *'--proto =https'* && "$*" == *'--max-time 30'* && "$*" != *'--location'* ]] || exit 96
if [[ "$url" == https://ghcr.io/token ]]; then
[[ "$*" == *'--user fixture:synthetic-token'* ]] || exit 97
printf 'auth-%s\n' "$kind" >>"$TRACE"
printf '{"token":"synthetic-bearer"}' >"$output"
[[ "$SCENARIO" != auth-partial ]] || exit 7
printf 200
exit
fi
[[ "$*" == *'Authorization: Bearer synthetic-bearer'* ]] || exit 98
[[ "$url" != *'/app/manifests/manifests/'* ]] || kind=manifests
[[ "$url" == "https://ghcr.io/v2/devantler-tech/app/manifests/$VERSION" || "$url" == "https://ghcr.io/v2/devantler-tech/app/manifests/manifests/$VERSION" ]] || exit 99
printf 'read-%s\n' "$kind" >>"$TRACE"
case "$SCENARIO" in
partial-*) [[ "$SCENARIO" != "partial-$kind" ]] || { cp "$STATE/$kind" "$output"; exit 7; } ;;
forbidden-*) [[ "$SCENARIO" != "forbidden-$kind" ]] || { printf '{}' >"$output"; printf 403; exit; } ;;
redirect-*) [[ "$SCENARIO" != "redirect-$kind" ]] || { printf '{}' >"$output"; printf 302; exit; } ;;
mixed-*) [[ "$SCENARIO" != "mixed-$kind" ]] || { printf '{"errors":[{"code":"MANIFEST_UNKNOWN"},{"code":"DENIED"}]}' >"$output"; printf 404; exit; } ;;
esac
if [[ -e "$STATE/present-$kind" ]]; then
if [[ "$SCENARIO" == "conflict-$kind" || "$SCENARIO" == "readback-$kind" ]]; then printf '{}' >"$output"; else cp "$STATE/$kind" "$output"; fi
printf 200
else printf '{"errors":[{"code":"MANIFEST_UNKNOWN"}]}' >"$output"; printf 404; fi
CURL
cat >"$work/bin/docker" <<'DOCKER'
#!/usr/bin/env bash
set -euo pipefail
[[ "$*" == "buildx imagetools create --prefer-index=false --tag ghcr.io/devantler-tech/app:$VERSION ghcr.io/devantler-tech/app@$RECOVERY_IMAGE_DIGEST" ]] || exit 99
grep -qx read-image "$TRACE"
grep -qx read-manifests "$TRACE"
printf 'write-image\n' >>"$TRACE"
touch "$STATE/present-image"
[[ "$SCENARIO" != interrupted-image ]] || exit 1
DOCKER
cat >"$work/bin/flux" <<'FLUX'
#!/usr/bin/env bash
set -euo pipefail
[[ "$*" == "tag artifact oci://ghcr.io/devantler-tech/app/manifests@$RECOVERY_MANIFESTS_DIGEST --tag $VERSION --creds fixture:synthetic-token" ]] || exit 99
grep -qx read-image "$TRACE"
grep -qx read-manifests "$TRACE"
printf 'write-manifests\n' >>"$TRACE"
touch "$STATE/present-manifests"
[[ "$SCENARIO" != interrupted-manifests ]] || exit 1
FLUX
chmod +x "$work/bin/"*
run() {
local scenario="$1"
shift
env -i PATH="$work/bin:$PATH" TRACE="$work/trace" STATE="$work" SCENARIO="$scenario" \
REGISTRY=ghcr.io ACTOR=fixture GH_TOKEN=synthetic-token IMAGE_NAME=devantler-tech/app REPOSITORY=devantler-tech/app \
SHA=fedcba9876543210fedcba9876543210fedcba98 REF_NAME=v1.2.3+proof VERSION=1.2.3 SERVER_URL=https://github.com \
WORKFLOW_REPOSITORY=devantler-tech/.github JOB_WORKFLOW_REF=devantler-tech/.github/.github/workflows/publish-app.yaml@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
RECOVERY_IMAGE_DIGEST="$image_digest" RECOVERY_MANIFESTS_DIGEST="$manifests_digest" RECOVERY_RUN_ID=123 RECOVERY_RUN_ATTEMPT=2 RECOVERY_WORKFLOW_SHA=0123456789abcdef0123456789abcdef01234567 \
ENABLE_SIGNED_PROMOTION=true ENABLE_CALLER_PIN=true "$@" bash "$root/.github/scripts/recover-app-version.sh" >"$work/log" 2>&1
}
for scenario in missing matching image-only manifests-only signature-image signature-manifests auth-partial partial-image partial-manifests forbidden-image forbidden-manifests redirect-image redirect-manifests mixed-image mixed-manifests conflict-image conflict-manifests readback-image readback-manifests interrupted-image interrupted-manifests; do
: >"$work/trace"
rm -f "$work/present-image" "$work/present-manifests"
case "$scenario" in
matching) touch "$work/present-image" "$work/present-manifests" ;;
image-only|conflict-image) touch "$work/present-image" ;;
manifests-only|conflict-manifests) touch "$work/present-manifests" ;;
esac
status=0
run "$scenario" env || status=$?
count="$(grep -c '^write-' "$work/trace" || true)"
case "$scenario" in
missing) [[ "$status" == 0 && "$count" == 2 ]] || { cat "$work/log"; fail 'missing pair not restored'; } ;;
matching) [[ "$status" == 0 && "$count" == 0 ]] || fail 'matching pair changed' ;;
image-only|manifests-only) [[ "$status" == 0 && "$count" == 1 ]] || fail 'partial pair not restored' ;;
readback-*|interrupted-*) [[ "$status" != 0 ]] || fail 'incomplete publication succeeded' ;;
*) [[ "$status" != 0 && "$count" == 0 ]] || fail "$scenario authorized a write" ;;
esac
! grep -Eq 'synthetic-token|synthetic-bearer' "$work/log" || fail 'credentials printed'
done
for scenario in interrupted-image interrupted-manifests; do
rm -f "$work/present-image" "$work/present-manifests"
: >"$work/trace"
if run "$scenario" env; then fail 'interrupted write succeeded'; fi
: >"$work/trace"
run missing env || { cat "$work/log"; fail 'interrupted pair could not resume'; }
count="$(grep -c '^write-' "$work/trace" || true)"
if [[ "$scenario" == interrupted-image ]]; then [[ "$count" == 1 ]]; else [[ "$count" == 0 ]]; fi
done
for assignment in ENABLE_SIGNED_PROMOTION=false ENABLE_CALLER_PIN=false RECOVERY_WORKFLOW_SHA=main RECOVERY_RUN_ID=wrong RECOVERY_RUN_ATTEMPT=wrong RECOVERY_IMAGE_DIGEST=sha256:bad RECOVERY_MANIFESTS_DIGEST=sha256:bad; do
: >"$work/trace"
if run missing env "$assignment"; then fail "invalid $assignment accepted"; fi
[[ ! -s "$work/trace" ]] || fail 'invalid admission reached an external command'
done
echo 'PASS: paired missing/matching/partial recovery, both signature and observation failures, conflicts, interrupted writes and idempotent retries'
9 changes: 8 additions & 1 deletion .github/tests/test-publish-preflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ lookup_expr() { # <expression> — the value GitHub would substitute for ${{ <ex
"!(inputs.enable-signed-promotion == true || inputs.enable-signed-promotion == 'true')")
[[ "$(lookup_expr inputs.enable-signed-promotion)" != true ]] && printf true || printf false
;;
"!inputs.enable-signed-recovery")
[[ "$(lookup_expr inputs.enable-signed-recovery)" != true ]] && printf true || printf false
;;
"!inputs.enable-signed-recovery && (inputs.enable-signed-promotion == true || inputs.enable-signed-promotion == 'true')")
[[ "$(lookup_expr inputs.enable-signed-recovery)" != true && "$(lookup_expr inputs.enable-signed-promotion)" == true ]] && printf true || printf false
;;
Expand Down Expand Up @@ -504,16 +507,20 @@ echo 'ok both publishers enforce omitted/true pin inputs before checkout and p

# Explicit recovery must never fall through to a legacy writer when either
# admission prerequisite is disabled. Execute the shipped guard in job order.
for workflow in "$app" "$manifests"; do
job=publish
[[ "$workflow" == "$app" ]] || job=publish-manifests
for prerequisites in 'enable-signed-promotion=false enable-caller-pin=true' 'enable-signed-promotion=true enable-caller-pin=false' 'enable-signed-promotion=false enable-caller-pin=false'; do
wd="$scratch/recovery-refused"
mkdir -p "$wd"
# shellcheck disable=SC2086 # fixed input pairs, not content from a repository
if run_job "$manifests" publish-manifests "$wd" tag v1.2.3 enable-signed-recovery=true $prerequisites; then
if run_job "$workflow" "$job" "$wd" tag v1.2.3 app-name=app enable-signed-recovery=true $prerequisites; then
fail 'recovery bypassed signed-promotion/caller admission'
fi
nothing_pushed 'recovery without its prerequisites'
refused_with 'recovery without its prerequisites' 'recovery requires signed promotion and caller pinning'
done
done
echo 'ok explicit recovery cannot fall through to legacy publication'

# A repository name with uppercase letters: docker/metadata-action lowercases the image it pushes,
Expand Down
Loading
Loading