SBOM Testing: Software Bill of Materials with Syft, Grype, and CI Attestation
Software supply chain attacks have surged in recent years. SolarWinds, Log4Shell, and the XZ Utils backdoor all shared a common thread: attackers compromised components that developers trusted implicitly. A Software Bill of Materials (SBOM) is the foundational tool that lets you know exactly what is inside your software—and whether any of it is dangerous.
This guide covers everything you need to put SBOMs to work: generating them with Syft, scanning them with Grype, attesting them in CI, and integrating them with GitHub's dependency graph.
What Is an SBOM and Why Does It Matter?
An SBOM is a machine-readable inventory of every component in a piece of software: libraries, frameworks, transitive dependencies, operating system packages, and even compiler toolchains. Think of it as a nutrition label for software.
The US government's 2021 Executive Order on Improving the Nation's Cybersecurity mandated SBOMs for software sold to federal agencies. The EU Cyber Resilience Act follows the same logic. But even if you have no government customers, SBOMs give you something invaluable: the ability to answer "are we affected?" within minutes of a new CVE dropping—instead of days.
Two Dominant Formats
CycloneDX is developed by OWASP and is optimized for security use cases. It supports vulnerability data, license information, and service dependencies. The JSON and XML schemas are well-tooled and widely adopted by security scanners.
SPDX (Software Package Data Exchange) is an ISO standard (ISO/IEC 5962:2021) originally developed by the Linux Foundation. It is broader in scope, covering license compliance as well as security. SPDX 2.3 added support for relationships between components that make it useful for complex multi-repo projects.
For most teams starting out, CycloneDX JSON is the pragmatic choice: it is compact, well-supported by Grype and other scanners, and GitHub's dependency submission API accepts it natively.
Generating SBOMs with Syft
Syft is an open-source SBOM generator from Anchore. It can inspect container images, filesystems, archives, and directories, and it supports both CycloneDX and SPDX output.
Installation
# macOS
brew install syft
# Linux / CI
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
# Verify
syft versionGenerating an SBOM from a Container Image
# CycloneDX JSON (recommended for vulnerability scanning)
syft ghcr.io/myorg/myapp:latest -o cyclonedx-json > sbom.cdx.json
# SPDX JSON
syft ghcr.io/myorg/myapp:latest -o spdx-json > sbom.spdx.json
# Human-readable table (useful for quick inspection)
syft ghcr.io/myorg/myapp:latest -o tableSyft pulls the image, inspects every layer, and identifies packages through multiple catalogers: dpkg for Debian/Ubuntu, rpm for Red Hat-based images, apk for Alpine, and language-specific catalogers for Python (pip, Poetry), Node (npm, yarn), Go (go.mod), Java (Maven, Gradle), and more.
Generating an SBOM from a Local Directory
If you want an SBOM for your source code rather than a built image:
# From the current directory
syft dir:. -o cyclonedx-json > sbom.cdx.json
# From a specific path
syft dir:/path/to/project -o cyclonedx-json > sbom.cdx.jsonInspecting the Output
A CycloneDX JSON SBOM has a straightforward structure:
{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": "urn:uuid:...",
"version": 1,
"metadata": {
"timestamp": "2026-01-15T10:30:00Z",
"component": {
"type": "container",
"name": "myapp",
"version": "1.2.3"
}
},
"components": [
{
"type": "library",
"name": "express",
"version": "4.18.2",
"purl": "pkg:npm/express@4.18.2",
"licenses": [{ "license": { "id": "MIT" } }]
}
]
}The purl (Package URL) field is the key: it is a standardized identifier that vulnerability databases use to cross-reference components against known CVEs.
Vulnerability Scanning with Grype
Grype is Anchore's vulnerability scanner. It can scan container images directly, but its real power comes from scanning SBOMs—this means you can scan offline, scan faster, and scan the same artifact multiple times as new vulnerabilities are disclosed.
Installation
# macOS
brew install grype
# Linux / CI
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/binScanning an SBOM
# Scan a CycloneDX SBOM
grype sbom:./sbom.cdx.json
# Scan with SARIF output (importable into GitHub Security tab)
grype sbom:./sbom.cdx.json -o sarif > grype-results.sarif
# Scan and fail on critical/high vulnerabilities
grype sbom:./sbom.cdx.json --fail-on highUnderstanding Grype Output
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY
openssl 3.0.7 3.0.8 deb CVE-2023-0286 High
libxml2 2.9.14 (none) deb CVE-2022-40303 Medium
express 4.18.1 4.18.2 npm CVE-2022-24999 MediumThe FIXED-IN column is critical: if a fix exists, upgrading the package resolves the vulnerability. If it shows (none), you need to evaluate mitigating controls or consider alternative packages.
Grype Configuration File
Create .grype.yaml at your project root to set consistent policies:
# .grype.yaml
output: table
fail-on-severity: high
ignore:
# Accepted risk: no fix available, internal service only
- vulnerability: CVE-2022-40303
reason: "No fix available; service not internet-facing"
# False positive: we don't use the affected code path
- package:
name: lodash
version: 4.17.21
vulnerability: CVE-2021-23337SBOM Attestation in CI
Generating an SBOM is only half the job. Attestation proves that a specific SBOM belongs to a specific artifact and was produced by a trusted build process. Without attestation, an attacker could swap your SBOM for one that hides a malicious component.
Full GitHub Actions Workflow
# .github/workflows/sbom.yml
name: SBOM Generation and Scanning
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
packages: write
id-token: write # Required for keyless signing
security-events: write # Required for SARIF upload
jobs:
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build container image
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
- name: Push image
if: github.ref == 'refs/heads/main'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Install Syft
uses: anchore/sbom-action/download-syft@v0
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ github.repository }}:${{ github.sha }}
format: cyclonedx-json
output-file: sbom.cdx.json
upload-artifact: true
upload-release-assets: true
- name: Scan SBOM with Grype
uses: anchore/scan-action@v3
id: grype
with:
sbom: sbom.cdx.json
fail-build: true
severity-cutoff: high
output-format: sarif
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ${{ steps.grype.outputs.sarif }}
- name: Install cosign
if: github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@v3
- name: Attest SBOM
if: github.ref == 'refs/heads/main'
run: |
cosign attest \
--predicate sbom.cdx.json \
--type cyclonedx \
ghcr.io/${{ github.repository }}:${{ github.sha }}Submitting to GitHub's Dependency Graph
GitHub's Dependency Submission API lets you feed SBOM data into the native dependency graph, which powers Dependabot alerts and the dependency review action on PRs.
- name: Submit SBOM to GitHub Dependency Graph
uses: advanced-security/spdx-dependency-submission-action@v0.1.1
with:
filePath: sbom.spdx.jsonOr use the official action for CycloneDX:
- name: Submit Dependencies to GitHub
uses: mikepenz/sbom-action@v1
with:
output: sbom.cdx.json
token: ${{ secrets.GITHUB_TOKEN }}Once submitted, the dependency graph at https://github.com/ORG/REPO/network/dependencies shows every component your container image contains—not just what package.json or go.mod declares, but what actually ended up in the image.
Verifying SBOM Attestations
When pulling an image, you can verify its SBOM attestation was produced by your CI pipeline:
cosign verify-attestation \
--type cyclonedx \
--certificate-identity-regexp "https://github.com/myorg/myapp/.github/workflows/sbom.yml" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp:latest | jq '.payload | @base64d | fromjson | .predicate.components | length'This command:
- Verifies the attestation signature against Sigstore's transparency log
- Confirms the identity of the workflow that signed it
- Decodes the SBOM payload and counts the components
If the attestation is missing or invalid, the command fails—giving you a hard gate before deployment.
Enforcing SBOM Policy in Kubernetes
Once images have SBOM attestations, you can enforce policy at the cluster level with Kyverno:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-sbom-attestation
spec:
validationFailureAction: Enforce
rules:
- name: check-sbom-attestation
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences: ["ghcr.io/myorg/*"]
attestations:
- predicateType: https://cyclonedx.org/bom
conditions:
- all:
- key: "{{ components | length(@) }}"
operator: GreaterThan
value: 0Integrating SBOM Checks into Pull Requests
Add a PR comment that surfaces new vulnerabilities introduced by a change:
- name: Diff SBOM against base branch
if: github.event_name == 'pull_request'
run: |
# Generate SBOM for base branch image
syft ghcr.io/${{ github.repository }}:${{ github.base_ref }} \
-o cyclonedx-json > sbom-base.cdx.json
# Compare component counts
BASE_COUNT=$(jq '.components | length' sbom-base.cdx.json)
HEAD_COUNT=$(jq '.components | length' sbom.cdx.json)
echo "## SBOM Diff" >> $GITHUB_STEP_SUMMARY
echo "Base: $BASE_COUNT components | PR: $HEAD_COUNT components" >> $GITHUB_STEP_SUMMARY
echo "Delta: $((HEAD_COUNT - BASE_COUNT)) components" >> $GITHUB_STEP_SUMMARYBuilding an SBOM Culture
Tooling is the easy part. The organizational shift is harder.
Store SBOMs as release artifacts. Every release should include its SBOM in a predictable location—alongside the binary or in the container registry as an attestation. When CVE-2024-XXXX drops at 2am, you need to answer "are we affected?" in five minutes, not five hours.
Scan on schedule, not just on build. New vulnerabilities appear against old code. Add a nightly workflow that scans the SBOMs of your last three production releases against the latest vulnerability databases.
Track SBOM age. An SBOM generated six months ago is stale. Treat it like a perishable. Any image that has not been rebuilt in 90 days should have its SBOM regenerated and rescanned.
Integrate with your ticketing system. Grype's JSON output can be parsed to auto-create tickets for high and critical findings. Don't make humans manually translate scanner output into Jira issues.
SBOMs are not a silver bullet—they cannot prevent vulnerabilities from being introduced. But they dramatically shrink the window between disclosure and response, and they are rapidly becoming a baseline requirement for enterprise customers and regulated industries. Start generating them now, while the tooling is mature and the process is still optional.