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
Syft (Recommended — Multi-Ecosystem)
# 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.jsonTrivy (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.jsonMaven 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"
fiCI/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.keyContinuous 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 findingsThis 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.