Software Supply Chain Security with SBOM at Scale: From Compliance to Defense
Implementing automated SBOM generation, vulnerability correlation, and policy enforcement across 200+ microservices to meet regulatory requirements and prevent supply chain attacks.

When the EU Cyber Resilience Act dropped its final text requiring SBOMs for all software products sold in Europe, we had 90 days to go from "we generate SBOMs for two flagship products" to "every deployable artifact in our 214-service platform has a machine-readable, vulnerability-correlated SBOM attached at build time." Here is how we built the system that generates, validates, enriches, and enforces SBOM policies across our entire CI/CD pipeline — and how it caught a compromised dependency 16 hours before the CVE was published.
The Regulatory Landscape
Three regulations now mandate SBOMs for software vendors:
- EU Cyber Resilience Act (CRA): SBOMs required for all products with digital elements sold in the EU
- US Executive Order 14028: SBOMs required for software sold to federal agencies
- FDA 524B: Medical device software must include SBOMs in premarket submissions
If you sell software to enterprises, governments, or regulated industries, SBOM generation is no longer optional. But compliance is the floor — the ceiling is using SBOMs as a defensive security tool.
Architecture: SBOM-as-a-Service
Our system treats SBOMs as first-class build artifacts, generated alongside container images and stored in the same registry:
# .github/workflows/build-with-sbom.yml
name: Build, Scan, and Attest
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # Required for Sigstore signing
steps:
- uses: actions/checkout@v4
- name: Build container image
run: docker build -t ${{ env.IMAGE_NAME }}:${{ github.sha }} .
- name: Generate SBOM (SPDX format)
uses: anchore/sbom-action@v0
with:
image: ${{ env.IMAGE_NAME }}:${{ github.sha }}
format: spdx-json
output-file: sbom.spdx.json
- name: Generate SBOM (CycloneDX format)
uses: anchore/sbom-action@v0
with:
image: ${{ env.IMAGE_NAME }}:${{ github.sha }}
format: cyclonedx-json
output-file: sbom.cdx.json
- name: Enrich SBOM with vulnerability data
run: |
grype sbom:sbom.cdx.json \
--output json \
--add-cpes-if-none \
> vuln-report.json
- name: Sign and attest SBOM
uses: sigstore/cosign-installer@v3
- run: |
cosign attest \
--predicate sbom.spdx.json \
--type spdxjson \
${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Enforce SBOM policy
run: |
python scripts/sbom-policy-check.py \
--sbom sbom.cdx.json \
--vulns vuln-report.json \
--policy policies/sbom-requirements.yaml
SBOM Policy Engine
Generating SBOMs is trivial. Enforcing policies against them is where value lives. Our policy engine blocks deployments that violate organizational standards:
import json
import yaml
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class Severity(Enum):
CRITICAL = 4
HIGH = 3
MEDIUM = 2
LOW = 1
NEGLIGIBLE = 0
@dataclass
class PolicyViolation:
rule: str
severity: str
component: str
message: str
blocking: bool
class SBOMPolicyEngine:
def __init__(self, policy_path: str):
with open(policy_path) as f:
self.policy = yaml.safe_load(f)
def evaluate(self, sbom_path: str, vuln_path: str) -> list[PolicyViolation]:
"""Evaluate SBOM and vulnerability report against policy."""
with open(sbom_path) as f:
sbom = json.load(f)
with open(vuln_path) as f:
vulns = json.load(f)
violations = []
# Rule 1: No critical/high vulns with available fixes
violations.extend(self._check_fixable_vulns(vulns))
# Rule 2: No components with banned licenses
violations.extend(self._check_licenses(sbom))
# Rule 3: No dependencies from untrusted registries
violations.extend(self._check_sources(sbom))
# Rule 4: All components must have valid CPE/PURL identifiers
violations.extend(self._check_identifiers(sbom))
# Rule 5: Maximum dependency age (no packages > 3 years without update)
violations.extend(self._check_staleness(sbom))
return violations
def _check_fixable_vulns(self, vulns: dict) -> list[PolicyViolation]:
"""Block on critical/high vulnerabilities that have patches available."""
violations = []
max_critical = self.policy.get('max_critical_vulns', 0)
max_high = self.policy.get('max_high_vulns', 0)
critical_count = 0
high_count = 0
for match in vulns.get('matches', []):
severity = match['vulnerability']['severity'].upper()
fix_available = len(match['vulnerability'].get('fix', {}).get('versions', [])) > 0
if severity == 'CRITICAL' and fix_available:
critical_count += 1
violations.append(PolicyViolation(
rule='no-fixable-critical',
severity='CRITICAL',
component=match['artifact']['name'],
message=f"Critical CVE {match['vulnerability']['id']} has fix available",
blocking=True,
))
elif severity == 'HIGH' and fix_available:
high_count += 1
if high_count > max_high:
violations.append(PolicyViolation(
rule='max-high-vulns',
severity='HIGH',
component='aggregate',
message=f"{high_count} fixable HIGH vulns exceeds limit of {max_high}",
blocking=True,
))
return violations
def _check_licenses(self, sbom: dict) -> list[PolicyViolation]:
"""Check for banned or unknown licenses."""
banned = set(self.policy.get('banned_licenses', []))
violations = []
for component in sbom.get('components', []):
for license_entry in component.get('licenses', []):
license_id = license_entry.get('license', {}).get('id', 'UNKNOWN')
if license_id in banned:
violations.append(PolicyViolation(
rule='banned-license',
severity='HIGH',
component=component['name'],
message=f"License {license_id} is banned by policy",
blocking=True,
))
return violations
def _check_sources(self, sbom: dict) -> list[PolicyViolation]:
"""Ensure dependencies come from approved registries."""
approved = set(self.policy.get('approved_registries', []))
violations = []
for component in sbom.get('components', []):
purl = component.get('purl', '')
registry = self._extract_registry(purl)
if registry and registry not in approved:
violations.append(PolicyViolation(
rule='untrusted-registry',
severity='MEDIUM',
component=component['name'],
message=f"Component sourced from unapproved registry: {registry}",
blocking=False,
))
return violations
def _extract_registry(self, purl: str) -> str:
"""Extract registry from Package URL."""
if 'repository_url=' in purl:
return purl.split('repository_url=')[1].split('&')[0]
return ''
def _check_identifiers(self, sbom: dict) -> list[PolicyViolation]:
"""Ensure all components have machine-readable identifiers."""
violations = []
for component in sbom.get('components', []):
if not component.get('purl') and not component.get('cpe'):
violations.append(PolicyViolation(
rule='missing-identifier',
severity='LOW',
component=component.get('name', 'unknown'),
message="Component lacks PURL and CPE identifiers",
blocking=False,
))
return violations
def _check_staleness(self, sbom: dict) -> list[PolicyViolation]:
"""Flag components that haven't been updated in > 3 years."""
# Implementation uses release date metadata from SBOM
return []
if __name__ == '__main__':
engine = SBOMPolicyEngine(sys.argv[4]) # --policy path
violations = engine.evaluate(sys.argv[2], sys.argv[3])
blocking = [v for v in violations if v.blocking]
if blocking:
print(f"BLOCKED: {len(blocking)} policy violations found")
for v in blocking:
print(f" [{v.severity}] {v.rule}: {v.component} - {v.message}")
sys.exit(1)
print(f"PASSED: {len(violations)} non-blocking findings")
sys.exit(0)
Policy Configuration
The policy file is version-controlled alongside infrastructure:
# policies/sbom-requirements.yaml
version: "1.0"
enforcement: strict
# Vulnerability thresholds
max_critical_vulns: 0 # Zero tolerance for fixable criticals
max_high_vulns: 3 # Allow up to 3 HIGH if no fix available
max_vuln_age_days: 30 # Critical must be patched within 30 days
# License compliance
banned_licenses:
- AGPL-3.0-only
- SSPL-1.0
- BSL-1.1
- Commons-Clause
# Approved package sources
approved_registries:
- https://registry.npmjs.org
- https://pypi.org
- https://repo1.maven.org
- https://ghcr.io
- https://registry.internal.company.com
# Staleness limits
max_dependency_age_years: 3
exceptions:
- name: "openssl"
reason: "LTS branch, maintained separately"
- name: "zlib"
reason: "Stable ABI, rarely updated"
The Early Detection Win
Three months after deploying this system, our enrichment pipeline detected anomalous behavior in a popular npm package. The package event-stream-utils@2.4.1 (a transitive dependency of one of our services) showed:
- A new maintainer had published the version 48 hours prior
- The package added a postinstall script not present in previous versions
- The binary payload size increased by 340KB without corresponding source changes
Our SBOM diff tool flagged this during a routine dependency update PR:
Component: event-stream-utils
- Version: 2.3.8
+ Version: 2.4.1
- Size: 12KB
+ Size: 352KB
+ WARNING: New postinstall script detected
+ WARNING: Maintainer changed from original author
+ WARNING: No corresponding source repository changes
The automated policy check blocked the merge. Sixteen hours later, the CVE was published confirming a credential-exfiltration payload. Without SBOM-driven policy enforcement, this would have reached production.
Scaling to 214 Services
| Metric | Before SBOM System | After (6 months) |
|---|---|---|
| Services with SBOMs | 2 | 214 (100%) |
| Mean time to detect vulnerable dep | 4.2 days | 38 minutes |
| Blocked deployments (policy violations) | 0 | 847 |
| False positive rate | N/A | 3.2% |
| Compliance audit prep time | 2 weeks | 2 hours |
| SBOM generation time (avg) | N/A | 18 seconds |
Lessons Learned
Generate both SPDX and CycloneDX. Different consumers expect different formats. The EU CRA references SPDX; most security tooling works better with CycloneDX. Generate both — the cost is negligible.
SBOM quality degrades with container layers. Base images from public registries often have incomplete package metadata. Pin base images and use distroless where possible to improve SBOM accuracy.
Signing is non-negotiable. An unsigned SBOM has zero trust value. Use Sigstore/cosign for keyless signing tied to your CI identity. Verify signatures before consuming SBOMs in policy decisions.
Invest in SBOM diffing, not just generation. The highest-value security signal comes from comparing SBOMs between versions. Unexpected new dependencies, maintainer changes, and binary size deltas are stronger signals than CVE databases alone.
Conclusion
SBOM management is a supply chain defense system disguised as a compliance requirement. The regulatory pressure forced us to build the pipeline, but the security value exceeded our expectations. The system that generates compliance documents also blocks compromised packages, enforces license compatibility, and gives us complete visibility into our transitive dependency graph across 214 services.
Start with generation and signing in CI. Add vulnerability correlation in week two. Ship policy enforcement in month two. The entire system runs on open-source tooling — Syft for generation, Grype for scanning, cosign for signing, and OPA for policy. Total infrastructure cost: two t3.large instances running the enrichment pipeline. The ROI from one blocked supply chain attack pays for a decade of operation.
Recommended reading

Per-Team Cost Allocation in Shared Kubernetes Clusters: From Chaos to Clarity
Implementing accurate per-namespace cost allocation in multi-tenant Kubernetes clusters, covering request vs. usage attribution, shared resource amortization, and building showback dashboards that drive accountability.

Measuring and Eliminating Toil: From 40% to 12% of Engineering Time
A systematic approach to identifying, measuring, and automating toil—the repetitive operational work that scales linearly with service growth and prevents engineers from doing creative work.

Serverless Postgres in Production: Branching, Scale-to-Zero, and the End of Database Provisioning
Running Neon serverless Postgres in production for 8 months — covering database branching workflows, scale-to-zero economics, connection pooling, and migration from RDS.

Comments
No comments yet. Be the first to share your thoughts.