307 lines
14 KiB
Bash
Executable File
307 lines
14 KiB
Bash
Executable File
#!/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"
|