#!/usr/bin/env bash set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) source "$root/.github/scripts/release_helpers.sh" attestation="$root/.github/scripts/release_attestation.sh" temp_dir=$(mktemp -d) trap 'rm -rf "$temp_dir"' EXIT export RELEASE_RETRY_COUNT=5 RELEASE_RETRY_DELAY=0 delete_attempts=0 deleted_path= gh() { if [[ $* == *'--paginate --slurp'* ]]; then printf '%s\n' '[[ {"id": 11, "name": "sha256:correct", "metadata": {"container": {"tags": ["rollback-other"]}}}, {"id": 22, "name": "sha256:wrong", "metadata": {"container": {"tags": ["rollback-123-1"]}}}, {"id": 33, "name": "sha256:correct", "metadata": {"container": {"tags": ["rollback-123-1"]}}} ]]' return fi ((++delete_attempts)) deleted_path=${4:-} ((delete_attempts >= 3)) } delete_package_version /users/owner/packages/container/repo/versions rollback-123-1 sha256:correct [[ $delete_attempts -eq 3 ]] [[ $deleted_path == /users/owner/packages/container/repo/versions/33 ]] counter_file="$temp_dir/counter" printf '0\n' > "$counter_file" crane() { local count count=$(< "$counter_file") ((++count)) printf '%s\n' "$count" > "$counter_file" if ((count < 3)); then printf '%s\n' sha256:rollback return fi echo MANIFEST_UNKNOWN >&2 return 1 } wait_for_tag_absent ghcr.io/owner/repo:1.2.3 [[ $(< "$counter_file") -eq 3 ]] source_uri=git+https://github.com/owner/repo.git source_ref=refs/tags/v1.2.3 commit=0123456789abcdef0123456789abcdef01234567 builder=https://github.com/owner/repo/.github/workflows/ci.yml@refs/tags/v1.2.3 invocation=https://github.com/owner/repo/actions/runs/123/attempts/1 identity=https://github.com/owner/repo/.github/workflows/ci.yml@refs/tags/v1.2.3 gochat_digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa shangwutong_digest=sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc gochat=ghcr.io/owner/repo@$gochat_digest shangwutong=ghcr.io/owner/repo-shangwutong@$shangwutong_digest gochat_tag=ghcr.io/owner/repo:1.2.3 shangwutong_tag=ghcr.io/owner/repo-shangwutong:1.2.3 predicate="$temp_dir/provenance.json" provenance_statement="$temp_dir/provenance-statement.json" sbom_statement="$temp_dir/sbom-statement.json" "$attestation" create-provenance "$predicate" "$source_uri" "$source_ref" "$commit" "$builder" "$invocation" jq -n --slurpfile predicate "$predicate" --arg name ghcr.io/owner/repo '{ _type: "https://in-toto.io/Statement/v0.1", subject: [{name: $name, digest: {sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}], predicateType: "https://slsa.dev/provenance/v1", predicate: $predicate[0] }' > "$provenance_statement" jq -n --arg name ghcr.io/owner/repo '{ _type: "https://in-toto.io/Statement/v0.1", subject: [{name: $name, digest: {sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}], predicateType: "https://spdx.dev/Document", predicate: {SPDXID: "SPDXRef-DOCUMENT", spdxVersion: "SPDX-2.3", packages: []} }' > "$sbom_statement" verify_statement() { local kind=$1 statement=$2 if [[ $kind == provenance ]]; then "$attestation" verify-statement provenance "$statement" "$gochat" \ "$source_uri" "$source_ref" "$commit" "$builder" "$invocation" else "$attestation" verify-statement sbom "$statement" "$gochat" fi } must_reject() { local kind=$1 mutation=$2 output="$temp_dir/mutated.json" statement if [[ $kind == provenance ]]; then statement=$provenance_statement else statement=$sbom_statement fi jq "$mutation" "$statement" > "$output" if verify_statement "$kind" "$output"; then echo "Accepted mutated $kind: $mutation" >&2 exit 1 fi } verify_statement provenance "$provenance_statement" verify_statement sbom "$sbom_statement" must_reject provenance '.predicateType = "https://slsa.dev/provenance/v0.2"' must_reject provenance '.predicate.buildDefinition.externalParameters.source.uri = "git+https://github.com/owner/other.git"' must_reject provenance '.predicate.buildDefinition.externalParameters.source.ref = "refs/tags/v9.9.9"' must_reject provenance '.predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit = "deadbeef"' must_reject provenance '.predicate.runDetails.builder.id = "https://github.com/owner/other/.github/workflows/ci.yml@refs/tags/v1.2.3"' must_reject provenance '.predicate.runDetails.metadata.invocationId = "https://github.com/owner/repo/actions/runs/999/attempts/1"' must_reject provenance '.subject[0].digest.sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"' must_reject sbom '.predicateType = "https://example.invalid/sbom"' must_reject sbom '.subject[0].digest.sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"' must_reject sbom '.predicate.SPDXID = "tampered"' export MOCK_LOG="$temp_dir/mock.log" MOCK_TEMP="$temp_dir" MOCK_IDENTITY="$identity" export MOCK_SOURCE_URI="$source_uri" MOCK_SOURCE_REF="$source_ref" MOCK_COMMIT="$commit" export MOCK_BUILDER="$builder" MOCK_INVOCATION="$invocation" export MOCK_GOCHAT="$gochat" MOCK_GOCHAT_TAG="$gochat_tag" MOCK_GOCHAT_DIGEST="$gochat_digest" export MOCK_SHANGWUTONG="$shangwutong" MOCK_SHANGWUTONG_TAG="$shangwutong_tag" MOCK_SHANGWUTONG_DIGEST="$shangwutong_digest" crane() { local digest printf 'crane %s %s\n' "${1:-}" "${2:-}" >> "$MOCK_LOG" [[ $# -eq 2 && $1 == digest ]] || return 2 case $2 in "$MOCK_GOCHAT"|"$MOCK_GOCHAT_TAG") digest=$MOCK_GOCHAT_DIGEST ;; "$MOCK_SHANGWUTONG"|"$MOCK_SHANGWUTONG_TAG") digest=$MOCK_SHANGWUTONG_DIGEST ;; *) echo "unknown mock referrer: $2" >&2; return 1 ;; esac [[ ${MOCK_BAD_DIGEST_REF:-} != "$2" ]] || digest=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb printf '%s\n' "$digest" } docker() { printf 'docker %s\n' "$*" >> "$MOCK_LOG" [[ $# -eq 6 && $1 == buildx && $2 == imagetools && $3 == inspect && $5 == --format && $6 == '{{ json .SBOM }}' ]] [[ $4 == "$MOCK_GOCHAT" || $4 == "$MOCK_SHANGWUTONG" ]] printf '%s\n' '{"SPDX":{"SPDXID":"SPDXRef-DOCUMENT","spdxVersion":"SPDX-2.3","packages":[]}}' } cosign() { local type predicate lookup repo digest statement payload { printf 'cosign' printf ' %s' "$@" printf '\n' } >> "$MOCK_LOG" case ${1:-} in attest) [[ $# -eq 7 && $2 == --yes && $3 == --type && $5 == --predicate ]] type=$4 predicate=$6 lookup=$7 [[ $lookup == "$MOCK_GOCHAT" || $lookup == "$MOCK_SHANGWUTONG" ]] if [[ $type == spdxjson ]]; then jq -e '.SPDXID == "SPDXRef-DOCUMENT" and (.packages | type == "array")' "$predicate" > /dev/null elif [[ $type == slsaprovenance1 ]]; then jq -e --arg uri "$MOCK_SOURCE_URI" --arg ref "$MOCK_SOURCE_REF" --arg commit "$MOCK_COMMIT" \ --arg builder "$MOCK_BUILDER" --arg invocation "$MOCK_INVOCATION" ' .buildDefinition.externalParameters.source == {uri: $uri, ref: $ref} and .buildDefinition.resolvedDependencies[0].digest.gitCommit == $commit and .runDetails.builder.id == $builder and .runDetails.metadata.invocationId == $invocation ' "$predicate" > /dev/null else return 1 fi ;; verify) [[ $# -eq 6 && $2 == --certificate-identity && $3 == "$MOCK_IDENTITY" && $4 == --certificate-oidc-issuer && $5 == https://token.actions.githubusercontent.com ]] crane digest "$6" > /dev/null ;; verify-attestation) [[ $# -eq 8 && $2 == --type && $4 == --certificate-identity && $5 == "$MOCK_IDENTITY" && $6 == --certificate-oidc-issuer && $7 == https://token.actions.githubusercontent.com ]] type=$3 lookup=$8 digest=$(crane digest "$lookup") if [[ $lookup == *@* ]]; then repo=${lookup%@*}; else repo=${lookup%:*}; fi statement="$MOCK_TEMP/mock-statement.json" if [[ $type == spdxjson ]]; then jq -n --arg repo "$repo" --arg digest "${digest#sha256:}" '{ _type: "https://in-toto.io/Statement/v0.1", subject: [{name: $repo, digest: {sha256: $digest}}], predicateType: "https://spdx.dev/Document", predicate: {SPDXID: "SPDXRef-DOCUMENT", spdxVersion: "SPDX-2.3", packages: []} }' > "$statement" elif [[ $type == slsaprovenance1 ]]; then jq -n --arg repo "$repo" --arg digest "${digest#sha256:}" \ --arg uri "$MOCK_SOURCE_URI" --arg ref "$MOCK_SOURCE_REF" --arg commit "$MOCK_COMMIT" \ --arg builder "$MOCK_BUILDER" --arg invocation "$MOCK_INVOCATION" '{ _type: "https://in-toto.io/Statement/v0.1", subject: [{name: $repo, digest: {sha256: $digest}}], predicateType: "https://slsa.dev/provenance/v1", predicate: { buildDefinition: { buildType: "https://github.com/Attestations/GitHubActionsWorkflow@v1", externalParameters: {source: {uri: $uri, ref: $ref}}, internalParameters: {}, resolvedDependencies: [{uri: $uri, digest: {gitCommit: $commit}}] }, runDetails: {builder: {id: $builder}, metadata: {invocationId: $invocation}} } }' > "$statement" else return 1 fi case ${MOCK_TAMPER:-} in builder) jq '.predicate.runDetails.builder.id = "tampered"' "$statement" > "$statement.tmp" && mv "$statement.tmp" "$statement" ;; invocation) jq '.predicate.runDetails.metadata.invocationId = "tampered"' "$statement" > "$statement.tmp" && mv "$statement.tmp" "$statement" ;; sbom-predicate) [[ $type != spdxjson ]] || jq '.predicateType = "tampered"' "$statement" > "$statement.tmp" && mv "$statement.tmp" "$statement" ;; sbom-subject) [[ $type != spdxjson ]] || jq '.subject[0].digest.sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"' "$statement" > "$statement.tmp" && mv "$statement.tmp" "$statement" ;; malformed-output) printf '%s\n' '{"payload":"%%%"}'; return ;; esac payload=$(base64 < "$statement" | tr -d '\n') printf '%s\n' '{"ignored":"non-envelope output"}' jq -n --arg payload "$payload" '{payload: $payload}' ;; *) return 2 ;; esac } export -f crane docker cosign "$attestation" attest-images "$source_uri" "$source_ref" "$commit" "$builder" "$invocation" \ "$gochat" "$shangwutong" "$attestation" verify-images "$identity" "$source_uri" "$source_ref" "$commit" "$builder" "$invocation" \ "$gochat" "$shangwutong" promotion_targets=("$gochat|$gochat_tag" "$shangwutong|$shangwutong_tag") "$attestation" verify-promoted "$identity" "$source_uri" "$source_ref" "$commit" "$builder" "$invocation" \ "${promotion_targets[@]}" [[ $(grep -c '^docker buildx imagetools inspect ' "$MOCK_LOG") -eq 2 ]] [[ $(grep -c '^cosign attest ' "$MOCK_LOG") -eq 4 ]] grep -Fq "cosign verify-attestation --type spdxjson --certificate-identity $identity --certificate-oidc-issuer https://token.actions.githubusercontent.com $gochat" "$MOCK_LOG" grep -Fq "cosign verify-attestation --type slsaprovenance1 --certificate-identity $identity --certificate-oidc-issuer https://token.actions.githubusercontent.com $shangwutong_tag" "$MOCK_LOG" must_reject_verify() { local tamper=$1 if MOCK_TAMPER=$tamper "$attestation" verify-images \ "$identity" "$source_uri" "$source_ref" "$commit" "$builder" "$invocation" "$gochat" "$shangwutong" \ > /dev/null 2>&1; then echo "Production verify accepted $tamper attestation output" >&2 exit 1 fi } must_reject_verify builder must_reject_verify invocation must_reject_verify sbom-predicate must_reject_verify sbom-subject must_reject_verify malformed-output must_reject_promoted() { local tamper=$1 if MOCK_TAMPER=$tamper "$attestation" verify-promoted \ "$identity" "$source_uri" "$source_ref" "$commit" "$builder" "$invocation" \ "${promotion_targets[@]}" > /dev/null 2>&1; then echo "Promotion verify accepted $tamper attestation output" >&2 exit 1 fi } must_reject_promoted builder must_reject_promoted invocation must_reject_promoted sbom-predicate must_reject_promoted sbom-subject must_reject_promoted malformed-output if "$attestation" verify-images "https://github.com/owner/other/.github/workflows/ci.yml@refs/tags/v1.2.3" \ "$source_uri" "$source_ref" "$commit" "$builder" "$invocation" "$gochat" > /dev/null 2>&1; then echo "Production verify accepted wrong certificate identity" >&2 exit 1 fi if MOCK_BAD_DIGEST_REF=$shangwutong_tag "$attestation" verify-promoted \ "$identity" "$source_uri" "$source_ref" "$commit" "$builder" "$invocation" \ "${promotion_targets[@]}" > /dev/null 2>&1; then echo "Promotion accepted a changed target digest" >&2 exit 1 fi python3 - "$root/.github/workflows/ci.yml" <<'PY' import re import sys from pathlib import Path workflow = Path(sys.argv[1]).read_text() release = re.search(r"(?ms)^ release:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", workflow) assert release, "release job missing" body = release.group(1) for command in ("attest-images", "verify-images", "verify-promoted"): assert f".github/scripts/release_attestation.sh {command}" in body assert re.search( r"(?m)^ concurrency:\n group: release-promotion\n cancel-in-progress: false$", body, ), "all release tags must share one non-cancelling promotion lock" assert "github.ref" not in re.search(r"(?ms)^ concurrency:\n(.*?)(?=^ \S)", body).group(1) PY grep -Fq 'python-version: "3.12.11"' "$root/.github/workflows/ci.yml" grep -Fq 'node-version: 20.19.5' "$root/.github/workflows/ci.yml" grep -Fq 'registry:2@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373' "$root/.github/workflows/ci.yml" grep -Fq -- '--require-hashes' "$root/.github/workflows/ci.yml" echo "release promotion checks passed"