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.

#supply-chain#sbom#security#compliance
Cover image for the article: Software Supply Chain Security with SBOM at Scale: From Compliance to Defense

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

SBOM Pipeline Architecture

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:

  1. A new maintainer had published the version 48 hours prior
  2. The package added a postinstall script not present in previous versions
  3. 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

MetricBefore SBOM SystemAfter (6 months)
Services with SBOMs2214 (100%)
Mean time to detect vulnerable dep4.2 days38 minutes
Blocked deployments (policy violations)0847
False positive rateN/A3.2%
Compliance audit prep time2 weeks2 hours
SBOM generation time (avg)N/A18 seconds

SBOM Coverage Growth

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.

Comments

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