From ebea13d11c1b439a2cc72f66932aae259a274625 Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Tue, 14 Jul 2026 17:26:25 +0200 Subject: [PATCH] fix(notify): recover immutable staged SBOM Normalize every SPDX annotation timestamp so fresh helper SBOMs are deterministic. During release recovery, fetch the existing draft SBOM by validated asset ID, verify its API digest and canonical content against a fresh scan, then use those immutable bytes locally when rebuilding the release manifest. Cross-check manifest SBOM and image-digest bindings before rollout. --- .github/scripts/notify-publish-safety.rb | 36 ++++++++++ .github/workflows/docker.yml | 88 ++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/.github/scripts/notify-publish-safety.rb b/.github/scripts/notify-publish-safety.rb index 8e6cbcd..efe1c9d 100644 --- a/.github/scripts/notify-publish-safety.rb +++ b/.github/scripts/notify-publish-safety.rb @@ -383,6 +383,40 @@ def resume_action(existing_digest, expected_digest, mutable:) !recovery_text.include?("docker/build-push-action@"), "image recovery must only verify the already-published image and attestations") +[ + "Normalize deterministic attestation SBOM metadata", + "Normalize deterministic SBOM metadata" +].each do |step_name| + job_name = step_name.include?("attestation") ? "attest-binaries" : "release-binaries" + step_text = steps(jobs.fetch(job_name)).find { |step| step["name"] == step_name }&.fetch("run", "") || "" + assert_policy(step_text.include?("creationInfo.created") && + step_text.include?("documentNamespace") && + step_text.include?("walk(") && + step_text.include?("annotationDate") && + step_text.include?("1970-01-01T00:00:00Z"), + "#{job_name}/#{step_name} must normalize every runtime timestamp") +end + +adopt_sbom = steps(jobs.fetch("release-binaries")).find do |step| + step["name"] == "Verify and adopt the immutable staged SBOM in recovery" +end +adopt_text = adopt_sbom&.fetch("run", "") || "" +assert_policy(adopt_sbom && + adopt_sbom.fetch("if", "").include?("needs.publish-gate.outputs.recovery_mode == 'true'") && + adopt_text.include?("gh api graphql") && + adopt_text.include?("databaseId") && + adopt_text.include?('releases/${release_id}') && + adopt_text.include?('test "$matches" = 1') && + adopt_text.include?('releases/assets/${asset_id}') && + adopt_text.include?("Accept: application/octet-stream") && + adopt_text.include?('^sha256:[0-9a-f]{64}$') && + adopt_text.include?("canonical_filter") && + adopt_text.include?("annotationDate") && + adopt_text.include?("cmp ") && + adopt_text.include?('mv "$staged" "$generated"') && + !adopt_text.match?(/gh release (?:upload|edit|delete)/), + "recovery must adopt an existing SBOM only after exact API-digest and canonical-content verification") + stage_release = steps(jobs.fetch("release-binaries")).find do |step| step["name"] == "Create or verify the exact draft release and assets" end @@ -533,6 +567,8 @@ def resume_action(existing_digest, expected_digest, mutable:) rollout_text.include?('Accept: application/octet-stream') && rollout_text.include?('test "$matches" = 1') && rollout_text.include?('^sha256:[0-9a-f]{64}$') && + rollout_text.include?("binary_sbom") && + rollout_text.include?("image_digests_sha256") && !rollout_text.include?('gh release download "$RELEASE_TAG"'), "published rollout must resolve draft assets by validated release and asset IDs") assert_policy((%w[rollout-verify image-ready] - jobs.fetch("finalize-release").fetch("needs")).empty?, diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c2bf49d..5698889 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -693,7 +693,11 @@ jobs: run: | set -euo pipefail jq --arg namespace "https://github.com/${GITHUB_REPOSITORY}/sbom/${RELEASE_TAG}/${RELEASE_SHA}" \ - '.creationInfo.created = "1970-01-01T00:00:00Z" | .documentNamespace = $namespace' \ + '.creationInfo.created = "1970-01-01T00:00:00Z" + | .documentNamespace = $namespace + | walk(if type == "object" and has("annotationDate") + then .annotationDate = "1970-01-01T00:00:00Z" + else . end)' \ notify/dist/SBOM.spdx.json > /tmp/SBOM.spdx.json mv /tmp/SBOM.spdx.json notify/dist/SBOM.spdx.json @@ -888,10 +892,76 @@ jobs: run: | set -euo pipefail jq --arg namespace "https://github.com/${GITHUB_REPOSITORY}/sbom/${RELEASE_TAG}/${RELEASE_SHA}" \ - '.creationInfo.created = "1970-01-01T00:00:00Z" | .documentNamespace = $namespace' \ + '.creationInfo.created = "1970-01-01T00:00:00Z" + | .documentNamespace = $namespace + | walk(if type == "object" and has("annotationDate") + then .annotationDate = "1970-01-01T00:00:00Z" + else . end)' \ notify/dist/SBOM.spdx.json > /tmp/SBOM.spdx.json mv /tmp/SBOM.spdx.json notify/dist/SBOM.spdx.json + - name: Verify and adopt the immutable staged SBOM in recovery + if: needs.publish-gate.outputs.recovery_mode == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} + RELEASE_VERSION: ${{ needs.publish-gate.outputs.version }} + RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} + run: | + set -euo pipefail + repository_owner=${GITHUB_REPOSITORY%%/*} + repository_name=${GITHUB_REPOSITORY#*/} + # GraphQL includes drafts; REST by-tag does not. + # shellcheck disable=SC2016 + lookup=$(gh api graphql \ + -F owner="$repository_owner" -F name="$repository_name" -F tag="$RELEASE_TAG" \ + -f query='query($owner: String!, $name: String!, $tag: String!) { + repository(owner: $owner, name: $name) { + release(tagName: $tag) { databaseId } + } + }') + jq -e '.data.repository != null and ((.errors // []) | length == 0)' <<<"$lookup" >/dev/null + release_id=$(jq -r '.data.repository.release.databaseId // empty' <<<"$lookup") + printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' + release_json=$(gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}") + test "$(jq -r .tag_name <<<"$release_json")" = "$RELEASE_TAG" + test "$(jq -r .name <<<"$release_json")" = "vaultsync-notify ${RELEASE_VERSION}" + test "$(jq -r .prerelease <<<"$release_json")" = false + case $(jq -r .draft <<<"$release_json") in true|false) ;; *) exit 1 ;; esac + + asset_name=SBOM.spdx.json + matches=$(jq -r --arg name "$asset_name" '[.assets[] | select(.name == $name)] | length' <<<"$release_json") + test "$matches" = 1 + asset_id=$(jq -r --arg name "$asset_name" '.assets[] | select(.name == $name) | .id' <<<"$release_json") + asset_digest=$(jq -r --arg name "$asset_name" '.assets[] | select(.name == $name) | .digest' <<<"$release_json") + printf '%s\n' "$asset_id" | grep -Eq '^[1-9][0-9]*$' + printf '%s\n' "$asset_digest" | grep -Eq '^sha256:[0-9a-f]{64}$' + staged=/tmp/vaultsync-notify-staged-SBOM.spdx.json + gh api --method GET -H 'Accept: application/octet-stream' \ + "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" > "$staged" + test "sha256:$(sha256sum "$staged" | awk '{print $1}')" = "$asset_digest" + + generated=notify/dist/SBOM.spdx.json + namespace="https://github.com/${GITHUB_REPOSITORY}/sbom/${RELEASE_TAG}/${RELEASE_SHA}" + for candidate in "$generated" "$staged"; do + jq -e --arg namespace "$namespace" ' + .spdxVersion == "SPDX-2.3" and + .dataLicense == "CC0-1.0" and + .name == "notify/dist" and + .documentNamespace == $namespace and + .creationInfo.created == "1970-01-01T00:00:00Z" and + (.creationInfo.creators | index("Tool: trivy-0.70.0") != null) + ' "$candidate" >/dev/null + done + canonical_filter='walk(if type == "object" and has("annotationDate") + then .annotationDate = "1970-01-01T00:00:00Z" + else . end)' + jq -S "$canonical_filter" "$generated" > /tmp/vaultsync-notify-generated-SBOM.canonical.json + jq -S "$canonical_filter" "$staged" > /tmp/vaultsync-notify-staged-SBOM.canonical.json + cmp /tmp/vaultsync-notify-generated-SBOM.canonical.json \ + /tmp/vaultsync-notify-staged-SBOM.canonical.json + mv "$staged" "$generated" + - name: Verify exact binary provenance env: GH_TOKEN: ${{ github.token }} @@ -1054,7 +1124,10 @@ jobs: expected="sha256:$(sha256sum "$file" | awk '{print $1}')" existing=$(jq -r --arg name "$name" '[.assets[] | select(.name == $name) | .digest][0] // empty' <<<"$release_json") if [ -n "$existing" ]; then - test "$existing" = "$expected" + if [ "$existing" != "$expected" ]; then + echo "immutable release asset digest mismatch for $name: expected $expected, found $existing" >&2 + exit 1 + fi else test "$release_is_draft" = true gh release upload "$RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" @@ -1147,8 +1220,15 @@ jobs: test "sha256:$(sha256sum "$target" | awk '{print $1}')" = "$asset_digest" done (cd /tmp/vaultsync-notify-release && sha256sum -c SHA256SUMS) + sbom_sha=$(sha256sum /tmp/vaultsync-notify-release/SBOM.spdx.json | awk '{print $1}') + image_digests_sha=$(sha256sum /tmp/vaultsync-notify-release/IMAGE-DIGESTS | awk '{print $1}') jq -e --arg sha "$RELEASE_SHA" --arg digest "$IMAGE_DIGEST" \ - '.source_commit == $sha and .image_index_digest == $digest' \ + --arg sbom_sha "$sbom_sha" --arg image_digests_sha "$image_digests_sha" ' + .source_commit == $sha and + .image_index_digest == $digest and + .binary_sbom == {"name": "SBOM.spdx.json", "sha256": $sbom_sha} and + .image_digests_sha256 == $image_digests_sha + ' \ /tmp/vaultsync-notify-release/RELEASE-MANIFEST.json >/dev/null - name: Prove published upgrade, rollback, and forward recovery