Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for DevOps Security & Hardening By James Joyner IV · · 11 min read

From SBOM to VEX: Suppressing Unexploitable CVEs With Evidence, Not Vibes

Use VEX and OpenVEX to mark CVEs not_affected with a real justification, cut scanner noise, attach VEX to images, and catch SBOM drift before you ship.

  • #security
  • #hardening
  • #vex
  • #sbom
  • #supply-chain

The first time I generated a Software Bill of Materials for one of our services and ran a scanner against it, I felt great for about ninety seconds. Then the report finished: 312 CVEs. Forty-one rated critical. By the end of the week I’d manually traced four of those criticals and every single one was unreachable — the vulnerable function lived in a code path we never call, or in a transitive dependency that ships but never loads. An SBOM told me what’s in the box. It told me nothing about what can actually hurt me. That gap is exactly where VEX lives, and it’s the difference between a security program that ships and one that drowns.

An SBOM Alone Is an Alert Fatigue Machine

An SBOM is an inventory. That’s it, and that’s valuable — you can’t defend what you can’t enumerate. But the moment you feed that inventory into Grype, Trivy, or any matcher, every component gets cross-referenced against every published CVE, and the matcher has no idea whether your code reaches the vulnerable bits. A bundled libxml2 with a parsing CVE counts the same whether you parse untrusted XML all day or never call the parser at all.

The result is a wall of red that trains everyone to ignore it. When 90% of your criticals are noise, the 10% that matter get the same treatment: triaged later, snoozed, eventually forgotten. Suppression by gut feeling (“eh, that one’s probably fine”) is worse than no suppression, because it leaves no record of why. VEX — Vulnerability Exploitability eXchange — exists to replace “probably fine” with a signed, auditable statement of fact.

The Four VEX Statuses and Why Justification Is Mandatory

A VEX statement attaches a status to a (product, vulnerability) pair. There are exactly four:

  • not_affected — this CVE does not impact this product. This is the powerful one, and the only status that requires a justification.
  • affected — the product is impacted; users should take action.
  • fixed — a version exists that remediates the CVE.
  • under_investigation — you’ve seen it, you don’t yet know, and you’re saying so out loud.

The whole game is in not_affected, because that’s the status that makes a scanner go quiet. To prevent it from becoming a rubber stamp, the spec demands a machine-readable justification drawn from a fixed vocabulary:

  • component_not_present
  • vulnerable_code_not_present
  • vulnerable_code_not_in_execute_path
  • vulnerable_code_cannot_be_controlled_by_adversary
  • inline_mitigations_already_exist

That enumeration is the contract. “We looked at it” is not a justification; “vulnerable_code_not_in_execute_path” is — and it’s a claim a reviewer can challenge and an auditor can replay later.

Pro Tip: Treat vulnerable_code_not_in_execute_path as the justification that demands the most scrutiny. “Not present” is usually easy to verify from the SBOM itself; “not in the execute path” is a runtime reachability claim that quietly rots the moment someone adds a new call site. Re-verify it on every release.

OpenVEX and CSAF: Two Formats, Same Idea

Two formats dominate. CSAF VEX (OASIS) is the heavyweight, vendor-grade format — rich product trees, great if you publish advisories to a broad customer base. OpenVEX is the lightweight, minimal JSON profile most teams reach for to wire VEX into a CI pipeline. Here’s a complete OpenVEX statement:

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://example.com/vex/2026-checkout-libxml2",
  "author": "Security Team <secops@example.com>",
  "timestamp": "2026-06-17T09:00:00Z",
  "version": 1,
  "statements": [
    {
      "vulnerability": { "name": "CVE-2025-12345" },
      "products": [
        {
          "@id": "pkg:oci/checkout-api@sha256:9a1f...c4",
          "subcomponents": [
            { "@id": "pkg:deb/debian/libxml2@2.9.14" }
          ]
        }
      ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "The service links libxml2 for config parsing only; the affected SAX handler is never invoked on untrusted input."
    }
  ]
}

Note the pkg:oci/...@sha256 purl pinned to a digest, not a tag. A VEX statement is only meaningful against a specific artifact — re-tagging latest must not silently inherit yesterday’s suppressions.

Authoring With vexctl Instead of Hand-Editing JSON

Hand-writing that JSON is fine to understand the shape, but in practice you generate it. vexctl (from the OpenVEX project) builds and merges statements:

vexctl create \
  --product="pkg:oci/checkout-api@sha256:9a1f...c4" \
  --subcomponent="pkg:deb/debian/libxml2@2.9.14" \
  --vuln="CVE-2025-12345" \
  --status="not_affected" \
  --justification="vulnerable_code_not_in_execute_path" \
  --file=checkout-libxml2.vex.json

As you accumulate statements across releases, merge them into a single document the scanner can consume:

vexctl merge \
  checkout-libxml2.vex.json \
  checkout-openssl.vex.json \
  --product="pkg:oci/checkout-api@sha256:9a1f...c4" \
  > checkout-api.vex.json

This is exactly where an AI assistant earns its keep — and exactly where you keep it on a short leash. Treat the model as a fast junior engineer: paste the CVE description and your dependency graph and let a tool like Claude or Cursor draft the impact_statement and propose a justification from the controlled vocabulary. What it must never do is publish. A wrong not_affected doesn’t create noise — it deletes a real finding from every downstream report. The human security owner verifies reachability, signs off, and only then does it ship. And you never hand the model real secrets, internal hostnames, or signing keys to “help” — the SBOM and the CVE text are all it needs.

Attaching VEX to the Image

A VEX document is most useful traveling with the artifact it describes. The common patterns:

  • Alongside in the registry: attach the VEX as an OCI referrer/attestation next to the image so a digest pull can discover it.
  • In-repo: keep *.vex.json in the source tree and ship it as a build output for air-gapped or offline scanning.

A typical attach with cosign (signing is a separate concern, but it’s how the document rides along):

cosign attach attestation \
  --predicate checkout-api.vex.json \
  --type openvex \
  registry.example.com/checkout-api@sha256:9a1f...c4

The point is provenance: when someone scans your image six months from now, the evidence for every suppression is one digest lookup away, not lost in a Slack thread.

Consuming VEX in Grype and Trivy

This is the payoff. Both major scanners accept a VEX document and filter their output against it. With Grype:

# Generate the SBOM
syft registry.example.com/checkout-api@sha256:9a1f...c4 \
  -o spdx-json=checkout-api.sbom.json

# Scan, applying VEX to drop justified not_affected findings
grype sbom:checkout-api.sbom.json \
  --vex checkout-api.vex.json \
  --fail-on critical

Findings marked not_affected with a valid justification move into an “ignored” bucket — visible if you ask for it, out of the failure gate by default. Trivy works the same way:

trivy image \
  --vex checkout-api.vex.json \
  --severity CRITICAL,HIGH \
  registry.example.com/checkout-api@sha256:9a1f...c4

Now your pipeline fails on the CVEs that are genuinely exploitable and stays green on the ones you’ve documented as unreachable. The red wall becomes a short, honest list. If you want a second pair of eyes on the diff that touches these gates, our code review dashboard is built for exactly this kind of policy change, and the security-hardening category collects the rest of the defensive playbook.

Who is allowed to author a VEX statement? This is governance, not tooling, and it’s where programs succeed or fail. A not_affected statement is a security decision with the power to hide a CVE from every downstream consumer. So authority must be explicit:

  • The producer of the component speaks authoritatively about their own code’s reachability. The team that owns checkout-api can assert vulnerable_code_not_in_execute_path for their service.
  • A designated security owner countersigns before publication. Drafting is delegable (to a junior engineer, or to an AI); the sign-off is not.
  • Consumers do not author VEX for upstream code they don’t understand. If you can’t explain the reachability claim, you can’t make it.

Record the author in the author field, keep the statements in version control, and require a human approval on the PR that adds or changes any not_affected. A prompt-driven workflow can standardize how engineers draft the impact_statement, but the merge gate stays human.

Pro Tip: Make the justification vocabulary a required, validated field in your VEX template. If an engineer can type free text instead of picking from the enumeration, “not_affected: it’s fine” will eventually slip through review. Schema-validate in CI.

Detecting SBOM Drift Between Declared and Shipped

Here’s the failure mode that quietly invalidates everything above: your VEX says not_affected for libxml2@2.9.14, but a base-image bump shipped libxml2@2.10.0 with a fresh CVE — and your suppressions, pinned to the old purl, no longer cover it (good) while your declared SBOM no longer matches the shipped artifact (bad). That delta is SBOM drift.

Catch it by regenerating the SBOM from the actual built image and diffing it against the one you committed:

# Regenerate from the artifact you're about to ship
syft registry.example.com/checkout-api@sha256:9a1f...c4 \
  -o spdx-json=shipped.sbom.json

# Diff declared vs shipped; fail the build on any delta
diff <(jq -S '.packages[].versionInfo' declared.sbom.json) \
     <(jq -S '.packages[].versionInfo' shipped.sbom.json) \
  || { echo "SBOM DRIFT DETECTED — re-verify VEX"; exit 1; }

When drift fires, the action is not “regenerate the SBOM and move on” — it’s “re-verify every VEX statement against the new component set.” A drift gate that auto-accepts the new inventory is just laundering the problem. Wire the alert into wherever your team already responds; the incident-response dashboard is a reasonable home for “the thing we shipped doesn’t match what we declared.”

Conclusion

An SBOM tells you what you ship. VEX tells you what’s actually exploitable, with a justification a reviewer can challenge and an auditor can replay. The combination turns a 312-CVE scream into a short, honest list — but only if every not_affected rests on evidence, an AI draft is verified by a human security owner before it’s published, and a drift gate forces you to re-check those suppressions whenever the bill of materials changes underneath them. Suppress with evidence, never with vibes.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.