HH-462: harden release attestation semantics (#94)

* HH-462: bind release attestations to source and digest

* HH-462: exercise release attestation production paths

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-22 04:07:32 +08:00
committed by GitHub
co-authored by rogee
parent 0e23ccc465
commit b6b1597e90
4 changed files with 494 additions and 36 deletions
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
set -euo pipefail
readonly statement_type=https://in-toto.io/Statement/v0.1
readonly provenance_type=https://slsa.dev/provenance/v1
readonly spdx_type=https://spdx.dev/Document
image_subject() {
local image_ref=$1
[[ $image_ref =~ ^(.+)@sha256:([0-9a-f]{64})$ ]] || {
echo "Expected immutable sha256 image reference: $image_ref" >&2
return 1
}
printf '%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
}
create_provenance() {
local output=$1 source_uri=$2 source_ref=$3 commit=$4 builder=$5 invocation=$6
jq -n \
--arg source_uri "$source_uri" \
--arg source_ref "$source_ref" \
--arg commit "$commit" \
--arg builder "$builder" \
--arg invocation "$invocation" \
'{
buildDefinition: {
buildType: "https://github.com/Attestations/GitHubActionsWorkflow@v1",
externalParameters: {source: {uri: $source_uri, ref: $source_ref}},
internalParameters: {},
resolvedDependencies: [{uri: $source_uri, digest: {gitCommit: $commit}}]
},
runDetails: {
builder: {id: $builder},
metadata: {invocationId: $invocation}
}
}' > "$output"
}
extract_sbom() (
local output=$1 image_ref=$2 wrapper
wrapper=$(mktemp)
trap 'rm -f "$wrapper"' EXIT
docker buildx imagetools inspect "$image_ref" --format '{{ json .SBOM }}' > "$wrapper"
jq -e '
.SPDX
| .SPDXID == "SPDXRef-DOCUMENT"
and (.spdxVersion | startswith("SPDX-"))
and (.packages | type == "array")
' "$wrapper" > /dev/null
jq '.SPDX' "$wrapper" > "$output"
)
verify_subject() {
local statement=$1 subject_ref=$2 expected_name expected_digest
mapfile -t subject < <(image_subject "$subject_ref")
expected_name=${subject[0]}
expected_digest=${subject[1]}
jq -e \
--arg statement_type "$statement_type" \
--arg expected_name "$expected_name" \
--arg expected_digest "$expected_digest" '
._type == $statement_type
and (.subject | length == 1)
and .subject[0].name == $expected_name
and .subject[0].digest.sha256 == $expected_digest
' "$statement" > /dev/null
}
verify_statement() {
local kind=$1 statement=$2 subject_ref=$3
verify_subject "$statement" "$subject_ref"
shift 3
case $kind in
provenance)
local source_uri=$1 source_ref=$2 commit=$3 builder=$4 invocation=$5
jq -e \
--arg predicate_type "$provenance_type" \
--arg source_uri "$source_uri" \
--arg source_ref "$source_ref" \
--arg commit "$commit" \
--arg builder "$builder" \
--arg invocation "$invocation" '
.predicateType == $predicate_type
and .predicate.buildDefinition.buildType == "https://github.com/Attestations/GitHubActionsWorkflow@v1"
and .predicate.buildDefinition.externalParameters.source.uri == $source_uri
and .predicate.buildDefinition.externalParameters.source.ref == $source_ref
and (.predicate.buildDefinition.resolvedDependencies | length == 1)
and .predicate.buildDefinition.resolvedDependencies[0].uri == $source_uri
and .predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit == $commit
and .predicate.runDetails.builder.id == $builder
and .predicate.runDetails.metadata.invocationId == $invocation
' "$statement" > /dev/null
;;
sbom)
jq -e \
--arg predicate_type "$spdx_type" '
.predicateType == $predicate_type
and .predicate.SPDXID == "SPDXRef-DOCUMENT"
and (.predicate.spdxVersion | startswith("SPDX-"))
and (.predicate.packages | type == "array")
' "$statement" > /dev/null
;;
*)
echo "Unknown attestation kind: $kind" >&2
return 1
;;
esac
}
verify_attestation() (
local kind=$1 lookup_ref=$2 subject_ref=$3 identity=$4 predicate_alias envelope statement payload
shift 4
case $kind in
provenance) predicate_alias=slsaprovenance1 ;;
sbom) predicate_alias=spdxjson ;;
*) echo "Unknown attestation kind: $kind" >&2; return 1 ;;
esac
envelope=$(mktemp)
statement=$(mktemp)
trap 'rm -f "$envelope" "$statement"' EXIT
cosign verify-attestation \
--type "$predicate_alias" \
--certificate-identity "$identity" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"$lookup_ref" > "$envelope"
payload=$(jq -sr -e 'map(select(.payload?))[0].payload' "$envelope")
printf '%s' "$payload" | base64 --decode > "$statement"
verify_statement "$kind" "$statement" "$subject_ref" "$@"
)
attest_images() (
local source_uri=$1 source_ref=$2 commit=$3 builder=$4 invocation=$5
local image_ref sbom provenance temp_dir
shift 5
(($# > 0))
temp_dir=$(mktemp -d)
trap 'rm -rf "$temp_dir"' EXIT
for image_ref in "$@"; do
sbom="$temp_dir/sbom.json"
provenance="$temp_dir/provenance.json"
extract_sbom "$sbom" "$image_ref"
create_provenance "$provenance" "$source_uri" "$source_ref" "$commit" "$builder" "$invocation"
cosign attest --yes --type spdxjson --predicate "$sbom" "$image_ref"
cosign attest --yes --type slsaprovenance1 --predicate "$provenance" "$image_ref"
done
)
verify_image() {
local lookup_ref=$1 subject_ref=$2 identity=$3 source_uri=$4 source_ref=$5 commit=$6 builder=$7 invocation=$8
cosign verify \
--certificate-identity "$identity" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"$lookup_ref"
verify_attestation sbom "$lookup_ref" "$subject_ref" "$identity"
verify_attestation provenance "$lookup_ref" "$subject_ref" "$identity" \
"$source_uri" "$source_ref" "$commit" "$builder" "$invocation"
}
verify_images() {
local identity=$1 source_uri=$2 source_ref=$3 commit=$4 builder=$5 invocation=$6 image_ref
shift 6
(($# > 0))
for image_ref in "$@"; do
verify_image "$image_ref" "$image_ref" "$identity" \
"$source_uri" "$source_ref" "$commit" "$builder" "$invocation"
done
}
verify_promoted() {
local identity=$1 source_uri=$2 source_ref=$3 commit=$4 builder=$5 invocation=$6
local entry subject_ref target expected actual
shift 6
(($# > 0))
for entry in "$@"; do
subject_ref=${entry%%|*}
target=${entry#*|}
expected=${subject_ref##*@}
actual=$(crane digest "$target")
[[ $actual == "$expected" ]] || {
echo "Digest mismatch: $target resolved to $actual, expected $expected" >&2
return 1
}
echo "$target -> $actual"
verify_image "$target" "$subject_ref" "$identity" \
"$source_uri" "$source_ref" "$commit" "$builder" "$invocation"
done
}
case ${1:-} in
create-provenance) shift; create_provenance "$@" ;;
extract-sbom) shift; extract_sbom "$@" ;;
attest-images) shift; attest_images "$@" ;;
verify-statement) shift; verify_statement "$@" ;;
verify) shift; verify_attestation "$@" ;;
verify-images) shift; verify_images "$@" ;;
verify-promoted) shift; verify_promoted "$@" ;;
*)
echo "Usage: $0 {create-provenance|extract-sbom|attest-images|verify-statement|verify|verify-images|verify-promoted} ..." >&2
exit 2
;;
esac
+229 -2
View File
@@ -3,6 +3,10 @@ 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
@@ -26,8 +30,7 @@ delete_package_version /users/owner/packages/container/repo/versions rollback-12
[[ $delete_attempts -eq 3 ]]
[[ $deleted_path == /users/owner/packages/container/repo/versions/33 ]]
counter_file=$(mktemp)
trap 'rm -f "$counter_file"' EXIT
counter_file="$temp_dir/counter"
printf '0\n' > "$counter_file"
crane() {
local count
@@ -45,6 +48,223 @@ crane() {
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
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
@@ -54,6 +274,8 @@ 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,
@@ -61,4 +283,9 @@ assert re.search(
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"