From 460ba38145d0bce1dd550399db59afc719ab473b Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Fri, 6 Mar 2026 20:34:10 +0000 Subject: [PATCH 1/2] Use SHA256 digest as SBOM version for docker/chainguard sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker image tags are mutable — the same tag (e.g. alpine:3.23.3) gets rebuilt with different content. Using the tag as COMPONENT_VERSION caused false dedup (old tag found → new SBOM never uploaded) and no traceability to actual image content. Now docker and chainguard sources persist the image digest to image-digest.txt and use it as the SBOM version. Dedup checks the sbomify API for existing artifacts with that digest, and after upload, stale artifacts for the same component are cleaned up. github_release and lockfile sources are unchanged (semver, TEA dedup). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/sbom-builder.yml | 79 +++++++++++++++++++++++++-- scripts/lib/sbomify-api.sh | 62 +++++++++++++++++++++ scripts/sources/chainguard.sh | 5 ++ scripts/sources/docker-attestation.sh | 1 + 4 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 scripts/lib/sbomify-api.sh diff --git a/.github/workflows/sbom-builder.yml b/.github/workflows/sbom-builder.yml index c1c54e2..232840a 100644 --- a/.github/workflows/sbom-builder.yml +++ b/.github/workflows/sbom-builder.yml @@ -103,6 +103,7 @@ jobs: echo "lockfile_path=" >> $GITHUB_OUTPUT fi PRODUCT_ID=$(yq -r '.sbomify.product_id // ""' "$CONFIG") + echo "product_id=${PRODUCT_ID}" >> $GITHUB_OUTPUT if [[ -n "$PRODUCT_ID" && -n "$VERSION" ]]; then echo "product_release=[\"${PRODUCT_ID}:${VERSION}\"]" >> $GITHUB_OUTPUT fi @@ -162,11 +163,25 @@ jobs: || (steps.config.outputs.source_type != 'github_release' && steps.config.outputs.source_type != 'lockfile') run: ./scripts/fetch-sbom.sh "${{ inputs.app }}" + - name: Read image digest + id: digest + if: >- + steps.config.outputs.source_type == 'docker' + || steps.config.outputs.source_type == 'chainguard' + run: | + if [[ ! -f image-digest.txt ]]; then + echo "::error::image-digest.txt not found" + exit 1 + fi + digest=$(cat image-digest.txt) + echo "image_digest=${digest}" >> "$GITHUB_OUTPUT" + echo "image_digest_safe=${digest//:/-}" >> "$GITHUB_OUTPUT" + - name: Upload input artifact if: always() && hashFiles('sbom.json') != '' uses: actions/upload-artifact@v4 with: - name: sbom-${{ inputs.app }}-${{ steps.config.outputs.version }} + name: sbom-${{ inputs.app }}-${{ steps.digest.outputs.image_digest_safe || steps.config.outputs.version }} path: sbom.json - name: Upload lockfile artifact @@ -184,7 +199,7 @@ jobs: TOKEN: ${{ secrets.SBOMIFY_TOKEN }} COMPONENT_ID: ${{ steps.config.outputs.component_id }} COMPONENT_NAME: ${{ steps.config.outputs.component_name }} - COMPONENT_VERSION: ${{ steps.config.outputs.version }} + COMPONENT_VERSION: ${{ steps.digest.outputs.image_digest || steps.config.outputs.version }} SBOM_FILE: sbom.json OUTPUT_FILE: sbom-output.json AUGMENT: true @@ -233,17 +248,52 @@ jobs: echo "should_upload=false" >> "$GITHUB_OUTPUT" fi + - name: Check sbomify for existing digest + id: sbomify-check + if: >- + steps.config.outputs.component_id != '' && !inputs.dry_run + && (steps.config.outputs.source_type == 'docker' + || steps.config.outputs.source_type == 'chainguard') + && steps.config.outputs.product_id != '' + run: | + source scripts/lib/common.sh + source scripts/lib/sbomify-api.sh + if sbomify_digest_exists \ + "${{ steps.config.outputs.product_id }}" \ + "${{ steps.config.outputs.version }}" \ + "${{ steps.config.outputs.component_id }}" \ + "${{ steps.digest.outputs.image_digest }}"; then + echo "Digest already exists, skipping upload" + echo "should_upload=false" >> "$GITHUB_OUTPUT" + else + echo "New digest, will upload" + echo "should_upload=true" >> "$GITHUB_OUTPUT" + fi + env: + SBOMIFY_TOKEN: ${{ secrets.SBOMIFY_TOKEN }} + + - name: Resolve upload decision + id: upload-decision + if: steps.config.outputs.component_id != '' && !inputs.dry_run + run: | + src="${{ steps.config.outputs.source_type }}" + if [[ "$src" == "docker" || "$src" == "chainguard" ]]; then + echo "should_upload=${{ steps.sbomify-check.outputs.should_upload || 'true' }}" >> "$GITHUB_OUTPUT" + else + echo "should_upload=${{ steps.tea-check.outputs.should_upload || 'true' }}" >> "$GITHUB_OUTPUT" + fi + # Phase 3: Upload only if SBOM is new - name: Upload SBOM (from existing SBOM) if: >- steps.config.outputs.component_id != '' && steps.config.outputs.source_type != 'lockfile' - && !inputs.dry_run && steps.tea-check.outputs.should_upload == 'true' + && !inputs.dry_run && steps.upload-decision.outputs.should_upload == 'true' uses: sbomify/sbomify-action@master env: TOKEN: ${{ secrets.SBOMIFY_TOKEN }} COMPONENT_ID: ${{ steps.config.outputs.component_id }} COMPONENT_NAME: ${{ steps.config.outputs.component_name }} - COMPONENT_VERSION: ${{ steps.config.outputs.version }} + COMPONENT_VERSION: ${{ steps.digest.outputs.image_digest || steps.config.outputs.version }} SBOM_FILE: sbom-output.json OUTPUT_FILE: sbom-final.json UPLOAD: true @@ -252,7 +302,7 @@ jobs: - name: Upload SBOM (from lockfile) if: >- steps.config.outputs.component_id != '' && steps.config.outputs.source_type == 'lockfile' - && !inputs.dry_run && steps.tea-check.outputs.should_upload == 'true' + && !inputs.dry_run && steps.upload-decision.outputs.should_upload == 'true' uses: sbomify/sbomify-action@master env: TOKEN: ${{ secrets.SBOMIFY_TOKEN }} @@ -264,6 +314,23 @@ jobs: UPLOAD: true PRODUCT_RELEASE: ${{ steps.config.outputs.product_release }} + - name: Cleanup old release artifacts + if: >- + steps.config.outputs.product_id != '' && !inputs.dry_run + && steps.upload-decision.outputs.should_upload == 'true' + && (steps.config.outputs.source_type == 'docker' + || steps.config.outputs.source_type == 'chainguard') + run: | + source scripts/lib/common.sh + source scripts/lib/sbomify-api.sh + sbomify_cleanup_old_artifacts \ + "${{ steps.config.outputs.product_id }}" \ + "${{ steps.config.outputs.version }}" \ + "${{ steps.config.outputs.component_id }}" \ + "${{ steps.digest.outputs.image_digest }}" + env: + SBOMIFY_TOKEN: ${{ secrets.SBOMIFY_TOKEN }} + - name: Upload output artifact if: always() uses: actions/upload-artifact@v4 @@ -274,7 +341,7 @@ jobs: - name: Attest SBOM provenance if: >- steps.config.outputs.component_id != '' && !inputs.dry_run - && steps.tea-check.outputs.should_upload == 'true' + && steps.upload-decision.outputs.should_upload == 'true' uses: actions/attest-build-provenance@v3 with: subject-path: sbom-output.json diff --git a/scripts/lib/sbomify-api.sh b/scripts/lib/sbomify-api.sh new file mode 100644 index 0000000..714696f --- /dev/null +++ b/scripts/lib/sbomify-api.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# sbomify-api.sh - Shared utilities for sbomify API calls +# +# Usage: source this file in other scripts (after common.sh) +# source "$(dirname "${BASH_SOURCE[0]}")/lib/sbomify-api.sh" +# +# Requires: SBOMIFY_TOKEN, curl, jq + +set -euo pipefail + +SBOMIFY_API="${SBOMIFY_API_URL:-https://app.sbomify.com}" + +# Check if a release artifact already has this digest version +# Usage: sbomify_digest_exists +# Returns: 0 if exists, 1 if not +sbomify_digest_exists() { + local product_id="$1" tag_version="$2" component_id="$3" digest="$4" + + # Find the release + local releases release_id + releases=$(curl -fsSL -H "Authorization: Bearer ${SBOMIFY_TOKEN}" \ + "${SBOMIFY_API}/api/v1/releases?product_id=${product_id}&version=${tag_version}") + release_id=$(echo "$releases" | jq -r --arg v "$tag_version" \ + '.items[] | select(.version == $v) | .id' | head -1) + + [[ -z "$release_id" ]] && return 1 # No release → digest doesn't exist + + # Check artifacts for matching digest + local artifacts + artifacts=$(curl -fsSL -H "Authorization: Bearer ${SBOMIFY_TOKEN}" \ + "${SBOMIFY_API}/api/v1/releases/${release_id}/artifacts?mode=existing") + echo "$artifacts" | jq -e --arg cid "$component_id" --arg d "$digest" \ + '.items[] | select(.component_id == $cid and .sbom_version == $d)' > /dev/null 2>&1 +} + +# Remove old artifacts for a component from a release (keep only current digest) +# Usage: sbomify_cleanup_old_artifacts +sbomify_cleanup_old_artifacts() { + local product_id="$1" tag_version="$2" component_id="$3" current_digest="$4" + + # Find the release + local releases release_id + releases=$(curl -fsSL -H "Authorization: Bearer ${SBOMIFY_TOKEN}" \ + "${SBOMIFY_API}/api/v1/releases?product_id=${product_id}&version=${tag_version}") + release_id=$(echo "$releases" | jq -r --arg v "$tag_version" \ + '.items[] | select(.version == $v) | .id' | head -1) + + [[ -z "$release_id" ]] && return 0 # No release → nothing to clean + + # Find old artifacts for this component with different digest + local old_ids + old_ids=$(curl -fsSL -H "Authorization: Bearer ${SBOMIFY_TOKEN}" \ + "${SBOMIFY_API}/api/v1/releases/${release_id}/artifacts?mode=existing" | \ + jq -r --arg cid "$component_id" --arg d "$current_digest" \ + '.items[] | select(.component_id == $cid and .sbom_version != $d) | .id') + + for artifact_id in $old_ids; do + log_info "Removing old artifact ${artifact_id} from release ${release_id}" + curl -fsSL -X DELETE -H "Authorization: Bearer ${SBOMIFY_TOKEN}" \ + "${SBOMIFY_API}/api/v1/releases/${release_id}/artifacts/${artifact_id}" + done +} diff --git a/scripts/sources/chainguard.sh b/scripts/sources/chainguard.sh index 0d30a93..579781b 100755 --- a/scripts/sources/chainguard.sh +++ b/scripts/sources/chainguard.sh @@ -17,6 +17,11 @@ platform=$(get_config "$app" ".source.platform" "linux/amd64") image_ref="${registry}/${image}:${version}" log_info "Downloading attestation: $image_ref" + +image_digest=$(crane digest --platform "$platform" "$image_ref") +echo "$image_digest" > image-digest.txt +log_info "Image digest: $image_digest" + cosign download attestation \ --platform "$platform" \ --predicate-type="https://spdx.dev/Document" \ diff --git a/scripts/sources/docker-attestation.sh b/scripts/sources/docker-attestation.sh index 9d4e874..5af6776 100755 --- a/scripts/sources/docker-attestation.sh +++ b/scripts/sources/docker-attestation.sh @@ -34,6 +34,7 @@ image_digest=$(echo "$index" | jq -r --arg os "$plat_os" --arg arch "$plat_arch" die "No image found for platform $platform" log_debug "Image digest: $image_digest" +echo "$image_digest" > image-digest.txt # Find the attestation manifest that references this image sbom_digest=$(echo "$index" | jq -r --arg ref "$image_digest" ' From e7c544e06ce5a5d27cfd55207398782b21aa1dd4 Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Fri, 6 Mar 2026 20:58:00 +0000 Subject: [PATCH 2/2] Add standalone products for Dependency Track and bundle_product_id support Dependency Track API Server and Frontend now have their own standalone products in sbomify, with the shared "Dependency Track" bundle referenced via bundle_product_id. The workflow builds PRODUCT_RELEASE with both IDs so SBOMs are tagged to both the standalone and bundle releases. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/sbom-builder.yml | 7 ++++++- apps/.template/config.yaml | 3 +++ apps/dependency-track-frontend/config.yaml | 3 ++- apps/dependency-track/config.yaml | 3 ++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sbom-builder.yml b/.github/workflows/sbom-builder.yml index 232840a..ddb715d 100644 --- a/.github/workflows/sbom-builder.yml +++ b/.github/workflows/sbom-builder.yml @@ -103,9 +103,14 @@ jobs: echo "lockfile_path=" >> $GITHUB_OUTPUT fi PRODUCT_ID=$(yq -r '.sbomify.product_id // ""' "$CONFIG") + BUNDLE_PRODUCT_ID=$(yq -r '.sbomify.bundle_product_id // ""' "$CONFIG") echo "product_id=${PRODUCT_ID}" >> $GITHUB_OUTPUT if [[ -n "$PRODUCT_ID" && -n "$VERSION" ]]; then - echo "product_release=[\"${PRODUCT_ID}:${VERSION}\"]" >> $GITHUB_OUTPUT + RELEASE_ARRAY="\"${PRODUCT_ID}:${VERSION}\"" + if [[ -n "$BUNDLE_PRODUCT_ID" ]]; then + RELEASE_ARRAY="${RELEASE_ARRAY},\"${BUNDLE_PRODUCT_ID}:${VERSION}\"" + fi + echo "product_release=[${RELEASE_ARRAY}]" >> $GITHUB_OUTPUT fi # Build PURL for TEA dedup lookup diff --git a/apps/.template/config.yaml b/apps/.template/config.yaml index 2197a67..6cc5ea8 100644 --- a/apps/.template/config.yaml +++ b/apps/.template/config.yaml @@ -65,4 +65,7 @@ sbomify: # Optional: Product ID for release tagging (if this component is part of a product) # When set, the SBOM will be tagged with "product_id:version" # product_id: "your-product-id" + # Optional: Bundle product ID (if this component also belongs to a multi-component bundle) + # When set, the SBOM will be tagged with both product releases + # bundle_product_id: "your-bundle-product-id" diff --git a/apps/dependency-track-frontend/config.yaml b/apps/dependency-track-frontend/config.yaml index 63f045d..80eeba4 100644 --- a/apps/dependency-track-frontend/config.yaml +++ b/apps/dependency-track-frontend/config.yaml @@ -20,5 +20,6 @@ source: sbomify: component_id: "UL9G0VKuqRiI" component_name: "Dependency Track Frontend" - product_id: "MoJ2O8FemjBc" + product_id: "UMJjUDpLYCTG" + bundle_product_id: "MoJ2O8FemjBc" diff --git a/apps/dependency-track/config.yaml b/apps/dependency-track/config.yaml index 15c9a1c..cf3b75f 100644 --- a/apps/dependency-track/config.yaml +++ b/apps/dependency-track/config.yaml @@ -20,5 +20,6 @@ source: sbomify: component_id: "hZSsrKGgp1ZP" component_name: "Dependency Track API Server" - product_id: "MoJ2O8FemjBc" + product_id: "rcTz13LAeJhO" + bundle_product_id: "MoJ2O8FemjBc"