diff --git a/.github/workflows/sbom-builder.yml b/.github/workflows/sbom-builder.yml index c1c54e2..ddb715d 100644 --- a/.github/workflows/sbom-builder.yml +++ b/.github/workflows/sbom-builder.yml @@ -103,8 +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 @@ -162,11 +168,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 +204,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 +253,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 +307,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 +319,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 +346,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/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" 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" '