diff --git a/.github/requirements-browser-harness.txt b/.github/requirements-browser-harness.txt new file mode 100644 index 00000000..a9cb811c --- /dev/null +++ b/.github/requirements-browser-harness.txt @@ -0,0 +1,27 @@ +pip==25.1.1 \ + --hash=sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af +browser-harness==0.1.9 \ + --hash=sha256:b91eb7ecc83d53f5b0fc15b26cad5a09e935797c24ae4a992181dc2a89cc3cd6 +cdp-use==1.4.5 \ + --hash=sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb +fetch-use==0.4.0 \ + --hash=sha256:b7885f2907e7920373fa75dcdb00afd6e603a25a9ee2151aa2881f5465e1a2c0 +pillow==12.3.0 \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 +websockets==15.0.1 \ + --hash=sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65 \ + --hash=sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f +httpx==0.28.1 \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 +h11==0.16.0 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 +idna==3.19 \ + --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 diff --git a/.github/scripts/release_attestation.sh b/.github/scripts/release_attestation.sh new file mode 100755 index 00000000..ae330f68 --- /dev/null +++ b/.github/scripts/release_attestation.sh @@ -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 diff --git a/.github/scripts/test_release_promotion.sh b/.github/scripts/test_release_promotion.sh index d4d76fc0..44462df0 100755 --- a/.github/scripts/test_release_promotion.sh +++ b/.github/scripts/test_release_promotion.sh @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ba501f3..7a7bc200 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,11 +32,11 @@ jobs: cache-dependency-path: backend/go.sum - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: "3.12" + python-version: "3.12.11" - uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2 id: chrome - name: Install browser harness - run: python -m pip install browser-harness==0.1.9 + run: python -m pip install --require-hashes -r .github/requirements-browser-harness.txt - name: Test Prometheus alert rules run: docker run --rm --entrypoint promtool -v "$PWD/backend/configs:/configs:ro" prom/prometheus:v3.5.0@sha256:63805ebb8d2b3920190daf1cb14a60871b16fd38bed42b857a3182bc621f4996 test rules /configs/prometheus_alerts_test.yml - name: Test @@ -170,7 +170,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 20 + node-version: 20.19.5 - run: corepack enable && corepack prepare pnpm@10.2.0 --activate - run: pnpm install --frozen-lockfile - name: Test @@ -239,7 +239,7 @@ jobs: run: | docker build -t "$GOCHAT_IMAGE_REF" -f deploy/docker/Dockerfile . docker build -t shangwutong:production-smoke -f channels/shangwutong/Dockerfile . - docker run -d --name gochat-ci-registry -p 127.0.0.1:5000:5000 registry:2 + docker run -d --name gochat-ci-registry -p 127.0.0.1:5000:5000 registry:2@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 timeout 30 sh -c 'until curl -fsS http://127.0.0.1:5000/v2/; do sleep 1; done' docker tag "$GOCHAT_IMAGE_REF" localhost:5000/gochat:production-smoke docker tag shangwutong:production-smoke localhost:5000/shangwutong:production-smoke @@ -408,18 +408,29 @@ jobs: run: | cosign sign --yes "${{ steps.ref.outputs.image_ref }}" cosign sign --yes "${{ steps.ref.outputs.shangwutong_image_ref }}" - - name: Verify signatures and attestations + - name: Attest SBOM and provenance + env: + SOURCE_URI: git+https://github.com/${{ github.repository }}.git + SOURCE_REF: ${{ github.ref }} + BUILDER_ID: https://github.com/${{ github.repository }}/.github/workflows/ci.yml@${{ github.ref }} + INVOCATION_ID: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} run: | - for image_ref in "${{ steps.ref.outputs.image_ref }}" "${{ steps.ref.outputs.shangwutong_image_ref }}"; do - cosign verify \ - --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/ci.yml@refs/tags/v" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - "$image_ref" - docker buildx imagetools inspect "$image_ref" --format '{{ json .SBOM }}' | grep -q '"SPDXID"' - provenance=$(docker buildx imagetools inspect "$image_ref" --format '{{ json .Provenance }}') - grep -q '"SLSA"' <<< "$provenance" - grep -q "$GITHUB_SHA" <<< "$provenance" - done + set -euo pipefail + .github/scripts/release_attestation.sh attest-images \ + "$SOURCE_URI" "$SOURCE_REF" "$GITHUB_SHA" "$BUILDER_ID" "$INVOCATION_ID" \ + "${{ steps.ref.outputs.image_ref }}" "${{ steps.ref.outputs.shangwutong_image_ref }}" + - name: Verify signatures and attestations + env: + SOURCE_URI: git+https://github.com/${{ github.repository }}.git + SOURCE_REF: ${{ github.ref }} + BUILDER_ID: https://github.com/${{ github.repository }}/.github/workflows/ci.yml@${{ github.ref }} + INVOCATION_ID: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + run: | + set -euo pipefail + identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/ci.yml@${GITHUB_REF}" + .github/scripts/release_attestation.sh verify-images \ + "$identity" "$SOURCE_URI" "$SOURCE_REF" "$GITHUB_SHA" "$BUILDER_ID" "$INVOCATION_ID" \ + "${{ steps.ref.outputs.image_ref }}" "${{ steps.ref.outputs.shangwutong_image_ref }}" - name: Promote and reverify release tags env: GOCHAT_RELEASE_TAGS: ${{ steps.meta.outputs.tags }} @@ -427,6 +438,10 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PACKAGE_OWNER: ${{ github.repository_owner }} PACKAGE_OWNER_TYPE: ${{ github.event.repository.owner.type }} + SOURCE_URI: git+https://github.com/${{ github.repository }}.git + SOURCE_REF: ${{ github.ref }} + BUILDER_ID: https://github.com/${{ github.repository }}/.github/workflows/ci.yml@${{ github.ref }} + INVOCATION_ID: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} run: | set -euo pipefail source .github/scripts/release_helpers.sh @@ -529,25 +544,10 @@ jobs: crane tag "$source_ref" "$tag" done - for entry in "${targets[@]}"; do - source_ref=${entry%%|*} - target=${entry#*|} - expected=${source_ref##*@} - actual=$(crane digest "$target") - [[ $actual == "$expected" ]] || { - echo "Digest mismatch: $target resolved to $actual, expected $expected" >&2 - exit 1 - } - echo "$target -> $actual" - cosign verify \ - --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/ci.yml@refs/tags/v" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - "$target" - docker buildx imagetools inspect "$target" --format '{{ json .SBOM }}' | grep -q '"SPDXID"' - provenance=$(docker buildx imagetools inspect "$target" --format '{{ json .Provenance }}') - grep -q '"SLSA"' <<< "$provenance" - grep -q "$GITHUB_SHA" <<< "$provenance" - done + identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/ci.yml@${GITHUB_REF}" + .github/scripts/release_attestation.sh verify-promoted \ + "$identity" "$SOURCE_URI" "$SOURCE_REF" "$GITHUB_SHA" "$BUILDER_ID" "$INVOCATION_ID" \ + "${targets[@]}" trap - EXIT INT TERM for repo in "${!cleanup_refs[@]}"; do