SBOM Policy Enforcement: Automating Supply Chain Compliance in CI/CD

SBOM Policy Enforcement: Automating Supply Chain Compliance in CI/CD

Generating an SBOM (Software Bill of Materials) is the easy part. The hard part is doing something useful with it — automatically enforcing policies that prevent vulnerable, unlicensed, or untrusted components from making it into your production builds.

This guide covers the full SBOM lifecycle: generation, validation, policy enforcement, and continuous monitoring.

Why Policy Enforcement Matters

An SBOM without enforcement is like a smoke detector without a battery. You know the data exists, but it's not protecting you.

Policy enforcement means:

  • Blocking builds when critical vulnerabilities are introduced
  • Failing PRs when prohibited licenses appear
  • Alerting security teams when new high-risk components are added
  • Generating compliance artifacts for audits and customer requests

Executive Order 14028 (US federal, 2021) and similar regulations in the EU (Cyber Resilience Act) now require SBOMs for software sold to government entities. Even if regulation doesn't apply to you yet, the practices are worth adopting.

SBOM Formats: CycloneDX vs SPDX

Two formats dominate:

CycloneDX (OWASP):

  • Richer vulnerability data integration
  • Better for DevSecOps workflows
  • Supported by most SCA tools
  • Native JSON and XML

SPDX (Linux Foundation):

  • ANSI/ISO standard (ISO 5962:2021)
  • Preferred for license compliance and legal workflows
  • US government requirement via EO 14028
  • Native JSON, YAML, RDF, tag-value

For most teams: generate CycloneDX for security workflows, SPDX for compliance/legal.

Generating SBOMs

# Install
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin

# Generate CycloneDX JSON
syft packages . -o cyclonedx-json=sbom.cyclonedx.json

# Generate SPDX JSON
syft packages . -o spdx-json=sbom.spdx.json

# Generate SPDX tag-value (for regulatory submissions)
syft packages . -o spdx-tag-value=sbom.spdx

# Scan a Docker image
syft packages myapp:latest -o cyclonedx-json > image-sbom.json

# Include all dependency types
syft packages . \
  --scope all-layers \
  -o cyclonedx-json=full-sbom.json

Trivy (Multi-Scanner + SBOM)

# Generate SBOM while scanning
trivy fs . \
  --format cyclonedx \
  --output sbom.cyclonedx.json

# Generate SPDX
trivy fs . \
  --format spdx-json \
  --output sbom.spdx.json

Maven CycloneDX Plugin

<!-- pom.xml -->
<plugin>
  <groupId>org.cyclonedx</groupId>
  <artifactId>cyclonedx-maven-plugin</artifactId>
  <version>2.7.11</version>
  <configuration>
    <projectType>library</projectType>
    <schemaVersion>1.4</schemaVersion>
    <includeBomSerialNumber>true</includeBomSerialNumber>
    <includeCompileScope>true</includeCompileScope>
    <includeProvidedScope>false</includeProvidedScope>
    <includeRuntimeScope>true</includeRuntimeScope>
    <includeSystemScope>false</includeSystemScope>
    <includeTestScope>false</includeTestScope>
    <includeLicenseText>false</includeLicenseText>
    <outputReactorProjects>true</outputReactorProjects>
    <outputFormat>json</outputFormat>
    <outputName>bom</outputName>
    <outputDirectory>${project.build.directory}</outputDirectory>
    <verbose>false</verbose>
  </configuration>
  <executions>
    <execution>
      <phase>package</phase>
      <goals>
        <goal>makeAggregateBom</goal>
      </goals>
    </execution>
  </executions>
</plugin>

Policy Enforcement with OPA (Open Policy Agent)

OPA is the industry standard for policy-as-code. You write policies in Rego, evaluate SBOMs against them.

Setting Up OPA

# Install
brew install opa
# or
curl -L -o opa https://openpolicyagent.org/downloads/v0.60.0/opa_linux_amd64_static
chmod +x opa

# Evaluate a policy against an SBOM
opa eval \
  --data policy.rego \
  --input sbom.cyclonedx.json \
  "data.sbom.deny"

Writing SBOM Policies

# policy/sbom_policy.rego
package sbom

import future.keywords.if
import future.keywords.in
import future.keywords.every

# Define prohibited licenses
prohibited_licenses := {
  "GPL-2.0",
  "GPL-2.0-only",
  "GPL-3.0",
  "GPL-3.0-only",
  "AGPL-3.0",
  "AGPL-3.0-only",
  "AGPL-3.0-or-later",
}

# Define approved component sources (trusted registries)
trusted_sources := {
  "pkg:npm/",
  "pkg:pypi/",
  "pkg:maven/",
  "pkg:golang/",
}

# Collect all deny violations
deny[msg] {
  some component in input.components
  some license in component.licenses
  license.license.id in prohibited_licenses
  msg := sprintf(
    "Component '%s' (%s) has prohibited license: %s",
    [component.name, component.version, license.license.id]
  )
}

# Deny components without any license
deny[msg] {
  some component in input.components
  not component.licenses
  not startswith(component.name, "@types/")  # Exception for TypeScript types
  msg := sprintf(
    "Component '%s' (%s) has no license information",
    [component.name, component.version]
  )
}

# Deny if no SBOM serial number (indicates incomplete generation)
deny[msg] {
  not input.serialNumber
  msg := "SBOM is missing serialNumber — may be incomplete"
}

# Warn on weak copyleft (don't fail, but surface)
warn[msg] {
  some component in input.components
  some license in component.licenses
  license.license.id in {"LGPL-2.1", "LGPL-3.0", "MPL-2.0", "EPL-2.0"}
  msg := sprintf(
    "Component '%s' has weak copyleft license '%s' — verify linking type",
    [component.name, license.license.id]
  )
}

# Count components for audit
component_count := count(input.components)

# Summary report
summary := {
  "total_components": component_count,
  "violations": count(deny),
  "warnings": count(warn),
  "compliant": count(deny) == 0,
}

Vulnerability Policy

# policy/vulnerability_policy.rego
package sbom.vulnerabilities

import future.keywords.if
import future.keywords.in

# Fail on critical vulnerabilities
deny[msg] {
  some vuln in input.vulnerabilities
  vuln.ratings[_].severity == "critical"
  not is_exception(vuln.id)
  msg := sprintf(
    "Critical vulnerability %s found in %s — fix or add to exceptions",
    [vuln.id, vuln.affects[0].ref]
  )
}

# Fail on high vulnerabilities with CVSS >= 9.0
deny[msg] {
  some vuln in input.vulnerabilities
  some rating in vuln.ratings
  rating.severity == "high"
  rating.score >= 9.0
  not is_exception(vuln.id)
  msg := sprintf(
    "High-severity vulnerability %s (CVSS %.1f) in %s must be remediated",
    [vuln.id, rating.score, vuln.affects[0].ref]
  )
}

# Exception list (separate data file)
is_exception(cve_id) {
  data.exceptions[cve_id]
}

Exception Data File

// policy/exceptions.json
{
  "CVE-2023-45133": {
    "reason": "Only affects Babel CLI, not library mode. We use library.",
    "reviewer": "security@company.com",
    "expiry": "2025-01-15"
  },
  "CVE-2022-25883": {
    "reason": "semver RegEx DoS - only triggered by malicious version strings we control",
    "reviewer": "security@company.com",
    "expiry": "2024-12-31"
  }
}

Running Policy Evaluation

#!/bin/bash
# evaluate-sbom-policy.sh

SBOM_FILE="sbom.cyclonedx.json"
POLICY_DIR="./policy"

echo "=== SBOM Policy Evaluation ==="

# License policy
echo ""
echo "--- License Compliance ---"
DENY_OUTPUT=$(opa eval \
  --data "$POLICY_DIR/sbom_policy.rego" \
  --input "$SBOM_FILE" \
  --format raw \
  "data.sbom.deny")

DENY_COUNT=$(echo "$DENY_OUTPUT" | jq length)

if [ "$DENY_COUNT" -gt "0" ]; then
  echo "❌ $DENY_COUNT license violations:"
  echo "$DENY_OUTPUT" | jq -r '.[]'
  LICENSE_FAIL=1
else
  echo "✓ No license violations"
fi

# Warnings
WARN_OUTPUT=$(opa eval \
  --data "$POLICY_DIR/sbom_policy.rego" \
  --input "$SBOM_FILE" \
  --format raw \
  "data.sbom.warn")

WARN_COUNT=$(echo "$WARN_OUTPUT" | jq length)
if [ "$WARN_COUNT" -gt "0" ]; then
  echo ""
  echo "⚠️  $WARN_COUNT warnings:"
  echo "$WARN_OUTPUT" | jq -r '.[]'
fi

# Final result
if [ "${LICENSE_FAIL:-0}" -eq "1" ]; then
  echo ""
  echo "FAIL: Policy violations found"
  exit 1
else
  echo ""
  echo "PASS: All policies satisfied"
fi

CI/CD Integration

# .github/workflows/sbom-policy.yml
name: SBOM Generation and Policy Check

on:
  push:
    branches: [main]
  pull_request:
  release:
    types: [created]

jobs:
  sbom-generate-and-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up tools
        run: |
          # Install Syft
          curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
          # Install OPA
          curl -L -o /usr/local/bin/opa https://openpolicyagent.org/downloads/v0.60.0/opa_linux_amd64_static
          chmod +x /usr/local/bin/opa
          
      - name: Generate SBOM
        run: |
          syft packages . \
            -o cyclonedx-json=sbom.cyclonedx.json \
            -o spdx-json=sbom.spdx.json
            
      - name: Validate SBOM completeness
        run: |
          # Check that SBOM has required fields
          python3 - <<'EOF'
          import json, sys
          
          with open('sbom.cyclonedx.json') as f:
              sbom = json.load(f)
          
          issues = []
          if not sbom.get('serialNumber'):
              issues.append("Missing serialNumber")
          if not sbom.get('metadata', {}).get('timestamp'):
              issues.append("Missing metadata.timestamp")
          if not sbom.get('components'):
              issues.append("No components found  SBOM may be empty")
          
          component_count = len(sbom.get('components', []))
          print(f"SBOM contains {component_count} components")
          
          if issues:
              print("SBOM validation issues:", issues)
              sys.exit(1)
          print("SBOM validation passed")
          EOF
          
      - name: Run license policy
        run: bash evaluate-sbom-policy.sh
        
      - name: Scan SBOM for vulnerabilities with Grype
        run: |
          curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
          grype sbom:sbom.cyclonedx.json \
            --fail-on high \
            -o json > grype-results.json || VULN_FAIL=1
          
          if [ "${VULN_FAIL:-0}" -eq "1" ]; then
            echo "Vulnerability scan found HIGH/CRITICAL issues:"
            cat grype-results.json | jq '.matches[] | select(.vulnerability.severity == "High" or .vulnerability.severity == "Critical") | {id: .vulnerability.id, severity: .vulnerability.severity, pkg: .artifact.name, version: .artifact.version}'
            exit 1
          fi
          
      - name: Upload SBOM artifacts
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: sbom-${{ github.sha }}
          path: |
            sbom.cyclonedx.json
            sbom.spdx.json
            grype-results.json
            
      - name: Attach SBOM to release
        if: github.event_name == 'release'
        run: |
          gh release upload ${{ github.event.release.tag_name }} \
            sbom.cyclonedx.json \
            sbom.spdx.json
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

SBOM Signing with Cosign

For supply chain integrity, sign your SBOMs so consumers can verify they haven't been tampered with:

# Install cosign
brew install cosign

# Generate key pair (store private key in secrets)
cosign generate-key-pair

# Sign the SBOM
cosign sign-blob \
  --key cosign.key \
  --output-signature sbom.cyclonedx.json.sig \
  sbom.cyclonedx.json

# Verify (consumer side)
cosign verify-blob \
  --key cosign.pub \
  --signature sbom.cyclonedx.json.sig \
  sbom.cyclonedx.json
# In CI, after generating SBOM:
- name: Sign SBOM with Cosign
  env:
    COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
    COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
  run: |
    echo "$COSIGN_PRIVATE_KEY" > cosign.key
    cosign sign-blob \
      --key cosign.key \
      --output-signature sbom.cyclonedx.json.sig \
      sbom.cyclonedx.json
    rm cosign.key

Continuous Monitoring with HelpMeTest

SBOMs generated at build time become stale. New CVEs are published daily against components you shipped months ago. HelpMeTest can run scheduled health checks that re-evaluate your production SBOMs against current vulnerability databases:

Health Check: Production SBOM vulnerability re-scan
Schedule: Daily at 6 AM
Steps:
  1. Download latest SBOM from last production release
  2. Run Grype scan against current vulnerability database
  3. Compare results against baseline from deployment date
  4. Alert security team if new HIGH/CRITICAL CVEs found for deployed components
  5. Create Jira ticket for any Critical-severity new findings

This closes the loop between build-time policy enforcement and runtime security monitoring.

Checklist: SBOM Policy Maturity

Level 1 — Basic:

  • SBOM generated for every release
  • SBOM stored as release artifact
  • Basic vulnerability scan against SBOM
  • Build fails on critical vulnerabilities

Level 2 — Intermediate:

  • OPA policies for license compliance
  • Exception process with review dates
  • Signed SBOMs with Cosign
  • SBOM generated in both CycloneDX and SPDX formats

Level 3 — Advanced:

  • Policy-as-code in version control
  • Automated policy exception expiry notifications
  • SBOM re-evaluation against new CVE databases (daily)
  • SBOM diff reporting on PRs
  • Supply chain attestations (SLSA Level 2+)
  • Customer-facing SBOM portal

The goal isn't perfection on day one — it's building the automation infrastructure so that supply chain security happens continuously and automatically, without slowing down your development team.

Start now free