License and VEX Gates From an SBOM
An inventory that nobody queries is a compliance artefact. An inventory wired into a gate is a control. This guide covers the two decisions worth automating from a bill of materials: whether every component carries a licence the organisation accepts, and whether each matched advisory actually affects the product — expressed not as an opaque suppression but as a machine-readable exploitability statement with a justification.
It is part of the Software Bill of Materials (SBOM) guide within Supply Chain & Dependency Security, and it assumes the inventory itself is produced as described in generating a CycloneDX SBOM in GitHub Actions.
Prerequisites
- A complete inventory generated from the resolved dependency tree, with package identifiers on every entry
- A written licence policy from whoever owns that decision in your organisation
- An advisory data source your scanner can consult offline or with a cached feed
- A place to store VEX statements in version control, reviewed like code
Expected Outcomes
- Builds failing on any component whose licence is outside the allowlist, including unknown values
- Advisories matched by package identifier rather than by fuzzy name comparison
- Exploitability decisions recorded as statements with justifications and expiry dates
- The gate failing only on findings that no current statement resolves
Step 1: Enforce the Licence Allowlist
# tools/license_gate.py — fail on anything outside the written policy.
import json, sys
ALLOWED = {
"MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC",
"Python-2.0", "Unlicense", "CC0-1.0",
}
REVIEW_REQUIRED = {"MPL-2.0", "LGPL-3.0-only"} # allowed with a recorded decision
DENIED = {"AGPL-3.0-only", "SSPL-1.0", "BUSL-1.1"}
sbom = json.load(open("sbom.json"))
violations, unknowns, review = [], [], []
for c in sbom.get("components", []):
ident = c.get("purl") or f'{c.get("name")}@{c.get("version")}'
licences = {l.get("license", {}).get("id") or l.get("license", {}).get("name")
for l in c.get("licenses", [])} - {None}
if not licences:
unknowns.append(ident) # unknown is a failure, not a pass
elif licences & DENIED:
violations.append((ident, sorted(licences)))
elif licences & REVIEW_REQUIRED and ident not in approved_exceptions():
review.append((ident, sorted(licences)))
elif not (licences & ALLOWED):
unknowns.append(ident)
for ident, lic in violations: print(f"DENIED {ident} {lic}")
for ident, lic in review: print(f"REVIEW {ident} {lic}")
for ident in unknowns: print(f"UNKNOWN {ident}")
sys.exit(1 if violations or review or unknowns else 0)
Treating an unknown licence as a failure is the decision that makes this gate worth having. Missing metadata is common precisely in the packages least likely to have been reviewed, and a gate that passes on absence of evidence teaches everyone that the field does not matter.
Step 2: Match Advisories by Identifier, Not by Name
# Correlate the inventory against advisory data using package URLs.
grype sbom:sbom.json --output json --file findings.json \
--add-cpes-if-none=false # avoid the fuzzy CPE guesses that create false matches
Name-based matching produces both kinds of error, and both are corrosive. False matches train the team to dismiss findings; missed matches leave real exposure invisible. Package identifiers name the ecosystem, namespace, package and version unambiguously, which is why the inventory has to carry them for every component — an entry without one is an entry the gate cannot reason about.
Step 3: Record Exploitability as VEX Statements
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://example-org/vex/web/2026-07-31",
"author": "[email protected]",
"timestamp": "2026-07-31T09:00:00Z",
"statements": [
{
"vulnerability": { "name": "CVE-2026-11111" },
"products": [{ "@id": "pkg:oci/web@sha256:9f2c…" }],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "The affected parser is only reachable through the CLI entry point, which is not built into the container image.",
"timestamp": "2026-07-31T09:00:00Z"
},
{
"vulnerability": { "name": "CVE-2026-22222" },
"products": [{ "@id": "pkg:oci/web@sha256:9f2c…" }],
"status": "under_investigation",
"impact_statement": "Reachability analysis in progress; re-evaluate by 2026-08-14.",
"timestamp": "2026-07-31T09:00:00Z"
}
]
}
The justification field is the part that makes a statement reviewable months later. “Not affected because the vulnerable code is not in the execute path” can be checked against the code; “not affected” alone cannot, and after two staff changes it is indistinguishable from a suppression somebody added to make a build green.
Step 4: Gate on What Remains
# tools/vuln_gate.py — apply VEX, then fail on the residue.
import json, sys, datetime
findings = json.load(open("findings.json"))["matches"]
vex = json.load(open("vex/web.openvex.json"))["statements"]
now = datetime.datetime.now(datetime.UTC)
resolved = {}
for s in vex:
expires = s.get("expires")
if expires and datetime.datetime.fromisoformat(expires) < now:
continue # expired statements do not resolve anything
if s["status"] in ("not_affected", "fixed"):
resolved[s["vulnerability"]["name"]] = s
blocking = []
for f in findings:
cve = f["vulnerability"]["id"]
severity = f["vulnerability"]["severity"]
if cve in resolved:
print(f"resolved {cve} ({resolved[cve]['justification']})")
continue
if severity in ("Critical", "High"):
blocking.append((cve, f["artifact"]["purl"], severity))
for cve, purl, sev in blocking:
print(f"BLOCKING {sev:8} {cve} {purl}")
sys.exit(1 if blocking else 0)
Printing the resolved findings alongside the blocking ones keeps the build log honest: a reviewer can see what was waved through and on what grounds, which is precisely what an auditor will ask about six months later.
Verification
# 1. A denied licence fails the build.
jq '.components += [{"name":"demo","version":"1.0.0","purl":"pkg:npm/[email protected]",
"licenses":[{"license":{"id":"AGPL-3.0-only"}}]}]' sbom.json > /tmp/sbom-agpl.json
python3 tools/license_gate.py /tmp/sbom-agpl.json; echo "exit=$?" # expect 1
# 2. A component with no licence field also fails.
jq '.components += [{"name":"nolic","version":"0.1.0","purl":"pkg:npm/[email protected]"}]' \
sbom.json > /tmp/sbom-nolic.json
python3 tools/license_gate.py /tmp/sbom-nolic.json; echo "exit=$?" # expect 1
# 3. A not-affected statement resolves its finding, and the log says why.
python3 tools/vuln_gate.py | grep '^resolved'
# 4. An expired statement stops resolving.
jq '.statements[0].expires = "2020-01-01T00:00:00Z"' vex/web.openvex.json > /tmp/vex-old.json
VEX=/tmp/vex-old.json python3 tools/vuln_gate.py; echo "exit=$?" # expect 1
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Findings appear for components you do not ship | Fuzzy identifier matching enabled | Disable generated identifiers and match on package URLs only |
| Licence gate passes suspicious packages | Unknown treated as allowed | Fail on unknown and missing licence metadata |
| VEX statements never apply | Product identifier in the statement does not match the artifact | Use the image or package digest as the product identifier, generated by the pipeline |
| The same advisory is re-triaged every sprint | Decisions live in chat rather than in version control | Store statements in the repository and review them as code |
| Gate blocks on a dev-only dependency | Inventory does not distinguish scope | Emit scope in the inventory and apply severity thresholds per scope |
| Expired statements silently keep working | Expiry not checked | Compare the expiry against the current time and ignore stale statements |
Common Implementation Mistakes
Frequently Asked Questions
Is VEX just a nicer word for an exception list?
No, and the difference is the whole point. An exception says “ignore this finding”. A VEX statement says why it is not exploitable in this specific product, using a defined justification — the vulnerable code is not present, or is present but never executed, or is unreachable given the configuration. That is auditable, re-checkable when the code changes, and communicable to a customer asking about the same advisory. An exception without a justification is a suppression nobody can evaluate later.
What licences should be on the allowlist?
That is a legal decision rather than an engineering one; the engineering job is to make whatever legal decides enforceable and consistent. Get the list in writing, encode it once, and make sure the gate fails on unknown and missing values as well as on denied ones. The unknown case is where the risk actually hides, because missing metadata is most common in exactly the packages that have had the least scrutiny.
How often should VEX statements be revisited?
Whenever the code that justified them changes, and on a fixed schedule otherwise. A statement asserting that a vulnerable function is never called stops being true the moment someone calls it. Give every statement an expiry, re-derive reachability during the periodic review, and treat an expired statement as a failing finding — otherwise a judgement made once quietly protects a build forever.
Related
- Software Bill of Materials (SBOM) — the parent guide covering generation, storage and use
- Generating a CycloneDX SBOM in GitHub Actions — producing the inventory these gates read
- Dependency Scanning & CI Security Gates — the scanner-driven gate this complements
- Verifying Container Image Signatures With Cosign — binding the attested inventory to the artifact that ships