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:
- Opens a browser for OIDC authentication (keyless signing via Fulcio)
- Creates an in-toto attestation with your SBOM as the predicate
- Signs the attestation and records it in the Rekor transparency log
- 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
dotenvartifacts - GitLab’s OIDC token is exposed via
id_tokensand theSIGSTORE_ID_TOKENvariable - 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:
- Artifact Provenance and Attestations — Learn how SLSA and in-toto frameworks extend attestation beyond SBOMs to cover build provenance, source integrity, and deployment policies.
- Signing and Verifying Container Images with Sigstore and Cosign — Deep dive into keyless signing, Fulcio certificates, Rekor transparency logs, and image signature verification policies.