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.

Four Outcomes, and Why Unknown Is Not a Pass Components with an allowlisted licence pass silently. Components with a review-required licence pass only with a recorded, dated decision. Components with a denied licence fail the build. Components with a missing or unrecognised licence also fail, because absence of metadata is most common exactly where scrutiny is most warranted. Allowlisted — passes silently the permissive set your legal team wrote down, encoded once and applied everywhere Review required — passes only with a recorded decision weak copyleft and source-available terms, approved per component with a date and an owner Denied — fails the build terms the organisation has decided it will not ship, whatever the technical merits of the package Unknown or missing — also fails, deliberately

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.

Two Ways to Match, Two Very Different Error Rates Name matching produces false matches, where a package sharing a name with an unrelated project in another ecosystem is flagged, and missed matches, where a namespaced package is not recognised at all. Package identifiers name ecosystem, namespace, package and version unambiguously, so both error classes disappear. Matching by name false match: an unrelated project sharing a name in a different ecosystem missed match: a namespaced package the matcher does not recognise Both errors train the team to ignore output. Matching by package identifier ecosystem, namespace, package, version — all four, unambiguously an entry without an identifier is one the gate cannot reason about at all Which is why generation must emit them.

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.

From Matched Advisories to a Small Blocking Set All matched advisories enter at the top. Statements marked fixed or not affected, with a justification and an unexpired date, resolve their findings and are logged. Under-investigation statements keep their findings visible but do not resolve them. Expired statements resolve nothing. What remains at critical or high severity blocks the build. All matched advisories for this inventory matched by package identifier, not by name similarity Minus: fixed and not-affected, with a justification and an unexpired date logged in the build output so the decision is visible, not silent Under investigation stays visible it is a commitment with a date, not a resolution Residue at high or critical → build fails

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.