Lab: Building an SBOM Pipeline — Generate, Attest, and Verify with Syft and Cosign

Overview

Software Bills of Materials (SBOMs) are rapidly becoming a mandatory component of software supply chain transparency. Executive orders, regulatory frameworks like NIST SSDF, and industry standards now require organizations to produce, distribute, and verify SBOMs for every software release. An SBOM lists every component, library, and dependency inside your software — giving consumers the ability to assess risk, track vulnerabilities, and verify provenance.

In this hands-on lab, you will build a complete SBOM pipeline from scratch. By the end, you will be able to:

  • Generate SBOMs in both SPDX and CycloneDX formats using Syft
  • Scan SBOMs for known vulnerabilities using Grype
  • Attach SBOMs as signed attestations to container images using Cosign
  • Automate the entire workflow in GitHub Actions and GitLab CI
  • Enforce SBOM attestation requirements at deployment using Kyverno
  • Diff SBOMs between releases to detect dependency changes

This lab mirrors real-world production workflows used by teams adopting SLSA, in-toto, and Sigstore-based supply chain security.

Prerequisites

Before starting, ensure you have the following tools installed and configured:

Tool Purpose Install
Syft SBOM generation curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
Grype Vulnerability scanning curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
Cosign Signing and attestation go install github.com/sigstore/cosign/v2/cmd/cosign@latest
Docker Container builds docs.docker.com
GitHub CLI (gh) Repository and GHCR access brew install gh or cli.github.com

You also need a GitHub account with access to GitHub Container Registry (GHCR), and a basic application with a Dockerfile.

Environment Setup

We will create a minimal Node.js application, containerize it, and push it to GHCR. This image becomes the target for all subsequent SBOM exercises.

Step 1: Create the test repository

mkdir sbom-pipeline-lab && cd sbom-pipeline-lab
git init

Step 2: Create a simple Node.js application

cat > package.json <<'EOF'
{
  "name": "sbom-lab-app",
  "version": "1.0.0",
  "description": "SBOM pipeline lab application",
  "main": "server.js",
  "dependencies": {
    "express": "^4.18.2",
    "lodash": "^4.17.21",
    "axios": "^1.6.0"
  }
}
EOF
cat > server.js <<'EOF'
const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.json({ status: 'ok', message: 'SBOM Pipeline Lab' });
});

app.listen(3000, () => console.log('Listening on port 3000'));
EOF

Step 3: Create the Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

Step 4: Build and push the container image

# Authenticate to GHCR
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin

# Build the image
docker build -t ghcr.io/YOUR_GITHUB_USERNAME/sbom-lab-app:v1.0.0 .

# Push to GHCR
docker push ghcr.io/YOUR_GITHUB_USERNAME/sbom-lab-app:v1.0.0

Replace YOUR_GITHUB_USERNAME with your actual GitHub username throughout this lab. After the push completes, note the full image digest — you will need it for signing operations.

# Capture the image digest
export IMAGE=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/YOUR_GITHUB_USERNAME/sbom-lab-app:v1.0.0)
echo $IMAGE
# Output: ghcr.io/YOUR_GITHUB_USERNAME/sbom-lab-app@sha256:abc123...

Exercise 1: Generate SBOM with Syft

Syft is an open-source tool from Anchore that generates SBOMs by analyzing container images, filesystems, and archives. It supports multiple output formats including SPDX and CycloneDX — the two dominant SBOM standards.

Generate an SPDX SBOM

syft $IMAGE -o spdx-json=sbom.spdx.json

Inspect the output:

cat sbom.spdx.json | jq '.packages | length'
# Output: 287 (count varies based on image content)

# View detected packages
cat sbom.spdx.json | jq '.packages[] | {name: .name, version: .versionInfo, license: .licenseDeclared}' | head -40

Generate a CycloneDX SBOM

syft $IMAGE -o cyclonedx-json=sbom.cyclonedx.json

Inspect the CycloneDX output:

cat sbom.cyclonedx.json | jq '.components | length'

# View components with licenses
cat sbom.cyclonedx.json | jq '.components[] | {name: .name, version: .version, type: .type}' | head -40

SPDX vs. CycloneDX: Key Differences

Feature SPDX CycloneDX
Origin Linux Foundation OWASP
Primary Focus License compliance and IP Security and risk analysis
ISO Standard ISO/IEC 5962:2021 ECMA-424
Vulnerability Data Limited native support First-class vulnerabilities field
Formats JSON, RDF, XML, YAML, tag-value JSON, XML, Protobuf
Best For Regulatory compliance, license audits DevSecOps, vulnerability tracking

For this lab, we primarily use SPDX JSON because it is the format required by many government procurement standards and pairs well with Cosign attestations. However, both formats are supported by Grype and Cosign.

Generate a human-readable summary

syft $IMAGE -o syft-table

This prints a table of all packages with name, version, and type — useful for quick review during development.

Exercise 2: Scan SBOM for Vulnerabilities with Grype

Grype is Anchore’s vulnerability scanner. It can scan container images directly, but it can also scan an SBOM file — which is significantly faster because it skips the image analysis step.

Scan the SBOM

grype sbom:./sbom.spdx.json

Sample output:

NAME          INSTALLED  FIXED-IN   TYPE  VULNERABILITY   SEVERITY
lodash        4.17.21               npm   CVE-2025-XXXXX  Medium
node          20.10.0    20.11.1    apk   CVE-2024-22019  High
libcrypto3    3.1.4-r1   3.1.4-r3   apk   CVE-2024-0727   Medium

Filter by severity

# Show only Critical and High vulnerabilities
grype sbom:./sbom.spdx.json --fail-on critical

# Output results in JSON for CI processing
grype sbom:./sbom.spdx.json -o json > vulnerabilities.json

# Count vulnerabilities by severity
cat vulnerabilities.json | jq '[.matches[].vulnerability.severity] | group_by(.) | map({severity: .[0], count: length})'

Image scan vs. SBOM scan

There is an important distinction between scanning the image directly and scanning the SBOM:

# Direct image scan — Grype pulls and analyzes the image layers
grype $IMAGE

# SBOM scan — Grype reads the pre-generated package list
grype sbom:./sbom.spdx.json
Approach Speed Accuracy Use Case
Direct image scan Slower (pulls layers) Discovers all packages First-time analysis, local dev
SBOM scan Fast (reads JSON file) Limited to SBOM contents CI pipelines, repeated scans, audit

The SBOM scan is only as complete as the SBOM itself. If Syft missed a package (e.g., a statically compiled binary), Grype won’t find vulnerabilities for it. For maximum coverage, generate the SBOM with Syft’s --catalogers all flag.

Exercise 3: Attach SBOM as a Cosign Attestation

An attestation is a signed statement about a software artifact. By attaching the SBOM as an attestation, you cryptographically bind the SBOM to the specific image digest — anyone can verify that the SBOM was produced for that exact image and hasn’t been tampered with.

Attach the SBOM attestation

Using Cosign’s keyless (Sigstore Fulcio) flow:

cosign attest --predicate sbom.spdx.json \
  --type spdxjson \
  --yes \
  $IMAGE

This command:

  1. Opens a browser for OIDC authentication (keyless signing via Fulcio)
  2. Creates an in-toto attestation with your SBOM as the predicate
  3. Signs the attestation and records it in the Rekor transparency log
  4. Pushes the attestation to the registry alongside the image

Verify the attestation

cosign verify-attestation \
  --type spdxjson \
  --certificate-identity=YOUR_EMAIL@example.com \
  --certificate-oidc-issuer=https://accounts.google.com \
  $IMAGE | jq '.payload | @base64d | fromjson | .predicate'

Replace the certificate identity and OIDC issuer with the values matching your Sigstore identity. In CI environments (GitHub Actions), these would be:

--certificate-identity=https://github.com/YOUR_ORG/YOUR_REPO/.github/workflows/build.yml@refs/heads/main
--certificate-oidc-issuer=https://token.actions.githubusercontent.com

Inspect the attestation in the registry

# List referrers (OCI 1.1 registries)
cosign tree $IMAGE

You should see the attestation listed as a referrer attached to the image manifest. The attestation is stored as an OCI artifact in the same repository, tagged with a sha256-<digest>.att convention.

Exercise 4: Build the Complete Pipeline in GitHub Actions

Now we automate everything into a single CI/CD workflow. This pipeline builds the image, generates the SBOM, scans for vulnerabilities, and attaches the SBOM as a signed attestation.

Create .github/workflows/sbom-pipeline.yml:

name: SBOM Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

permissions:
  contents: read
  packages: write
  id-token: write  # Required for Cosign keyless signing
  attestations: write

jobs:
  build-and-attest:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push image
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

      - name: Get image digest
        id: digest
        run: |
          echo "image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}" >> $GITHUB_OUTPUT

      - name: Install Syft
        uses: anchore/sbom-action/download-syft@v0

      - name: Generate SPDX SBOM
        run: |
          syft ${{ steps.digest.outputs.image }} -o spdx-json=sbom.spdx.json
          echo "### SBOM Summary" >> $GITHUB_STEP_SUMMARY
          echo "Package count: $(cat sbom.spdx.json | jq '.packages | length')" >> $GITHUB_STEP_SUMMARY

      - name: Install Grype
        uses: anchore/scan-action/download-grype@v4

      - name: Scan SBOM for vulnerabilities
        run: |
          grype sbom:./sbom.spdx.json -o json > vulnerabilities.json
          grype sbom:./sbom.spdx.json -o table >> $GITHUB_STEP_SUMMARY

          # Fail if critical vulnerabilities are found
          CRITICAL_COUNT=$(cat vulnerabilities.json | jq '[.matches[] | select(.vulnerability.severity=="Critical")] | length')
          echo "Critical vulnerabilities: $CRITICAL_COUNT"
          if [ "$CRITICAL_COUNT" -gt 0 ]; then
            echo "::error::Found $CRITICAL_COUNT critical vulnerabilities. Failing pipeline."
            exit 1
          fi

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3

      - name: Attest SBOM
        run: |
          cosign attest --predicate sbom.spdx.json \
            --type spdxjson \
            --yes \
            ${{ steps.digest.outputs.image }}

      - name: Verify attestation
        run: |
          cosign verify-attestation \
            --type spdxjson \
            --certificate-identity-regexp="https://github.com/${{ github.repository }}/" \
            --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
            ${{ steps.digest.outputs.image }}

      - name: Upload SBOM as artifact
        uses: actions/upload-artifact@v4
        with:
          name: sbom-spdx
          path: sbom.spdx.json
          retention-days: 90

      - name: Upload vulnerability report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: vulnerability-report
          path: vulnerabilities.json
          retention-days: 90

This workflow uses keyless signing via GitHub Actions’ OIDC identity. No private keys are stored in secrets — Cosign obtains a short-lived certificate from Sigstore’s Fulcio CA, and the signing event is recorded in the Rekor transparency log.

The pipeline fails if any critical vulnerabilities are detected. Adjust the threshold by changing the severity filter in the Grype scan step.

Exercise 5: Build the Pipeline in GitLab CI

The equivalent pipeline in GitLab CI uses the same tools but adapts to GitLab’s stage-based model and its built-in container registry.

Create .gitlab-ci.yml:

stages:
  - build
  - sbom
  - scan
  - attest

variables:
  IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  IMAGE_DIGEST: ""

build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $IMAGE .
    - docker push $IMAGE
    - |
      DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' $IMAGE)
      echo "IMAGE_DIGEST=$DIGEST" >> build.env
  artifacts:
    reports:
      dotenv: build.env

generate-sbom:
  stage: sbom
  image: alpine:3.19
  needs: [build]
  before_script:
    - apk add --no-cache curl jq
    - curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
  script:
    - syft $IMAGE_DIGEST -o spdx-json=sbom.spdx.json
    - echo "Package count:" $(cat sbom.spdx.json | jq '.packages | length')
  artifacts:
    paths:
      - sbom.spdx.json
    expire_in: 90 days

scan-vulnerabilities:
  stage: scan
  image: alpine:3.19
  needs: [generate-sbom]
  before_script:
    - apk add --no-cache curl jq
    - curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
  script:
    - grype sbom:./sbom.spdx.json -o json > vulnerabilities.json
    - grype sbom:./sbom.spdx.json -o table
    - |
      CRITICAL_COUNT=$(cat vulnerabilities.json | jq '[.matches[] | select(.vulnerability.severity=="Critical")] | length')
      echo "Critical vulnerabilities found: $CRITICAL_COUNT"
      if [ "$CRITICAL_COUNT" -gt 0 ]; then
        echo "ERROR: Critical vulnerabilities detected. Failing pipeline."
        exit 1
      fi
  artifacts:
    paths:
      - vulnerabilities.json
    expire_in: 90 days
    when: always

attest-sbom:
  stage: attest
  image: alpine:3.19
  needs: [build, generate-sbom, scan-vulnerabilities]
  id_tokens:
    SIGSTORE_ID_TOKEN:
      aud: sigstore
  before_script:
    - apk add --no-cache curl
    - curl -sSfL https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 -o /usr/local/bin/cosign
    - chmod +x /usr/local/bin/cosign
  script:
    - cosign attest --predicate sbom.spdx.json
        --type spdxjson
        --yes
        $IMAGE_DIGEST
    - cosign verify-attestation
        --type spdxjson
        --certificate-identity-regexp="https://gitlab.com/$CI_PROJECT_PATH//"
        --certificate-oidc-issuer=https://gitlab.com
        $IMAGE_DIGEST

Key differences from the GitHub Actions version:

  • GitLab uses stages instead of a single job with steps
  • The image digest is passed between stages via dotenv artifacts
  • GitLab’s OIDC token is exposed via id_tokens and the SIGSTORE_ID_TOKEN variable
  • The OIDC issuer for verification is https://gitlab.com

Exercise 6: Verify SBOM at Deployment

Generating and attesting SBOMs is only half the story. The real security benefit comes from enforcing verification at deployment time — rejecting any image that lacks a valid SBOM attestation.

Option A: Verify in a deployment script

#!/bin/bash
# deploy.sh — Verify SBOM attestation before deploying
set -euo pipefail

IMAGE="$1"

echo "Verifying SBOM attestation for: $IMAGE"

cosign verify-attestation \
  --type spdxjson \
  --certificate-identity-regexp="https://github.com/YOUR_ORG/YOUR_REPO/" \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  "$IMAGE" > /dev/null 2>&1

if [ $? -eq 0 ]; then
  echo "SBOM attestation verified successfully."
  kubectl set image deployment/myapp myapp="$IMAGE"
else
  echo "ERROR: SBOM attestation verification failed. Deployment blocked."
  exit 1
fi

Option B: Enforce with Kyverno in Kubernetes

Kyverno is a Kubernetes-native policy engine that can verify image signatures and attestations at admission time. The following policy rejects any pod whose image lacks a valid SBOM attestation.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-sbom-attestation
spec:
  validationFailureAction: Enforce
  webhookTimeoutSeconds: 30
  rules:
    - name: check-sbom-attestation
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/YOUR_ORG/*"
          attestations:
            - type: spdxjson
              attestors:
                - entries:
                    - keyless:
                        subject: "https://github.com/YOUR_ORG/YOUR_REPO/.github/workflows/sbom-pipeline.yml@refs/heads/main"
                        issuer: "https://token.actions.githubusercontent.com"
                        rekor:
                          url: https://rekor.sigstore.dev
              conditions:
                - all:
                    - key: "{{ len(spdxVersion) }}"
                      operator: GreaterThan
                      value: "0"

Apply the policy:

kubectl apply -f require-sbom-attestation.yaml

Test admission: image with attestation

# This should be ADMITTED
kubectl run test-admitted \
  --image=ghcr.io/YOUR_ORG/sbom-lab-app@sha256:abc123... \
  --restart=Never

# Expected output:
# pod/test-admitted created

Test admission: image without attestation

# This should be REJECTED
kubectl run test-rejected \
  --image=ghcr.io/YOUR_ORG/sbom-lab-app:unattested \
  --restart=Never

# Expected output:
# Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
# resource Pod/default/test-rejected was blocked due to the following policies:
# require-sbom-attestation:
#   check-sbom-attestation: 'image verification failed for ghcr.io/YOUR_ORG/sbom-lab-app:unattested:
#     attestation spdxjson not found'

This is the enforcement loop that makes SBOMs operationally meaningful — without verification at deployment, SBOM generation is documentation only.

Exercise 7: SBOM Diff Between Versions

When you release a new version, you need to understand what changed in your dependency tree. Diffing SBOMs between versions reveals added, removed, and updated packages — critical for assessing new risk.

Step 1: Generate SBOMs for two versions

# Generate SBOM for v1.0.0
syft ghcr.io/YOUR_GITHUB_USERNAME/sbom-lab-app:v1.0.0 -o spdx-json=sbom-v1.spdx.json

# Generate SBOM for v1.1.0 (after updating dependencies)
syft ghcr.io/YOUR_GITHUB_USERNAME/sbom-lab-app:v1.1.0 -o spdx-json=sbom-v2.spdx.json

Step 2: Extract package lists

# Extract sorted package lists
cat sbom-v1.spdx.json | jq -r '.packages[] | "\(.name)@\(.versionInfo)"' | sort > packages-v1.txt
cat sbom-v2.spdx.json | jq -r '.packages[] | "\(.name)@\(.versionInfo)"' | sort > packages-v2.txt

Step 3: Diff the package lists

diff --unified packages-v1.txt packages-v2.txt

Sample output:

--- packages-v1.txt
+++ packages-v2.txt
@@ -12,7 +12,8 @@
  express@4.18.2
  lodash@4.17.21
- axios@1.6.0
+ axios@1.7.2
+ helmet@7.1.0
  mime-types@2.1.35

Step 4: Identify newly introduced vulnerabilities

# Scan only the new/changed packages
grype sbom:./sbom-v2.spdx.json -o json > vulns-v2.json
grype sbom:./sbom-v1.spdx.json -o json > vulns-v1.json

# Compare vulnerability counts
echo "v1.0.0 vulnerabilities: $(cat vulns-v1.json | jq '.matches | length')"
echo "v1.1.0 vulnerabilities: $(cat vulns-v2.json | jq '.matches | length')"

# Find NEW vulnerabilities in v1.1.0
comm -13 \
  <(cat vulns-v1.json | jq -r '.matches[].vulnerability.id' | sort -u) \
  <(cat vulns-v2.json | jq -r '.matches[].vulnerability.id' | sort -u)

This diff process can be automated in CI to comment on pull requests with dependency changes, giving reviewers visibility into supply chain impact before merging.

Cleanup

Remove the resources created during this lab:

# Delete local files
rm -f sbom.spdx.json sbom.cyclonedx.json vulnerabilities.json
rm -f sbom-v1.spdx.json sbom-v2.spdx.json packages-v1.txt packages-v2.txt
rm -f vulns-v1.json vulns-v2.json

# Remove the test image from GHCR (requires gh CLI)
gh api -X DELETE /user/packages/container/sbom-lab-app/versions/PACKAGE_VERSION_ID

# Remove the Kyverno policy
kubectl delete clusterpolicy require-sbom-attestation

# Remove the test pods
kubectl delete pod test-admitted test-rejected --ignore-not-found

# Remove the local repository
cd .. && rm -rf sbom-pipeline-lab

Key Takeaways

  • SBOMs are a foundation for supply chain security — they provide the inventory that enables vulnerability scanning, license compliance, and provenance verification.
  • Syft generates SBOMs in all major formats — SPDX for regulatory compliance, CycloneDX for security workflows, both supported across the ecosystem.
  • Scanning the SBOM is faster than scanning the image — use Grype with sbom: prefix in CI pipelines for rapid feedback loops without pulling image layers.
  • Cosign attestations cryptographically bind SBOMs to images — signed attestations prove that a specific SBOM was produced for a specific image digest, stored in the registry alongside the image.
  • Verification at deployment is what makes SBOMs enforceable — without admission control (Kyverno, OPA Gatekeeper, or deployment scripts), SBOMs remain documentation without teeth.
  • SBOM diffs reveal supply chain changes between releases — automating dependency comparison in CI gives teams visibility into new risk before code reaches production.

Next Steps

Continue building your supply chain security practice with these related guides: