diff --git a/.github/actions/release-nightly/action.yml b/.github/actions/release-nightly/action.yml index cf980ad84b..084f7fac22 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,63 @@ 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 run: | - go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@latest - echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - - - name: Generate SBOM + 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} + 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}" + + - 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: + SYFT_CHECK_FOR_APP_UPDATE: "false" + run: | + source script/release-assurance-tools.env + export SYFT_VERSION + bash script/generate_release_assurance.sh \ + "${{ env.VERSION }}" \ + "${{ env.TAG }}" \ + "${{ env.BUILD_SOURCE_SHA }}" \ + "${{ env.COMMIT_ID }}" \ + "$CYCLONEDX_GOMOD_VERSION" + + - name: Verify nightly release assurance + shell: bash + run: | + ASSURANCE_REQUIRE_LOCAL_ASSETS=true \ + bash script/verify_release_assurance.sh \ + release-assurance.json \ + release-assurance.verdict.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 || echo "No nightly release updates changes to push" - name: Create release first time shell: bash @@ -180,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 }} @@ -236,7 +272,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.verdict.json" for FILE in $FILES; do if [ -f "$FILE" ]; then 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/release-assurance-tools.env b/script/release-assurance-tools.env new file mode 100644 index 0000000000..5d5f21c7cb --- /dev/null +++ b/script/release-assurance-tools.env @@ -0,0 +1,22 @@ +# 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 diff --git a/script/verify_release_assurance.sh b/script/verify_release_assurance.sh new file mode 100644 index 0000000000..bcf06c7e40 --- /dev/null +++ b/script/verify_release_assurance.sh @@ -0,0 +1,294 @@ +#!/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 + +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" "${errors_file}.json" "${checks_file}.json" "${warnings_file}.json"' 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="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" + 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 assuranceClass "unsigned-nightly-integrity" \ + --slurpfile checks "${checks_file}.json" \ + --slurpfile errors "${errors_file}.json" \ + --slurpfile warnings "${warnings_file}.json" \ + '{verdict:$verdict,assuranceClass:$assuranceClass,checks:$checks[0],errors:$errors[0],warnings:$warnings[0]}' \ + >"$verdict_file" + + cat "$verdict_file" + [[ "$verdict" == VALID ]] +} + +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 +} + +for command in jq; do + if command -v "$command" >/dev/null 2>&1; then + pass "tool:$command" "available" + else + reject "required command not found: $command" "tool:$command" + 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 [[ ! -f "$receipt" ]]; then + reject "receipt not found: $receipt" "receipt:file" + finish + exit $? +fi + +if [[ -s "$errors_file" ]]; then + finish + exit $? +fi + +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 + +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") +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 + 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: $module_file" "module-sbom:file" + else + warn "module SBOM not downloaded: $module_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") + + 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") + 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 "$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) + 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 + 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 + + sbom_file="$asset_dir/$(basename "$sbom")" + expected=$(jq -r '.sbom.sha256' <<<"$artifact") + 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 + 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") + + 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 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 + warn "docker/buildx unavailable; image digest not independently resolved" "image:$kind:resolved-digest" + fi + + 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") + image_digest=$(jq -r '.imageDigest' <<<"$sbom") + expected=$(jq -r '.sha256' <<<"$sbom") + sbom_file="$asset_dir/$(basename "$name")" + + 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 "$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" + fi + elif [[ "$require_local" == true ]]; then + reject "image SBOM missing: $sbom_file" "image:$kind:sbom:$name:file" + else + warn "image SBOM not downloaded: $name" "image:$kind:sbom:$name:file" + fi + done < <(jq -c '.sboms[]?' <<<"$image") +done < <(jq -c '.images[]' "$receipt") + +finish