Every dependency you ship is a liability you haven't fully mapped. The Log4Shell and XZ Utils incidents proved that supply chain attacks are real, scalable, and expensive to remediate without a pre-existing inventory. An SBOM — Software Bill of Materials — is that inventory. The problem: most teams treat SBOM generation as a one-time compliance checkbox rather than a continuously updated artifact built on every commit.
What an SBOM Is (and Which Format to Choose)
An SBOM is a machine-readable list of all components in a software artifact: libraries, OS packages, versions, licenses, and the dependency graph between them. Two formats dominate:
- CycloneDX — JSON or XML, designed for security use cases, native support in most vulnerability scanners
- SPDX — the Linux Foundation standard, more compliance-oriented (required for some US government contracts post-EO 14028)
For security-first workflows, CycloneDX is the pragmatic choice. For supply chain compliance (NTIA, DoD), SPDX may be required. Both formats can coexist — Syft can emit both from the same scan run.
Generating SBOMs Automatically with Syft
Syft by Anchore is the most widely adopted open-source SBOM generator. It supports Docker images, filesystems, Go binaries, Python wheels, npm trees, and more.
# Install Syft
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
# Generate SBOM for a Docker image (CycloneDX JSON)
syft your-registry.io/your-app:latest -o cyclonedx-json=sbom.cdx.json
# Generate SBOM for a Go or Python project directory
syft dir:. -o cyclonedx-json=sbom.cdx.json
# Emit both formats simultaneously
syft dir:. \
-o cyclonedx-json=sbom.cdx.json \
-o spdx-json=sbom.spdx.json
Syft walks every layer of a container image and every package manifest in a source tree. The output includes PURL (Package URL) identifiers for each component — the standard format that vulnerability databases like OSV.dev and Grype use to match packages against CVE records.
Querying Vulnerabilities from the SBOM
An SBOM without vulnerability correlation is just documentation. The real value is failing the CI build automatically when a critical CVE appears in one of your dependencies.
This Python script reads a CycloneDX JSON SBOM and queries the OSV.dev API for known vulnerabilities against each component's PURL:
import json
import sys
import httpx
CRITICAL_SEVERITIES = {"CRITICAL", "HIGH"}
def query_osv(purl: str) -> list[dict]:
resp = httpx.post(
"https://api.osv.dev/v1/query",
json={"package": {"purl": purl}},
timeout=10,
)
resp.raise_for_status()
return resp.json().get("vulns", [])
def check_sbom(sbom_path: str, fail_on: set[str] = CRITICAL_SEVERITIES) -> int:
with open(sbom_path) as f:
sbom = json.load(f)
components = sbom.get("components", [])
findings = []
for component in components:
purl = component.get("purl")
if not purl:
continue
for vuln in query_osv(purl):
severity = vuln.get("database_specific", {}).get("severity", "UNKNOWN").upper()
if severity in fail_on:
findings.append({
"component": component.get("name"),
"version": component.get("version"),
"vuln_id": vuln.get("id"),
"severity": severity,
"summary": vuln.get("summary", ""),
})
if findings:
print(f"\n[FAIL] {len(findings)} critical/high CVEs found:\n")
for f in findings:
print(f" {f['vuln_id']} ({f['severity']}) — {f['component']}@{f['version']}")
print(f" {f['summary']}\n")
return 1
print(f"[OK] {len(components)} components scanned, no critical/high CVEs.")
return 0
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "sbom.cdx.json"
sys.exit(check_sbom(path))
The script exits with code 1 on any HIGH or CRITICAL finding — which fails the CI step. Adjust fail_on to include MEDIUM if your security policy requires it. The OSV.dev API is free, covers PyPI, npm, Go modules, Maven, RubyGems, crates.io, and OS packages from Debian, Alpine, and others.
Wiring It Into GitHub Actions
Here's a complete workflow that builds a Docker image, generates the SBOM, checks vulnerabilities, and uploads the SBOM as a retained artifact:
name: Build and SBOM
on:
push:
branches: [main]
pull_request:
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t app:${{ github.sha }} .
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: app:${{ github.sha }}
format: cyclonedx-json
output-file: sbom.cdx.json
- name: Check for critical CVEs
run: |
pip install httpx
python scripts/check_sbom.py sbom.cdx.json
- name: Store SBOM
uses: actions/upload-artifact@v4
with:
name: sbom-${{ github.sha }}
path: sbom.cdx.json
retention-days: 90
A few practical notes on this workflow:
-
anchore/sbom-actionwraps Syft installation and execution in a single step, no manual installation needed - The SBOM is kept for 90 days — useful for post-incident forensics when you need to reconstruct what was running at the time of a breach
- Vulnerability checking runs as its own step so the GitHub Actions UI pinpoints exactly where the build failed
Signing and Attesting SBOMs for Audit Trails
Storing an SBOM proves you have one. Attesting it cryptographically proves it was generated from a specific commit by your CI pipeline — not altered after the fact. This matters for compliance frameworks and security hardening audits that require chain-of-custody evidence.
Sigstore's cosign supports keyless attestation via the GitHub Actions OIDC token:
# Attest the SBOM to the image (keyless, uses GitHub OIDC)
cosign attest \
--type cyclonedx \
--predicate sbom.cdx.json \
your-registry.io/your-app:${{ github.sha }}
# Later, verify attestation
cosign verify-attestation \
--type cyclonedx \
--certificate-identity-regexp "https://github.com/your-org/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
your-registry.io/your-app:${{ github.sha }}
With keyless signing, cosign requests a short-lived certificate from Fulcio bound to the GitHub Actions job identity, then publishes the attestation to the Rekor transparency log. No key management. Anyone can verify that the SBOM for a given image hash was produced by your CI and not modified.
The Takeaway
SBOM generation is not a one-time deliverable. In a mature DevSecOps pipeline it runs on every build, stores the result, and breaks the build on critical CVEs — fully automatically.
The stack here — Syft, OSV.dev, GitHub Actions, cosign — is entirely open source. The operational cost is near zero beyond CI minutes. Start with generation and the vulnerability gate. Add attestation when you have a consumer for it: a customer contract, a government requirement, or an internal audit.
The harder problem this doesn't solve: what to do when a build breaks. A failed job is only useful if there's a defined process — upgrade the dependency, patch it, or accept the risk with a documented exception. Build the automation first. Then build the process around it.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)