From 50da676cb589feb4fa2bb95ba9d039f0d1680e93 Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Wed, 26 Aug 2026 02:57:45 +0000 Subject: [PATCH 01/11] feat(release): add artifact-bound SBOM assurance and verification --- .github/actions/release-nightly/action.yml | 63 +++- .github/workflows/nightly-release.yml | 1 + script/attest_release_assurance.sh | 122 +++++++ script/generate_release_assurance.sh | 386 +++++++++++++++++++++ script/verify_release_assurance.sh | 382 ++++++++++++++++++++ 5 files changed, 943 insertions(+), 11 deletions(-) create mode 100644 script/attest_release_assurance.sh create mode 100644 script/generate_release_assurance.sh create mode 100644 script/verify_release_assurance.sh diff --git a/.github/actions/release-nightly/action.yml b/.github/actions/release-nightly/action.yml index cf980ad84b..b5a744111e 100644 --- a/.github/actions/release-nightly/action.yml +++ b/.github/actions/release-nightly/action.yml @@ -90,6 +90,7 @@ runs: echo "VERSION=$V" >> $GITHUB_ENV echo "TAG=v$V" >> $GITHUB_ENV echo "UPD_DATE=$D" >> $GITHUB_ENV + echo "BUILD_SOURCE_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV IMAGE_NAME=docker.io/${TEST_DOCKER_HUB_USERNAME:-testcamelk}/camel-k echo "Using IMAGE_NAME=$IMAGE_NAME" echo "IMAGE_NAME=$IMAGE_NAME" >> $GITHUB_ENV @@ -110,30 +111,70 @@ runs: run: | NOTEST=1 make VERSION=${{ env.VERSION }} IMAGE_NAME=${{ env.IMAGE_NAME }} BUNDLE_IMAGE_NAME=docker.io/testcamelk/camel-k-bundle bundle-push - - name: Install SBOM generator + - name: Install assurance tools shell: bash + env: + CYCLONEDX_GOMOD_VERSION: v1.12.0 + SYFT_VERSION: v1.51.0 + COSIGN_VERSION: v3.1.3 run: | - go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@latest - echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - - - name: Generate SBOM + go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@${CYCLONEDX_GOMOD_VERSION} + go install github.com/anchore/syft/cmd/syft@${SYFT_VERSION} + go install github.com/sigstore/cosign/v3/cmd/cosign@${COSIGN_VERSION} + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + go version -m "$(go env GOPATH)/bin/cyclonedx-gomod" | grep -F "github.com/CycloneDX/cyclonedx-gomod ${CYCLONEDX_GOMOD_VERSION}" + "$(go env GOPATH)/bin/syft" --version | grep -E '1\.51\.0$' + go version -m "$(go env GOPATH)/bin/cosign" | grep -F "github.com/sigstore/cosign/v3 ${COSIGN_VERSION}" + + - name: Generate module SBOM shell: bash - run: cyclonedx-gomod mod -licenses -json -output sbom.json + run: cyclonedx-gomod mod -licenses -json -noserial -notimestamp -output sbom.json - - name: Commit and push nightly branch + - name: Prepare nightly release commit shell: bash env: CI_USER: "github-actions[bot]" CI_EMAIL: "41898282+github-actions[bot]@users.noreply.github.com" - CI_TOKEN: ${{ inputs.secretGithubToken }} run: | git config --local user.email "$CI_EMAIL" git config --local user.name "$CI_USER" git add docs/charts/ git commit -am 'chore(ci): ${{ env.TAG }} release updates' || echo "No nightly release updates changes to commit" echo "COMMIT_ID=$(git rev-parse HEAD)" >> $GITHUB_ENV - git tag ${{ env.TAG }} $(git rev-parse HEAD) - git push "https://$CI_USER:$CI_TOKEN@github.com/$GITHUB_REPOSITORY.git" tag ${{ env.TAG }} -f || echo "No nightly release updates changes to push" + + - name: Generate release assurance + shell: bash + env: + CYCLONEDX_GOMOD_VERSION: v1.12.0 + SYFT_VERSION: v1.51.0 + SYFT_CHECK_FOR_APP_UPDATE: "false" + SYFT_REGISTRY_AUTH_USERNAME: ${{ inputs.secretDockerHubUser }} + SYFT_REGISTRY_AUTH_PASSWORD: ${{ inputs.secretDockerHubPassword }} + run: | + bash script/generate_release_assurance.sh \ + "${{ env.VERSION }}" \ + "${{ env.TAG }}" \ + "${{ env.BUILD_SOURCE_SHA }}" \ + "${{ env.COMMIT_ID }}" \ + "$CYCLONEDX_GOMOD_VERSION" + + - name: Sign and attest release assurance + shell: bash + env: + COSIGN_YES: "true" + run: | + bash script/attest_release_assurance.sh \ + release-assurance.json \ + release-assurance.sigstore.json + + - name: Publish nightly tag + shell: bash + env: + CI_USER: "github-actions[bot]" + CI_TOKEN: ${{ inputs.secretGithubToken }} + run: | + git tag -f "${{ env.TAG }}" "${{ env.COMMIT_ID }}" + git push "https://$CI_USER:$CI_TOKEN@github.com/$GITHUB_REPOSITORY.git" "refs/tags/${{ env.TAG }}" --force - name: Create release first time shell: bash @@ -236,7 +277,7 @@ runs: shell: bash run: | RELEASE_ID=${{ env.RELEASE_ID }} - FILES="./camel-k-client*.tar.gz sbom.json" + FILES="./camel-k-client*.tar.gz sbom.json ./camel-k-client*.sbom.cdx.json ./camel-k-image-*.sbom.cdx.json release-assurance.json release-assurance.json.sha256 release-assurance.sigstore.json" for FILE in $FILES; do if [ -f "$FILE" ]; then diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 554b1a865b..1d6afc70ec 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -30,6 +30,7 @@ env: permissions: contents: write + id-token: write on: schedule: diff --git a/script/attest_release_assurance.sh b/script/attest_release_assurance.sh new file mode 100644 index 0000000000..9a3453795d --- /dev/null +++ b/script/attest_release_assurance.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +receipt=${1:-release-assurance.json} +bundle=${2:-release-assurance.sigstore.json} +issuer=${COSIGN_CERTIFICATE_OIDC_ISSUER:-https://token.actions.githubusercontent.com} + +for command in cosign jq; do + command -v "$command" >/dev/null 2>&1 || { + echo "required command not found: $command" >&2 + exit 1 + } +done + +if [[ ! -f "$receipt" ]]; then + echo "release assurance receipt not found: $receipt" >&2 + exit 1 +fi + +identity=${COSIGN_CERTIFICATE_IDENTITY:-} +if [[ -z "$identity" ]]; then + if [[ -z "${GITHUB_WORKFLOW_REF:-}" ]]; then + echo "GITHUB_WORKFLOW_REF or COSIGN_CERTIFICATE_IDENTITY is required for keyless verification" >&2 + exit 2 + fi + identity="https://github.com/${GITHUB_WORKFLOW_REF}" +fi + +repository_without_tag() { + local ref=${1%@*} + local tail=${ref##*/} + + if [[ "$tail" == *:* ]]; then + ref=${ref%:*} + fi + + printf '%s\n' "$ref" +} + +verify_identity_args=( + --certificate-identity "$identity" + --certificate-oidc-issuer "$issuer" +) + +export COSIGN_YES=true + +# The local bundle makes the downloadable receipt independently verifiable, +# while OCI signatures and attestations make the registry artifacts discoverable. +cosign sign-blob --yes --bundle "$bundle" "$receipt" +cosign verify-blob \ + --bundle "$bundle" \ + "${verify_identity_args[@]}" \ + "$receipt" >/dev/null + +while IFS= read -r image; do + tag_ref=$(jq -r '.ref' <<<"$image") + digest=$(jq -r '.digest' <<<"$image") + repository=$(repository_without_tag "$tag_ref") + subject="${repository}@${digest}" + + if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "invalid image digest in receipt: $digest" >&2 + exit 1 + fi + + cosign sign --yes "$subject" + cosign verify "${verify_identity_args[@]}" "$subject" >/dev/null + + cosign attest --yes --type custom --predicate "$receipt" "$subject" + cosign verify-attestation \ + --type custom \ + "${verify_identity_args[@]}" \ + "$subject" >/dev/null + + while IFS= read -r sbom; do + sbom_name=$(jq -r '.name' <<<"$sbom") + sbom_source=$(jq -r '.source' <<<"$sbom") + sbom_digest=$(jq -r '.imageDigest' <<<"$sbom") + + if [[ ! -f "$sbom_name" ]]; then + echo "image SBOM not found: $sbom_name" >&2 + exit 1 + fi + if [[ "$sbom_source" != *@"$sbom_digest" ]]; then + echo "image SBOM source/digest binding mismatch: $sbom_name" >&2 + exit 1 + fi + + cosign attest --yes --type cyclonedx --predicate "$sbom_name" "$sbom_source" + cosign verify-attestation \ + --type cyclonedx \ + "${verify_identity_args[@]}" \ + "$sbom_source" >/dev/null + done < <(jq -c '.sboms[]?' <<<"$image") +done < <(jq -c '.images[]' "$receipt") + +# Re-run the entire release contract from the consumer side. Strict mode requires +# every local release asset, recomputes hashes, re-reads embedded Go VCS metadata, +# and requires the verified OCI predicates to match the exact local JSON files. +ASSURANCE_REQUIRE_LOCAL_ASSETS=true \ +COSIGN_CERTIFICATE_IDENTITY="$identity" \ +COSIGN_CERTIFICATE_OIDC_ISSUER="$issuer" \ +bash "$(dirname "$0")/verify_release_assurance.sh" \ + "$receipt" \ + "$bundle" \ + release-assurance.verdict.json diff --git a/script/generate_release_assurance.sh b/script/generate_release_assurance.sh new file mode 100644 index 0000000000..76c20c1f01 --- /dev/null +++ b/script/generate_release_assurance.sh @@ -0,0 +1,386 @@ +#!/usr/bin/env bash + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +if [[ $# -ne 5 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +version=$1 +tag=$2 +build_source_sha=$3 +release_commit_sha=$4 +cyclonedx_version=$5 +syft_version=${SYFT_VERSION:-v1.51.0} + +operator_image_ref=${OPERATOR_IMAGE_REF:-${IMAGE_NAME:-}:$version} +bundle_image_ref=${BUNDLE_IMAGE_REF:-docker.io/testcamelk/camel-k-bundle:$version} + +if [[ "$operator_image_ref" == :* ]]; then + echo "IMAGE_NAME or OPERATOR_IMAGE_REF is required to bind the operator image" >&2 + exit 2 +fi + +for command in cyclonedx-gomod docker go jq tar; do + command -v "$command" >/dev/null 2>&1 || { + echo "required command not found: $command" >&2 + exit 1 + } +done + +verify_syft() { + command -v syft >/dev/null 2>&1 || { + echo "required command not found: syft" >&2 + exit 1 + } + + local reported_version + reported_version=$(syft --version | awk '{print $NF}') + if [[ "$reported_version" != "$syft_version" && "v$reported_version" != "$syft_version" ]]; then + echo "syft binary is not the required version ${syft_version}: got ${reported_version}" >&2 + exit 1 + fi +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + echo "required command not found: sha256sum or shasum" >&2 + exit 1 + fi +} + +resolve_image() { + local ref=$1 + local kind=$2 + local manifest_json="" + local attempt + + for attempt in 1 2 3 4 5; do + if manifest_json=$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' 2>/dev/null) && \ + jq -e '.digest | strings | startswith("sha256:")' >/dev/null <<<"$manifest_json"; then + break + fi + manifest_json="" + sleep 2 + done + + if [[ -z "$manifest_json" ]]; then + echo "unable to resolve pushed $kind image manifest: $ref" >&2 + exit 1 + fi + + jq -cS \ + --arg kind "$kind" \ + --arg ref "$ref" \ + '{ + kind: $kind, + ref: $ref, + digest: .digest, + mediaType: .mediaType, + platforms: ((.manifests // []) + | map({ + digest: .digest, + mediaType: .mediaType, + platform: (.platform // null) + }) + | sort_by([.platform.os // "", .platform.architecture // "", .platform.variant // "", .digest])) + }' <<<"$manifest_json" +} + +repository_without_tag() { + local ref=${1%@*} + local tail=${ref##*/} + + if [[ "$tail" == *:* ]]; then + ref=${ref%:*} + fi + + printf '%s\n' "$ref" +} + +normalize_cyclonedx() { + local input=$1 + local output=$2 + local source=$3 + local digest=$4 + + jq -S \ + --arg source "$source" \ + --arg digest "$digest" \ + ' + del(.serialNumber, .metadata.timestamp) + | .metadata.component.properties = ((.metadata.component.properties // []) + [ + {name: "org.apache.camel-k.assurance.image.source", value: $source}, + {name: "org.apache.camel-k.assurance.image.digest", value: $digest} + ]) + ' "$input" > "$output" + + jq -e \ + --arg source "$source" \ + --arg digest "$digest" \ + ' + .bomFormat == "CycloneDX" + and (.components | type == "array") + and any(.metadata.component.properties[]?; .name == "org.apache.camel-k.assurance.image.source" and .value == $source) + and any(.metadata.component.properties[]?; .name == "org.apache.camel-k.assurance.image.digest" and .value == $digest) + ' "$output" >/dev/null +} + +scan_image_sbom() { + local repository=$1 + local digest=$2 + local output=$3 + local raw=$4 + + SYFT_CHECK_FOR_APP_UPDATE=false syft scan \ + --from registry \ + "${repository}@${digest}" \ + --source-name "$repository" \ + --source-version "$version" \ + -q \ + -o "cyclonedx-json@1.6=${raw}" + + normalize_cyclonedx "$raw" "$output" "${repository}@${digest}" "$digest" +} + +generate_image_sboms() { + local image_json=$1 + local kind=$2 + local ref repository top_digest sbom_entries platform_count + + ref=$(jq -r '.ref' <<<"$image_json") + repository=$(repository_without_tag "$ref") + top_digest=$(jq -r '.digest' <<<"$image_json") + sbom_entries="$workdir/${kind}.sboms.jsonl" + : > "$sbom_entries" + + platform_count=$(jq '[.platforms[]? | select( + .digest != null and + .platform != null and + .platform.os != null and + .platform.architecture != null and + .platform.os != "unknown" and + .platform.architecture != "unknown" + )] | length' <<<"$image_json") + + if [[ "$platform_count" -gt 0 ]]; then + while IFS=$'\t' read -r digest os architecture variant; do + local suffix output raw sbom_sha immutable_ref + suffix="${os}-${architecture}" + if [[ -n "$variant" && "$variant" != "null" ]]; then + suffix="${suffix}-${variant//\//-}" + fi + output="camel-k-image-${kind}-${suffix}.sbom.cdx.json" + raw="$workdir/${kind}-${suffix}.raw.cdx.json" + immutable_ref="${repository}@${digest}" + + scan_image_sbom "$repository" "$digest" "$output" "$raw" + sbom_sha=$(sha256_file "$output") + + jq -cn \ + --arg name "$output" \ + --arg source "$immutable_ref" \ + --arg digest "$digest" \ + --arg os "$os" \ + --arg architecture "$architecture" \ + --arg variant "$variant" \ + --arg sha256 "$sbom_sha" \ + '{ + name: $name, + format: "CycloneDX JSON 1.6", + source: $source, + imageDigest: $digest, + platform: { + os: $os, + architecture: $architecture, + variant: (if $variant == "" or $variant == "null" then null else $variant end) + }, + sha256: $sha256 + }' >> "$sbom_entries" + done < <(jq -r '.platforms[]? | select( + .digest != null and + .platform != null and + .platform.os != null and + .platform.architecture != null and + .platform.os != "unknown" and + .platform.architecture != "unknown" + ) | [.digest, .platform.os, .platform.architecture, (.platform.variant // "")] | @tsv' <<<"$image_json") + else + local output raw sbom_sha immutable_ref + output="camel-k-image-${kind}.sbom.cdx.json" + raw="$workdir/${kind}.raw.cdx.json" + immutable_ref="${repository}@${top_digest}" + + scan_image_sbom "$repository" "$top_digest" "$output" "$raw" + sbom_sha=$(sha256_file "$output") + + jq -cn \ + --arg name "$output" \ + --arg source "$immutable_ref" \ + --arg digest "$top_digest" \ + --arg sha256 "$sbom_sha" \ + '{ + name: $name, + format: "CycloneDX JSON 1.6", + source: $source, + imageDigest: $digest, + platform: null, + sha256: $sha256 + }' >> "$sbom_entries" + fi + + jq -s '.' "$sbom_entries" +} + +verify_syft + +workdir=$(mktemp -d) +entries="$workdir/artifacts.jsonl" +trap 'rm -rf "$workdir"' EXIT + +shopt -s nullglob +archives=(camel-k-client-"$version"-*.tar.gz) +shopt -u nullglob + +if [[ ${#archives[@]} -eq 0 ]]; then + echo "no Camel K client archives found for version $version" >&2 + exit 1 +fi + +IFS=$'\n' archives=($(printf '%s\n' "${archives[@]}" | sort)) +unset IFS + +for archive in "${archives[@]}"; do + target=${archive#camel-k-client-"$version"-} + target=${target%.tar.gz} + + case "$target" in + windows-*) binary_name=kamel.exe ;; + *) binary_name=kamel ;; + esac + + extract_dir="$workdir/$target" + mkdir -p "$extract_dir" + tar -xzf "$archive" -C "$extract_dir" "$binary_name" + chmod +x "$extract_dir/$binary_name" + + sbom="${archive%.tar.gz}.sbom.cdx.json" + cyclonedx-gomod bin \ + -json \ + -noserial \ + -notimestamp \ + -version "$version" \ + -output "$sbom" \ + "$extract_dir/$binary_name" + + binary_source_sha=$(go version -m "$extract_dir/$binary_name" | sed -n 's/^[[:space:]]*build[[:space:]]*vcs\.revision=//p' | head -n 1) + if [[ -z "$binary_source_sha" ]]; then + echo "binary source revision missing for $archive" >&2 + exit 1 + fi + if [[ "$binary_source_sha" != "$build_source_sha" ]]; then + echo "binary source revision mismatch for $archive: expected $build_source_sha, got $binary_source_sha" >&2 + exit 1 + fi + + archive_sha=$(sha256_file "$archive") + sbom_sha=$(sha256_file "$sbom") + + jq -cn \ + --arg name "$archive" \ + --arg target "$target" \ + --arg sha256 "$archive_sha" \ + --arg binarySourceSha "$binary_source_sha" \ + --arg sbomName "$sbom" \ + --arg sbomSha256 "$sbom_sha" \ + '{ + name: $name, + target: $target, + sha256: $sha256, + binarySourceSha: $binarySourceSha, + sbom: { + name: $sbomName, + format: "CycloneDX JSON", + mode: "binary", + sha256: $sbomSha256 + } + }' >> "$entries" +done + +module_sbom=null +if [[ -f sbom.json ]]; then + module_sbom=$(jq -cn \ + --arg name "sbom.json" \ + --arg sha256 "$(sha256_file sbom.json)" \ + '{name: $name, format: "CycloneDX JSON", mode: "module", sha256: $sha256}') +fi + +operator_image=$(resolve_image "$operator_image_ref" "operator") +bundle_image=$(resolve_image "$bundle_image_ref" "olm-bundle") +operator_sboms=$(generate_image_sboms "$operator_image" "operator") +bundle_sboms=$(generate_image_sboms "$bundle_image" "olm-bundle") +operator_image=$(jq -cS --argjson sboms "$operator_sboms" '. + {sboms: $sboms}' <<<"$operator_image") +bundle_image=$(jq -cS --argjson sboms "$bundle_sboms" '. + {sboms: $sboms}' <<<"$bundle_image") +buildx_version=$(docker buildx version | head -n 1) +syft_build=$syft_version + +jq -s '.' "$entries" > "$workdir/artifacts.json" + +jq -Sn \ + --argjson schemaVersion 1 \ + --arg version "$version" \ + --arg tag "$tag" \ + --arg buildSourceSha "$build_source_sha" \ + --arg releaseCommitSha "$release_commit_sha" \ + --arg generatorModule "github.com/CycloneDX/cyclonedx-gomod" \ + --arg generatorVersion "$cyclonedx_version" \ + --arg imageSbomGenerator "github.com/anchore/syft" \ + --arg imageSbomGeneratorVersion "$syft_build" \ + --arg registryResolver "$buildx_version" \ + --slurpfile artifacts "$workdir/artifacts.json" \ + --argjson moduleSbom "$module_sbom" \ + --argjson operatorImage "$operator_image" \ + --argjson bundleImage "$bundle_image" \ + '{ + schemaVersion: $schemaVersion, + release: { + version: $version, + tag: $tag, + buildSourceSha: $buildSourceSha, + releaseCommitSha: $releaseCommitSha + }, + generator: { + module: $generatorModule, + version: $generatorVersion, + imageSbom: { + module: $imageSbomGenerator, + version: $imageSbomGeneratorVersion + }, + registryResolver: $registryResolver + }, + moduleSbom: $moduleSbom, + artifacts: $artifacts[0], + images: [$operatorImage, $bundleImage] + }' > release-assurance.json + +printf '%s %s\n' "$(sha256_file release-assurance.json)" "release-assurance.json" > release-assurance.json.sha256 diff --git a/script/verify_release_assurance.sh b/script/verify_release_assurance.sh new file mode 100644 index 0000000000..de642135f5 --- /dev/null +++ b/script/verify_release_assurance.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -uo pipefail + +mode=receipt +if [[ ${1:-} == "--image" ]]; then + mode=image + shift +fi + +issuer=${COSIGN_CERTIFICATE_OIDC_ISSUER:-https://token.actions.githubusercontent.com} +identity=${COSIGN_CERTIFICATE_IDENTITY:-} +if [[ -z "$identity" && -n "${GITHUB_WORKFLOW_REF:-}" ]]; then + identity="https://github.com/${GITHUB_WORKFLOW_REF}" +fi + +verdict_file=${ASSURANCE_VERDICT_FILE:-release-assurance.verdict.json} +require_local=${ASSURANCE_REQUIRE_LOCAL_ASSETS:-false} +errors_file=$(mktemp) +checks_file=$(mktemp) +warnings_file=$(mktemp) +trap 'rm -f "$errors_file" "$checks_file" "$warnings_file"' EXIT +: >"$errors_file" +: >"$checks_file" +: >"$warnings_file" + +record_check() { + jq -cn --arg name "$1" --arg status "$2" --arg detail "$3" \ + '{name:$name,status:$status,detail:$detail}' >>"$checks_file" +} +reject() { printf '%s\n' "$1" >>"$errors_file"; record_check "$2" "fail" "$1"; } +pass() { record_check "$1" "pass" "$2"; } +warn() { printf '%s\n' "$1" >>"$warnings_file"; record_check "$2" "warn" "$1"; } + +finish() { + local verdict="TRUST" + [[ -s "$errors_file" ]] && verdict="REJECT" + jq -s '.' "$checks_file" >"${checks_file}.json" + if [[ -s "$errors_file" ]]; then + jq -R -s 'split("\n")[:-1]' "$errors_file" >"${errors_file}.json" + else + echo '[]' >"${errors_file}.json" + fi + if [[ -s "$warnings_file" ]]; then + jq -R -s 'split("\n")[:-1]' "$warnings_file" >"${warnings_file}.json" + else + echo '[]' >"${warnings_file}.json" + fi + jq -n \ + --arg verdict "$verdict" \ + --arg mode "$mode" \ + --slurpfile checks "${checks_file}.json" \ + --slurpfile errors "${errors_file}.json" \ + --slurpfile warnings "${warnings_file}.json" \ + '{verdict:$verdict,mode:$mode,checks:$checks[0],errors:$errors[0],warnings:$warnings[0]}' \ + >"$verdict_file" + cat "$verdict_file" + [[ "$verdict" == TRUST ]] +} + +for command in jq cosign base64; do + if ! command -v "$command" >/dev/null 2>&1; then + reject "required command not found: $command" "tool:$command" + else + pass "tool:$command" "available" + fi +done +if command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1; then + pass "tool:sha256" "available" +else + reject "required command not found: sha256sum or shasum" "tool:sha256" +fi +if [[ -z "$identity" ]]; then + reject "COSIGN_CERTIFICATE_IDENTITY or GITHUB_WORKFLOW_REF is required" "identity" +else + pass "identity" "$identity" +fi +if [[ -s "$errors_file" ]]; then + finish + exit $? +fi + +verify_identity_args=( + --certificate-identity "$identity" + --certificate-oidc-issuer "$issuer" +) + +base64_decode() { + if base64 --decode /dev/null 2>&1; then + base64 --decode + else + base64 -D + fi +} + +sha256_stdin() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + else + shasum -a 256 | awk '{print $1}' + fi +} + +canonical_json_hash() { + jq -cS . "$1" | sha256_stdin +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +repository_without_tag() { + local ref=${1%@*} + local tail=${ref##*/} + [[ "$tail" == *:* ]] && ref=${ref%:*} + printf '%s\n' "$ref" +} + +verify_attested_json_file() { + local type=$1 + local subject=$2 + local file=$3 + local name=$4 + local out target_hash matched=0 + + if ! out=$(cosign verify-attestation --type "$type" "${verify_identity_args[@]}" "$subject" 2>/dev/null); then + reject "attestation verification failed for $subject ($type)" "$name:signature" + return + fi + + target_hash=$(canonical_json_hash "$file") || { + reject "cannot canonicalize $file" "$name:local" + return + } + + while IFS= read -r payload; do + [[ -z "$payload" ]] && continue + local data tmp hash + data=$(printf '%s' "$payload" | base64_decode 2>/dev/null | jq -r '.predicate.Data // empty' 2>/dev/null || true) + [[ -z "$data" ]] && continue + tmp=$(mktemp) + printf '%s' "$data" >"$tmp" + if jq -e . "$tmp" >/dev/null 2>&1; then + hash=$(canonical_json_hash "$tmp") + [[ "$hash" == "$target_hash" ]] && matched=1 + fi + rm -f "$tmp" + done < <(printf '%s\n' "$out" | jq -r \ + 'if type == "array" then .[]?.payload // empty else .payload // empty end' 2>/dev/null) + + if [[ $matched -eq 1 ]]; then + pass "$name" "signed predicate matches local JSON" + else + reject "no verified $type attestation matches local $file" "$name" + fi +} + +if [[ "$mode" == image ]]; then + subject=${1:-} + verdict_file=${2:-$verdict_file} + + if [[ ! "$subject" =~ @sha256:[0-9a-f]{64}$ ]]; then + reject "image subject must be immutable @sha256 reference" "image:subject" + finish + exit $? + fi + + if cosign verify "${verify_identity_args[@]}" "$subject" >/dev/null 2>&1; then + pass "image:signature" "$subject" + else + reject "image signature verification failed: $subject" "image:signature" + fi + if cosign verify-attestation --type custom "${verify_identity_args[@]}" "$subject" >/dev/null 2>&1; then + pass "image:receipt-attestation" "present" + else + reject "release receipt attestation missing or invalid: $subject" "image:receipt-attestation" + fi + if cosign verify-attestation --type cyclonedx "${verify_identity_args[@]}" "$subject" >/dev/null 2>&1; then + pass "image:sbom-attestation" "present" + else + reject "CycloneDX attestation missing or invalid: $subject" "image:sbom-attestation" + fi + + finish + exit $? +fi + +receipt=${1:-release-assurance.json} +bundle=${2:-release-assurance.sigstore.json} +verdict_file=${3:-$verdict_file} +asset_dir=${ASSURANCE_ASSET_DIR:-$(dirname "$receipt")} + +if [[ ! -f "$receipt" ]]; then + reject "receipt not found: $receipt" "receipt:file" + finish + exit $? +fi + +if jq -e '.schemaVersion == 1 and (.images|type=="array") and (.artifacts|type=="array")' \ + "$receipt" >/dev/null 2>&1; then + pass "receipt:schema" "schemaVersion=1" +else + reject "invalid or unsupported receipt schema" "receipt:schema" +fi + +if [[ ! -f "$bundle" ]]; then + reject "Sigstore bundle not found: $bundle" "receipt:bundle" +elif cosign verify-blob --bundle "$bundle" "${verify_identity_args[@]}" "$receipt" >/dev/null 2>&1; then + pass "receipt:signature" "Sigstore bundle verified" +else + reject "receipt Sigstore bundle verification failed" "receipt:signature" +fi + +sidecar="${receipt}.sha256" +[[ "$receipt" == release-assurance.json ]] && \ + sidecar="$(dirname "$receipt")/release-assurance.json.sha256" +if [[ -f "$sidecar" ]]; then + expected=$(awk '{print $1}' "$sidecar") + actual=$(sha256_file "$receipt") + if [[ "$expected" == "$actual" ]]; then + pass "receipt:sha256" "$actual" + else + reject "receipt SHA-256 mismatch" "receipt:sha256" + fi +elif [[ "$require_local" == true ]]; then + reject "receipt SHA-256 sidecar missing" "receipt:sha256" +else + warn "receipt SHA-256 sidecar not present" "receipt:sha256" +fi + +build_sha=$(jq -r '.release.buildSourceSha // empty' "$receipt") +release_sha=$(jq -r '.release.releaseCommitSha // empty' "$receipt") +[[ "$build_sha" =~ ^[0-9a-f]{40,64}$ ]] && \ + pass "release:build-source" "$build_sha" || reject "invalid buildSourceSha" "release:build-source" +[[ "$release_sha" =~ ^[0-9a-f]{40,64}$ ]] && \ + pass "release:commit" "$release_sha" || reject "invalid releaseCommitSha" "release:commit" + +module_name=$(jq -r '.moduleSbom.name // empty' "$receipt") +if [[ -n "$module_name" ]]; then + file="$asset_dir/$(basename "$module_name")" + if [[ -f "$file" ]]; then + expected=$(jq -r '.moduleSbom.sha256' "$receipt") + actual=$(sha256_file "$file") + [[ "$expected" == "$actual" ]] && \ + pass "module-sbom:sha256" "$actual" || reject "module SBOM hash mismatch" "module-sbom:sha256" + elif [[ "$require_local" == true ]]; then + reject "module SBOM missing: $file" "module-sbom:file" + else + warn "module SBOM not downloaded: $file" "module-sbom:file" + fi +fi + +while IFS= read -r artifact; do + name=$(jq -r '.name' <<<"$artifact") + sbom=$(jq -r '.sbom.name' <<<"$artifact") + source_sha=$(jq -r '.binarySourceSha' <<<"$artifact") + + [[ "$source_sha" == "$build_sha" ]] && \ + pass "artifact:$name:source" "$source_sha" || \ + reject "binary source SHA does not match build source for $name" "artifact:$name:source" + + expected=$(jq -r '.sha256' <<<"$artifact") + file="$asset_dir/$(basename "$name")" + if [[ -f "$file" ]]; then + actual=$(sha256_file "$file") + [[ "$actual" == "$expected" ]] && \ + pass "artifact:$name:sha256" "$actual" || reject "hash mismatch: $name" "artifact:$name:sha256" + + if command -v tar >/dev/null 2>&1 && command -v go >/dev/null 2>&1; then + extract_dir=$(mktemp -d) + binary_name=kamel + target=$(jq -r '.target // empty' <<<"$artifact") + [[ "$target" == windows-* ]] && binary_name=kamel.exe + if tar -xzf "$file" -C "$extract_dir" "$binary_name" >/dev/null 2>&1; then + embedded=$(go version -m "$extract_dir/$binary_name" 2>/dev/null | \ + sed -n 's/^[[:space:]]*build[[:space:]]*vcs\.revision=//p' | head -n 1) + [[ "$embedded" == "$build_sha" ]] && \ + pass "artifact:$name:embedded-source" "$embedded" || \ + reject "embedded binary source revision mismatch: $name" "artifact:$name:embedded-source" + else + reject "cannot extract release binary from $name" "artifact:$name:embedded-source" + fi + rm -rf "$extract_dir" + elif [[ "$require_local" == true ]]; then + reject "go and tar are required for strict binary source verification" "artifact:$name:embedded-source" + else + warn "go/tar unavailable; embedded source revision not independently checked" \ + "artifact:$name:embedded-source" + fi + elif [[ "$require_local" == true ]]; then + reject "local asset missing: $name" "artifact:$name:file" + else + warn "local asset not downloaded: $name" "artifact:$name:file" + fi + + expected=$(jq -r '.sbom.sha256' <<<"$artifact") + file="$asset_dir/$(basename "$sbom")" + if [[ -f "$file" ]]; then + actual=$(sha256_file "$file") + [[ "$actual" == "$expected" ]] && \ + pass "artifact:$name:sbom:sha256" "$actual" || reject "hash mismatch: $sbom" "artifact:$name:sbom:sha256" + elif [[ "$require_local" == true ]]; then + reject "local asset missing: $sbom" "artifact:$name:sbom:file" + else + warn "local asset not downloaded: $sbom" "artifact:$name:sbom:file" + fi +done < <(jq -c '.artifacts[]' "$receipt") + +while IFS= read -r image; do + kind=$(jq -r '.kind' <<<"$image") + ref=$(jq -r '.ref' <<<"$image") + digest=$(jq -r '.digest' <<<"$image") + repository=$(repository_without_tag "$ref") + subject="${repository}@${digest}" + + if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + reject "invalid image digest for $kind" "image:$kind:digest" + continue + fi + + if cosign verify "${verify_identity_args[@]}" "$subject" >/dev/null 2>&1; then + pass "image:$kind:signature" "$subject" + else + reject "image signature verification failed: $subject" "image:$kind:signature" + fi + + verify_attested_json_file custom "$subject" "$receipt" "image:$kind:receipt-attestation" + + while IFS= read -r sbom; do + name=$(jq -r '.name' <<<"$sbom") + source=$(jq -r '.source' <<<"$sbom") + digest=$(jq -r '.imageDigest' <<<"$sbom") + expected=$(jq -r '.sha256' <<<"$sbom") + file="$asset_dir/$(basename "$name")" + + [[ "$source" == *@"$digest" ]] && \ + pass "image:$kind:sbom:$name:binding" "$source" || \ + reject "SBOM source/digest mismatch: $name" "image:$kind:sbom:$name:binding" + + if [[ -f "$file" ]]; then + actual=$(sha256_file "$file") + [[ "$actual" == "$expected" ]] && \ + pass "image:$kind:sbom:$name:sha256" "$actual" || \ + reject "image SBOM hash mismatch: $name" "image:$kind:sbom:$name:sha256" + verify_attested_json_file cyclonedx "$source" "$file" \ + "image:$kind:sbom:$name:attestation" + elif [[ "$require_local" == true ]]; then + reject "image SBOM missing: $file" "image:$kind:sbom:$name:file" + else + warn "image SBOM not downloaded; verifying attestation existence only: $name" \ + "image:$kind:sbom:$name:file" + if cosign verify-attestation --type cyclonedx "${verify_identity_args[@]}" \ + "$source" >/dev/null 2>&1; then + pass "image:$kind:sbom:$name:attestation" "verified" + else + reject "CycloneDX attestation verification failed: $source" \ + "image:$kind:sbom:$name:attestation" + fi + fi + done < <(jq -c '.sboms[]?' <<<"$image") +done < <(jq -c '.images[]' "$receipt") + +finish From 4373afc5dbd7153d6acf797b30711e9d5bb7a5da Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Wed, 26 Aug 2026 18:04:25 -0400 Subject: [PATCH 02/11] fix(release): pin assurance tools by commit --- script/release-assurance-tools.env | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 script/release-assurance-tools.env diff --git a/script/release-assurance-tools.env b/script/release-assurance-tools.env new file mode 100644 index 0000000000..a775d2fd81 --- /dev/null +++ b/script/release-assurance-tools.env @@ -0,0 +1,25 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Keep the release-facing versions together with the immutable commits used +# to install them. The refs below are the commits referenced by these tags. +CYCLONEDX_GOMOD_VERSION=v1.12.0 +CYCLONEDX_GOMOD_REF=07257d5b9cbd2a3d4338a880c0ca50081e1ac445 + +SYFT_VERSION=v1.51.0 +SYFT_REF=2293641e3bd628a01bb37639318d62c0ebe89b39 + +COSIGN_VERSION=v3.1.3 +COSIGN_REF=11926fa5bbbbde47e88fc006b625a17769b743b2 From 9155159e9b2b70783b4117857f2cc738599e975c Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Wed, 26 Aug 2026 18:04:57 -0400 Subject: [PATCH 03/11] fix(release): address assurance review feedback --- .github/actions/release-nightly/action.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/actions/release-nightly/action.yml b/.github/actions/release-nightly/action.yml index b5a744111e..dc04a6336a 100644 --- a/.github/actions/release-nightly/action.yml +++ b/.github/actions/release-nightly/action.yml @@ -113,14 +113,11 @@ runs: - name: Install assurance tools shell: bash - env: - CYCLONEDX_GOMOD_VERSION: v1.12.0 - SYFT_VERSION: v1.51.0 - COSIGN_VERSION: v3.1.3 run: | - go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@${CYCLONEDX_GOMOD_VERSION} - go install github.com/anchore/syft/cmd/syft@${SYFT_VERSION} - go install github.com/sigstore/cosign/v3/cmd/cosign@${COSIGN_VERSION} + source script/release-assurance-tools.env + go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@${CYCLONEDX_GOMOD_REF} + go install github.com/anchore/syft/cmd/syft@${SYFT_REF} + go install github.com/sigstore/cosign/v3/cmd/cosign@${COSIGN_REF} echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" go version -m "$(go env GOPATH)/bin/cyclonedx-gomod" | grep -F "github.com/CycloneDX/cyclonedx-gomod ${CYCLONEDX_GOMOD_VERSION}" "$(go env GOPATH)/bin/syft" --version | grep -E '1\.51\.0$' @@ -145,12 +142,10 @@ runs: - name: Generate release assurance shell: bash env: - CYCLONEDX_GOMOD_VERSION: v1.12.0 - SYFT_VERSION: v1.51.0 SYFT_CHECK_FOR_APP_UPDATE: "false" - SYFT_REGISTRY_AUTH_USERNAME: ${{ inputs.secretDockerHubUser }} - SYFT_REGISTRY_AUTH_PASSWORD: ${{ inputs.secretDockerHubPassword }} run: | + source script/release-assurance-tools.env + export SYFT_VERSION bash script/generate_release_assurance.sh \ "${{ env.VERSION }}" \ "${{ env.TAG }}" \ From 2f4c8147c5d67f6809f10833f095d44c6e2a825f Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Wed, 26 Aug 2026 18:05:06 -0400 Subject: [PATCH 04/11] docs(release): explain keyless OIDC permission --- .github/workflows/nightly-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 1d6afc70ec..386824fb3b 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -30,6 +30,7 @@ env: permissions: contents: write + # Required by Cosign keyless signing to mint the GitHub Actions OIDC token. id-token: write on: From 7976c8239a609b56f6fdf2cc2713ed9944d36a32 Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Wed, 26 Aug 2026 18:06:25 -0400 Subject: [PATCH 05/11] fix(release): use centralized Syft version check --- .github/actions/release-nightly/action.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/release-nightly/action.yml b/.github/actions/release-nightly/action.yml index dc04a6336a..919595ef4e 100644 --- a/.github/actions/release-nightly/action.yml +++ b/.github/actions/release-nightly/action.yml @@ -120,7 +120,8 @@ runs: go install github.com/sigstore/cosign/v3/cmd/cosign@${COSIGN_REF} echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" go version -m "$(go env GOPATH)/bin/cyclonedx-gomod" | grep -F "github.com/CycloneDX/cyclonedx-gomod ${CYCLONEDX_GOMOD_VERSION}" - "$(go env GOPATH)/bin/syft" --version | grep -E '1\.51\.0$' + reported_syft_version=$("$(go env GOPATH)/bin/syft" --version | awk '{print $NF}') + test "$reported_syft_version" = "${SYFT_VERSION#v}" go version -m "$(go env GOPATH)/bin/cosign" | grep -F "github.com/sigstore/cosign/v3 ${COSIGN_VERSION}" - name: Generate module SBOM From 38f74163fa1961e5bc9679e3475f6508f1356b15 Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Thu, 27 Aug 2026 11:10:02 -0400 Subject: [PATCH 06/11] fix(ci): preserve no-op nightly tag push --- .github/actions/release-nightly/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/release-nightly/action.yml b/.github/actions/release-nightly/action.yml index 919595ef4e..5cf8dff074 100644 --- a/.github/actions/release-nightly/action.yml +++ b/.github/actions/release-nightly/action.yml @@ -170,7 +170,7 @@ runs: CI_TOKEN: ${{ inputs.secretGithubToken }} run: | git tag -f "${{ env.TAG }}" "${{ env.COMMIT_ID }}" - git push "https://$CI_USER:$CI_TOKEN@github.com/$GITHUB_REPOSITORY.git" "refs/tags/${{ env.TAG }}" --force + git push "https://$CI_USER:$CI_TOKEN@github.com/$GITHUB_REPOSITORY.git" "refs/tags/${{ env.TAG }}" --force || echo "No nightly release updates changes to push" - name: Create release first time shell: bash From 01ecf2041812558f59adee3e4a1f658d362ea99d Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Fri, 28 Aug 2026 22:39:06 -0400 Subject: [PATCH 07/11] ci: keep nightly assurance unsigned --- .github/actions/release-nightly/action.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/actions/release-nightly/action.yml b/.github/actions/release-nightly/action.yml index 5cf8dff074..084f7fac22 100644 --- a/.github/actions/release-nightly/action.yml +++ b/.github/actions/release-nightly/action.yml @@ -117,12 +117,10 @@ runs: source script/release-assurance-tools.env go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@${CYCLONEDX_GOMOD_REF} go install github.com/anchore/syft/cmd/syft@${SYFT_REF} - go install github.com/sigstore/cosign/v3/cmd/cosign@${COSIGN_REF} echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" go version -m "$(go env GOPATH)/bin/cyclonedx-gomod" | grep -F "github.com/CycloneDX/cyclonedx-gomod ${CYCLONEDX_GOMOD_VERSION}" reported_syft_version=$("$(go env GOPATH)/bin/syft" --version | awk '{print $NF}') test "$reported_syft_version" = "${SYFT_VERSION#v}" - go version -m "$(go env GOPATH)/bin/cosign" | grep -F "github.com/sigstore/cosign/v3 ${COSIGN_VERSION}" - name: Generate module SBOM shell: bash @@ -154,14 +152,13 @@ runs: "${{ env.COMMIT_ID }}" \ "$CYCLONEDX_GOMOD_VERSION" - - name: Sign and attest release assurance + - name: Verify nightly release assurance shell: bash - env: - COSIGN_YES: "true" run: | - bash script/attest_release_assurance.sh \ + ASSURANCE_REQUIRE_LOCAL_ASSETS=true \ + bash script/verify_release_assurance.sh \ release-assurance.json \ - release-assurance.sigstore.json + release-assurance.verdict.json - name: Publish nightly tag shell: bash @@ -217,6 +214,8 @@ runs: NOTE: last update on ${{ env.UPD_DATE }} ``` Apache Camel K ${{ env.VERSION }} build for testing purposes only (unstable). This nightly release is using an **unsupported** operator image published as `${{ env.IMAGE_NAME }}:${{ env.VERSION }}`. The available platforms are AMD64 and ARM64. + + The attached `release-assurance.json` and CycloneDX files are unsigned nightly integrity evidence. They bind the generated SBOMs to the exact nightly client and image digests, but they are not Apache release signatures and do not represent a voted release. ## Kubectl ``` kubectl apply -k github.com/apache/camel-k/install/overlays/all-namespaces?ref=${{ env.TAG }} @@ -273,7 +272,7 @@ runs: shell: bash run: | RELEASE_ID=${{ env.RELEASE_ID }} - FILES="./camel-k-client*.tar.gz sbom.json ./camel-k-client*.sbom.cdx.json ./camel-k-image-*.sbom.cdx.json release-assurance.json release-assurance.json.sha256 release-assurance.sigstore.json" + FILES="./camel-k-client*.tar.gz sbom.json ./camel-k-client*.sbom.cdx.json ./camel-k-image-*.sbom.cdx.json release-assurance.json release-assurance.json.sha256 release-assurance.verdict.json" for FILE in $FILES; do if [ -f "$FILE" ]; then From f5c905f508004239121535f86816033f4397c6ab Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Fri, 28 Aug 2026 22:39:23 -0400 Subject: [PATCH 08/11] ci: drop nightly OIDC signing permission --- .github/workflows/nightly-release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 386824fb3b..554b1a865b 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -30,8 +30,6 @@ env: permissions: contents: write - # Required by Cosign keyless signing to mint the GitHub Actions OIDC token. - id-token: write on: schedule: From 37e7317d2d8dca105931a0bcf6148656ea4dfa67 Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Fri, 28 Aug 2026 22:39:29 -0400 Subject: [PATCH 09/11] build: remove Cosign from nightly toolchain --- script/release-assurance-tools.env | 3 --- 1 file changed, 3 deletions(-) diff --git a/script/release-assurance-tools.env b/script/release-assurance-tools.env index a775d2fd81..5d5f21c7cb 100644 --- a/script/release-assurance-tools.env +++ b/script/release-assurance-tools.env @@ -20,6 +20,3 @@ CYCLONEDX_GOMOD_REF=07257d5b9cbd2a3d4338a880c0ca50081e1ac445 SYFT_VERSION=v1.51.0 SYFT_REF=2293641e3bd628a01bb37639318d62c0ebe89b39 - -COSIGN_VERSION=v3.1.3 -COSIGN_REF=11926fa5bbbbde47e88fc006b625a17769b743b2 From 66191478956f69095f9482039da60f8ac58ef8bf Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Fri, 28 Aug 2026 22:39:37 -0400 Subject: [PATCH 10/11] ci: remove nightly signing authority --- script/attest_release_assurance.sh | 122 ----------------------------- 1 file changed, 122 deletions(-) delete mode 100644 script/attest_release_assurance.sh diff --git a/script/attest_release_assurance.sh b/script/attest_release_assurance.sh deleted file mode 100644 index 9a3453795d..0000000000 --- a/script/attest_release_assurance.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env bash - -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -euo pipefail - -receipt=${1:-release-assurance.json} -bundle=${2:-release-assurance.sigstore.json} -issuer=${COSIGN_CERTIFICATE_OIDC_ISSUER:-https://token.actions.githubusercontent.com} - -for command in cosign jq; do - command -v "$command" >/dev/null 2>&1 || { - echo "required command not found: $command" >&2 - exit 1 - } -done - -if [[ ! -f "$receipt" ]]; then - echo "release assurance receipt not found: $receipt" >&2 - exit 1 -fi - -identity=${COSIGN_CERTIFICATE_IDENTITY:-} -if [[ -z "$identity" ]]; then - if [[ -z "${GITHUB_WORKFLOW_REF:-}" ]]; then - echo "GITHUB_WORKFLOW_REF or COSIGN_CERTIFICATE_IDENTITY is required for keyless verification" >&2 - exit 2 - fi - identity="https://github.com/${GITHUB_WORKFLOW_REF}" -fi - -repository_without_tag() { - local ref=${1%@*} - local tail=${ref##*/} - - if [[ "$tail" == *:* ]]; then - ref=${ref%:*} - fi - - printf '%s\n' "$ref" -} - -verify_identity_args=( - --certificate-identity "$identity" - --certificate-oidc-issuer "$issuer" -) - -export COSIGN_YES=true - -# The local bundle makes the downloadable receipt independently verifiable, -# while OCI signatures and attestations make the registry artifacts discoverable. -cosign sign-blob --yes --bundle "$bundle" "$receipt" -cosign verify-blob \ - --bundle "$bundle" \ - "${verify_identity_args[@]}" \ - "$receipt" >/dev/null - -while IFS= read -r image; do - tag_ref=$(jq -r '.ref' <<<"$image") - digest=$(jq -r '.digest' <<<"$image") - repository=$(repository_without_tag "$tag_ref") - subject="${repository}@${digest}" - - if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "invalid image digest in receipt: $digest" >&2 - exit 1 - fi - - cosign sign --yes "$subject" - cosign verify "${verify_identity_args[@]}" "$subject" >/dev/null - - cosign attest --yes --type custom --predicate "$receipt" "$subject" - cosign verify-attestation \ - --type custom \ - "${verify_identity_args[@]}" \ - "$subject" >/dev/null - - while IFS= read -r sbom; do - sbom_name=$(jq -r '.name' <<<"$sbom") - sbom_source=$(jq -r '.source' <<<"$sbom") - sbom_digest=$(jq -r '.imageDigest' <<<"$sbom") - - if [[ ! -f "$sbom_name" ]]; then - echo "image SBOM not found: $sbom_name" >&2 - exit 1 - fi - if [[ "$sbom_source" != *@"$sbom_digest" ]]; then - echo "image SBOM source/digest binding mismatch: $sbom_name" >&2 - exit 1 - fi - - cosign attest --yes --type cyclonedx --predicate "$sbom_name" "$sbom_source" - cosign verify-attestation \ - --type cyclonedx \ - "${verify_identity_args[@]}" \ - "$sbom_source" >/dev/null - done < <(jq -c '.sboms[]?' <<<"$image") -done < <(jq -c '.images[]' "$receipt") - -# Re-run the entire release contract from the consumer side. Strict mode requires -# every local release asset, recomputes hashes, re-reads embedded Go VCS metadata, -# and requires the verified OCI predicates to match the exact local JSON files. -ASSURANCE_REQUIRE_LOCAL_ASSETS=true \ -COSIGN_CERTIFICATE_IDENTITY="$identity" \ -COSIGN_CERTIFICATE_OIDC_ISSUER="$issuer" \ -bash "$(dirname "$0")/verify_release_assurance.sh" \ - "$receipt" \ - "$bundle" \ - release-assurance.verdict.json From 7760d64ef56c2eaffe76c582e3dd7d1c30df2121 Mon Sep 17 00:00:00 2001 From: Robert McConnell Date: Fri, 28 Aug 2026 22:40:08 -0400 Subject: [PATCH 11/11] ci: verify nightly assurance without signatures --- script/verify_release_assurance.sh | 324 +++++++++++------------------ 1 file changed, 118 insertions(+), 206 deletions(-) diff --git a/script/verify_release_assurance.sh b/script/verify_release_assurance.sh index de642135f5..bcf06c7e40 100644 --- a/script/verify_release_assurance.sh +++ b/script/verify_release_assurance.sh @@ -17,24 +17,15 @@ set -uo pipefail -mode=receipt -if [[ ${1:-} == "--image" ]]; then - mode=image - shift -fi - -issuer=${COSIGN_CERTIFICATE_OIDC_ISSUER:-https://token.actions.githubusercontent.com} -identity=${COSIGN_CERTIFICATE_IDENTITY:-} -if [[ -z "$identity" && -n "${GITHUB_WORKFLOW_REF:-}" ]]; then - identity="https://github.com/${GITHUB_WORKFLOW_REF}" -fi - -verdict_file=${ASSURANCE_VERDICT_FILE:-release-assurance.verdict.json} +receipt=${1:-release-assurance.json} +verdict_file=${2:-${ASSURANCE_VERDICT_FILE:-release-assurance.verdict.json}} +asset_dir=${ASSURANCE_ASSET_DIR:-$(dirname "$receipt")} require_local=${ASSURANCE_REQUIRE_LOCAL_ASSETS:-false} + errors_file=$(mktemp) checks_file=$(mktemp) warnings_file=$(mktemp) -trap 'rm -f "$errors_file" "$checks_file" "$warnings_file"' EXIT +trap 'rm -f "$errors_file" "$checks_file" "$warnings_file" "${errors_file}.json" "${checks_file}.json" "${warnings_file}.json"' EXIT : >"$errors_file" : >"$checks_file" : >"$warnings_file" @@ -48,8 +39,9 @@ pass() { record_check "$1" "pass" "$2"; } warn() { printf '%s\n' "$1" >>"$warnings_file"; record_check "$2" "warn" "$1"; } finish() { - local verdict="TRUST" + local verdict="VALID" [[ -s "$errors_file" ]] && verdict="REJECT" + jq -s '.' "$checks_file" >"${checks_file}.json" if [[ -s "$errors_file" ]]; then jq -R -s 'split("\n")[:-1]' "$errors_file" >"${errors_file}.json" @@ -61,63 +53,18 @@ finish() { else echo '[]' >"${warnings_file}.json" fi + jq -n \ --arg verdict "$verdict" \ - --arg mode "$mode" \ + --arg assuranceClass "unsigned-nightly-integrity" \ --slurpfile checks "${checks_file}.json" \ --slurpfile errors "${errors_file}.json" \ --slurpfile warnings "${warnings_file}.json" \ - '{verdict:$verdict,mode:$mode,checks:$checks[0],errors:$errors[0],warnings:$warnings[0]}' \ + '{verdict:$verdict,assuranceClass:$assuranceClass,checks:$checks[0],errors:$errors[0],warnings:$warnings[0]}' \ >"$verdict_file" - cat "$verdict_file" - [[ "$verdict" == TRUST ]] -} -for command in jq cosign base64; do - if ! command -v "$command" >/dev/null 2>&1; then - reject "required command not found: $command" "tool:$command" - else - pass "tool:$command" "available" - fi -done -if command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1; then - pass "tool:sha256" "available" -else - reject "required command not found: sha256sum or shasum" "tool:sha256" -fi -if [[ -z "$identity" ]]; then - reject "COSIGN_CERTIFICATE_IDENTITY or GITHUB_WORKFLOW_REF is required" "identity" -else - pass "identity" "$identity" -fi -if [[ -s "$errors_file" ]]; then - finish - exit $? -fi - -verify_identity_args=( - --certificate-identity "$identity" - --certificate-oidc-issuer "$issuer" -) - -base64_decode() { - if base64 --decode /dev/null 2>&1; then - base64 --decode - else - base64 -D - fi -} - -sha256_stdin() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum | awk '{print $1}' - else - shasum -a 256 | awk '{print $1}' - fi -} - -canonical_json_hash() { - jq -cS . "$1" | sha256_stdin + cat "$verdict_file" + [[ "$verdict" == VALID ]] } sha256_file() { @@ -128,108 +75,42 @@ sha256_file() { fi } -repository_without_tag() { - local ref=${1%@*} - local tail=${ref##*/} - [[ "$tail" == *:* ]] && ref=${ref%:*} - printf '%s\n' "$ref" -} - -verify_attested_json_file() { - local type=$1 - local subject=$2 - local file=$3 - local name=$4 - local out target_hash matched=0 - - if ! out=$(cosign verify-attestation --type "$type" "${verify_identity_args[@]}" "$subject" 2>/dev/null); then - reject "attestation verification failed for $subject ($type)" "$name:signature" - return - fi - - target_hash=$(canonical_json_hash "$file") || { - reject "cannot canonicalize $file" "$name:local" - return - } - - while IFS= read -r payload; do - [[ -z "$payload" ]] && continue - local data tmp hash - data=$(printf '%s' "$payload" | base64_decode 2>/dev/null | jq -r '.predicate.Data // empty' 2>/dev/null || true) - [[ -z "$data" ]] && continue - tmp=$(mktemp) - printf '%s' "$data" >"$tmp" - if jq -e . "$tmp" >/dev/null 2>&1; then - hash=$(canonical_json_hash "$tmp") - [[ "$hash" == "$target_hash" ]] && matched=1 - fi - rm -f "$tmp" - done < <(printf '%s\n' "$out" | jq -r \ - 'if type == "array" then .[]?.payload // empty else .payload // empty end' 2>/dev/null) - - if [[ $matched -eq 1 ]]; then - pass "$name" "signed predicate matches local JSON" +for command in jq; do + if command -v "$command" >/dev/null 2>&1; then + pass "tool:$command" "available" else - reject "no verified $type attestation matches local $file" "$name" - fi -} - -if [[ "$mode" == image ]]; then - subject=${1:-} - verdict_file=${2:-$verdict_file} - - if [[ ! "$subject" =~ @sha256:[0-9a-f]{64}$ ]]; then - reject "image subject must be immutable @sha256 reference" "image:subject" - finish - exit $? + reject "required command not found: $command" "tool:$command" fi +done - if cosign verify "${verify_identity_args[@]}" "$subject" >/dev/null 2>&1; then - pass "image:signature" "$subject" - else - reject "image signature verification failed: $subject" "image:signature" - fi - if cosign verify-attestation --type custom "${verify_identity_args[@]}" "$subject" >/dev/null 2>&1; then - pass "image:receipt-attestation" "present" - else - reject "release receipt attestation missing or invalid: $subject" "image:receipt-attestation" - fi - if cosign verify-attestation --type cyclonedx "${verify_identity_args[@]}" "$subject" >/dev/null 2>&1; then - pass "image:sbom-attestation" "present" - else - reject "CycloneDX attestation missing or invalid: $subject" "image:sbom-attestation" - fi +if command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1; then + pass "tool:sha256" "available" +else + reject "required command not found: sha256sum or shasum" "tool:sha256" +fi +if [[ ! -f "$receipt" ]]; then + reject "receipt not found: $receipt" "receipt:file" finish exit $? fi -receipt=${1:-release-assurance.json} -bundle=${2:-release-assurance.sigstore.json} -verdict_file=${3:-$verdict_file} -asset_dir=${ASSURANCE_ASSET_DIR:-$(dirname "$receipt")} - -if [[ ! -f "$receipt" ]]; then - reject "receipt not found: $receipt" "receipt:file" +if [[ -s "$errors_file" ]]; then finish exit $? fi -if jq -e '.schemaVersion == 1 and (.images|type=="array") and (.artifacts|type=="array")' \ - "$receipt" >/dev/null 2>&1; then +if jq -e ' + .schemaVersion == 1 and + (.release | type == "object") and + (.images | type == "array") and + (.artifacts | type == "array") +' "$receipt" >/dev/null 2>&1; then pass "receipt:schema" "schemaVersion=1" else reject "invalid or unsupported receipt schema" "receipt:schema" fi -if [[ ! -f "$bundle" ]]; then - reject "Sigstore bundle not found: $bundle" "receipt:bundle" -elif cosign verify-blob --bundle "$bundle" "${verify_identity_args[@]}" "$receipt" >/dev/null 2>&1; then - pass "receipt:signature" "Sigstore bundle verified" -else - reject "receipt Sigstore bundle verification failed" "receipt:signature" -fi - sidecar="${receipt}.sha256" [[ "$receipt" == release-assurance.json ]] && \ sidecar="$(dirname "$receipt")/release-assurance.json.sha256" @@ -249,23 +130,32 @@ fi build_sha=$(jq -r '.release.buildSourceSha // empty' "$receipt") release_sha=$(jq -r '.release.releaseCommitSha // empty' "$receipt") -[[ "$build_sha" =~ ^[0-9a-f]{40,64}$ ]] && \ - pass "release:build-source" "$build_sha" || reject "invalid buildSourceSha" "release:build-source" -[[ "$release_sha" =~ ^[0-9a-f]{40,64}$ ]] && \ - pass "release:commit" "$release_sha" || reject "invalid releaseCommitSha" "release:commit" +if [[ "$build_sha" =~ ^[0-9a-f]{40,64}$ ]]; then + pass "release:build-source" "$build_sha" +else + reject "invalid buildSourceSha" "release:build-source" +fi +if [[ "$release_sha" =~ ^[0-9a-f]{40,64}$ ]]; then + pass "release:commit" "$release_sha" +else + reject "invalid releaseCommitSha" "release:commit" +fi module_name=$(jq -r '.moduleSbom.name // empty' "$receipt") if [[ -n "$module_name" ]]; then - file="$asset_dir/$(basename "$module_name")" - if [[ -f "$file" ]]; then - expected=$(jq -r '.moduleSbom.sha256' "$receipt") - actual=$(sha256_file "$file") - [[ "$expected" == "$actual" ]] && \ - pass "module-sbom:sha256" "$actual" || reject "module SBOM hash mismatch" "module-sbom:sha256" + module_file="$asset_dir/$(basename "$module_name")" + if [[ -f "$module_file" ]]; then + expected=$(jq -r '.moduleSbom.sha256 // empty' "$receipt") + actual=$(sha256_file "$module_file") + if [[ "$expected" == "$actual" ]]; then + pass "module-sbom:sha256" "$actual" + else + reject "module SBOM hash mismatch" "module-sbom:sha256" + fi elif [[ "$require_local" == true ]]; then - reject "module SBOM missing: $file" "module-sbom:file" + reject "module SBOM missing: $module_file" "module-sbom:file" else - warn "module SBOM not downloaded: $file" "module-sbom:file" + warn "module SBOM not downloaded: $module_file" "module-sbom:file" fi fi @@ -274,28 +164,35 @@ while IFS= read -r artifact; do sbom=$(jq -r '.sbom.name' <<<"$artifact") source_sha=$(jq -r '.binarySourceSha' <<<"$artifact") - [[ "$source_sha" == "$build_sha" ]] && \ - pass "artifact:$name:source" "$source_sha" || \ + if [[ "$source_sha" == "$build_sha" ]]; then + pass "artifact:$name:source" "$source_sha" + else reject "binary source SHA does not match build source for $name" "artifact:$name:source" + fi + artifact_file="$asset_dir/$(basename "$name")" expected=$(jq -r '.sha256' <<<"$artifact") - file="$asset_dir/$(basename "$name")" - if [[ -f "$file" ]]; then - actual=$(sha256_file "$file") - [[ "$actual" == "$expected" ]] && \ - pass "artifact:$name:sha256" "$actual" || reject "hash mismatch: $name" "artifact:$name:sha256" + if [[ -f "$artifact_file" ]]; then + actual=$(sha256_file "$artifact_file") + if [[ "$actual" == "$expected" ]]; then + pass "artifact:$name:sha256" "$actual" + else + reject "hash mismatch: $name" "artifact:$name:sha256" + fi if command -v tar >/dev/null 2>&1 && command -v go >/dev/null 2>&1; then extract_dir=$(mktemp -d) binary_name=kamel target=$(jq -r '.target // empty' <<<"$artifact") [[ "$target" == windows-* ]] && binary_name=kamel.exe - if tar -xzf "$file" -C "$extract_dir" "$binary_name" >/dev/null 2>&1; then + if tar -xzf "$artifact_file" -C "$extract_dir" "$binary_name" >/dev/null 2>&1; then embedded=$(go version -m "$extract_dir/$binary_name" 2>/dev/null | \ sed -n 's/^[[:space:]]*build[[:space:]]*vcs\.revision=//p' | head -n 1) - [[ "$embedded" == "$build_sha" ]] && \ - pass "artifact:$name:embedded-source" "$embedded" || \ + if [[ "$embedded" == "$build_sha" ]]; then + pass "artifact:$name:embedded-source" "$embedded" + else reject "embedded binary source revision mismatch: $name" "artifact:$name:embedded-source" + fi else reject "cannot extract release binary from $name" "artifact:$name:embedded-source" fi @@ -303,8 +200,7 @@ while IFS= read -r artifact; do elif [[ "$require_local" == true ]]; then reject "go and tar are required for strict binary source verification" "artifact:$name:embedded-source" else - warn "go/tar unavailable; embedded source revision not independently checked" \ - "artifact:$name:embedded-source" + warn "go/tar unavailable; embedded source revision not independently checked" "artifact:$name:embedded-source" fi elif [[ "$require_local" == true ]]; then reject "local asset missing: $name" "artifact:$name:file" @@ -312,12 +208,15 @@ while IFS= read -r artifact; do warn "local asset not downloaded: $name" "artifact:$name:file" fi + sbom_file="$asset_dir/$(basename "$sbom")" expected=$(jq -r '.sbom.sha256' <<<"$artifact") - file="$asset_dir/$(basename "$sbom")" - if [[ -f "$file" ]]; then - actual=$(sha256_file "$file") - [[ "$actual" == "$expected" ]] && \ - pass "artifact:$name:sbom:sha256" "$actual" || reject "hash mismatch: $sbom" "artifact:$name:sbom:sha256" + if [[ -f "$sbom_file" ]]; then + actual=$(sha256_file "$sbom_file") + if [[ "$actual" == "$expected" ]]; then + pass "artifact:$name:sbom:sha256" "$actual" + else + reject "hash mismatch: $sbom" "artifact:$name:sbom:sha256" + fi elif [[ "$require_local" == true ]]; then reject "local asset missing: $sbom" "artifact:$name:sbom:file" else @@ -329,52 +228,65 @@ while IFS= read -r image; do kind=$(jq -r '.kind' <<<"$image") ref=$(jq -r '.ref' <<<"$image") digest=$(jq -r '.digest' <<<"$image") - repository=$(repository_without_tag "$ref") - subject="${repository}@${digest}" - if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + if [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + pass "image:$kind:digest" "$digest" + else reject "invalid image digest for $kind" "image:$kind:digest" continue fi - if cosign verify "${verify_identity_args[@]}" "$subject" >/dev/null 2>&1; then - pass "image:$kind:signature" "$subject" + if command -v docker >/dev/null 2>&1; then + manifest_json=$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' 2>/dev/null || true) + resolved=$(jq -r '.digest // empty' <<<"$manifest_json" 2>/dev/null || true) + if [[ "$resolved" == "$digest" ]]; then + pass "image:$kind:resolved-digest" "$resolved" + elif [[ -n "$resolved" ]]; then + reject "resolved image digest differs from receipt for $kind" "image:$kind:resolved-digest" + elif [[ "$require_local" == true ]]; then + reject "unable to resolve image reference: $ref" "image:$kind:resolved-digest" + else + warn "image reference not independently resolved: $ref" "image:$kind:resolved-digest" + fi + elif [[ "$require_local" == true ]]; then + reject "docker/buildx is required for strict image digest verification" "image:$kind:resolved-digest" else - reject "image signature verification failed: $subject" "image:$kind:signature" + warn "docker/buildx unavailable; image digest not independently resolved" "image:$kind:resolved-digest" fi - verify_attested_json_file custom "$subject" "$receipt" "image:$kind:receipt-attestation" + while IFS= read -r platform; do + platform_digest=$(jq -r '.digest // empty' <<<"$platform") + if [[ "$platform_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + pass "image:$kind:platform:$platform_digest" "valid immutable digest" + else + reject "invalid platform digest for $kind" "image:$kind:platform" + fi + done < <(jq -c '.platforms[]?' <<<"$image") while IFS= read -r sbom; do name=$(jq -r '.name' <<<"$sbom") source=$(jq -r '.source' <<<"$sbom") - digest=$(jq -r '.imageDigest' <<<"$sbom") + image_digest=$(jq -r '.imageDigest' <<<"$sbom") expected=$(jq -r '.sha256' <<<"$sbom") - file="$asset_dir/$(basename "$name")" + sbom_file="$asset_dir/$(basename "$name")" - [[ "$source" == *@"$digest" ]] && \ - pass "image:$kind:sbom:$name:binding" "$source" || \ + if [[ "$source" == *@"$image_digest" && "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + pass "image:$kind:sbom:$name:binding" "$source" + else reject "SBOM source/digest mismatch: $name" "image:$kind:sbom:$name:binding" + fi - if [[ -f "$file" ]]; then - actual=$(sha256_file "$file") - [[ "$actual" == "$expected" ]] && \ - pass "image:$kind:sbom:$name:sha256" "$actual" || \ + if [[ -f "$sbom_file" ]]; then + actual=$(sha256_file "$sbom_file") + if [[ "$actual" == "$expected" ]]; then + pass "image:$kind:sbom:$name:sha256" "$actual" + else reject "image SBOM hash mismatch: $name" "image:$kind:sbom:$name:sha256" - verify_attested_json_file cyclonedx "$source" "$file" \ - "image:$kind:sbom:$name:attestation" + fi elif [[ "$require_local" == true ]]; then - reject "image SBOM missing: $file" "image:$kind:sbom:$name:file" + reject "image SBOM missing: $sbom_file" "image:$kind:sbom:$name:file" else - warn "image SBOM not downloaded; verifying attestation existence only: $name" \ - "image:$kind:sbom:$name:file" - if cosign verify-attestation --type cyclonedx "${verify_identity_args[@]}" \ - "$source" >/dev/null 2>&1; then - pass "image:$kind:sbom:$name:attestation" "verified" - else - reject "CycloneDX attestation verification failed: $source" \ - "image:$kind:sbom:$name:attestation" - fi + warn "image SBOM not downloaded: $name" "image:$kind:sbom:$name:file" fi done < <(jq -c '.sboms[]?' <<<"$image") done < <(jq -c '.images[]' "$receipt")