Skip to main content

SBOM and VEX explained: the inventory and the verdict

· 9 min read
Vulkro
Security research

Ask a supplier for an SBOM and VEX and you get two files that answer two different questions. The SBOM (software bill of materials) answers "what is in this product?". The VEX (Vulnerability Exploitability eXchange) answers "of the advisories that match those components, which ones actually affect this product?". The first is an inventory. The second is a verdict, and a verdict is only as good as the evidence behind it.

This post covers what each document records, what the VEX statuses and not_affected justifications mean in the CISA and OpenVEX vocabulary, and why a not_affected claim needs reachability evidence before anyone should trust it. It ends with the commands Vulkro Core uses to produce both on your own machine.

What is an SBOM?​

An SBOM is a machine-readable list of the components a piece of software is built from: each package, its version, a unique identifier and the relationships between them. CISA describes it as a "nested inventory" of the ingredients that make up software components, on its SBOM resource page.

The baseline most tooling still checks against is the NTIA's 2021 minimum elements: supplier name, component name, version, other unique identifiers, dependency relationships, author of the SBOM data and a timestamp. In July 2026 CISA and partner agencies published the 2026 Minimum Elements for an SBOM, which updates that baseline and adds fields such as component hashes, licences and the name of the generating tool. Both are voluntary guidance, but procurement teams increasingly copy them into contracts.

Two formats dominate:

  • CycloneDX, an OWASP project (specification). Components, dependencies and, in the same document, known vulnerabilities and their analysis.
  • SPDX, a Linux Foundation project published as ISO/IEC 5962 (spdx.dev). Strong on licensing, widely read by legal and procurement pipelines.

Either is fine. Pick the one the person asking can read. The important property is that the versions are the ones your lockfile resolved, not the ranges your manifest declared. An SBOM that says ^4.17.0 cannot answer whether you ship 4.17.20.

What is VEX, and why is an SBOM not enough?​

Hand someone an SBOM and the first thing they do is match it against vulnerability data. For a typical web application that produces dozens of matches. Most of them do not matter, because the vulnerable function sits in a part of the library the product never calls. The SBOM cannot say so. It lists what is present, not what is used.

VEX is the companion document that says so. CISA's Minimum Requirements for VEX (April 2023) defines it as a statement, per product and per vulnerability, of the product's status. OpenVEX is a compact JSON implementation of those requirements, and CycloneDX carries the same idea in its vulnerabilities[].analysis block.

What do the VEX statuses mean?​

There are four statuses, and OpenVEX spells them like this:

StatusMeaning
not_affectedThe product contains the component but is not affected by this vulnerability. Needs a justification.
affectedThe product is affected. OpenVEX expects an action statement saying what to do.
fixedThese product versions contain a fix.
under_investigationIt is not yet known whether the product is affected.

under_investigation is the honest default. It is not a failure to fill in the form. It says the supplier has not reached a conclusion, which is far more useful than a confident guess.

What are the not_affected justifications?​

A bare not_affected is an assertion with nothing behind it, so both CISA and OpenVEX attach a reason. CISA's VEX Status Justification document (June 2022) defines five, and OpenVEX uses the same labels:

  • component_not_present: the vulnerable component is not in the product at all.
  • vulnerable_code_not_present: the component is there, but the vulnerable code is not (for example, it was compiled out).
  • vulnerable_code_not_in_execute_path: the vulnerable code is present but the product never runs it.
  • vulnerable_code_cannot_be_controlled_by_adversary: it runs, but an attacker cannot control its input.
  • inline_mitigations_already_exist: built-in protections that the user cannot turn off prevent exploitation.

The third one is the most common case and the easiest to get wrong. CISA's definition carries a warning worth reading twice: a single path of execution "should not be assumed", because an attacker may divert it. In other words, "we never call it" has to mean "nothing reachable calls it", not "the main flow does not call it".

Why does reachability evidence make a VEX credible?​

A VEX is usually written by hand. Someone reads the advisory, looks at the code, decides the product is fine and types not_affected. The person receiving it has no way to check the reasoning. If the claim is wrong, a real vulnerability has been suppressed with a signature on it.

The fix is to derive the verdict from the code, and to refuse to claim more than the code shows. That is how Vulkro writes its VEX:

  • affected when a call chain runs from one of your entry points (a route, a handler, an exported tool) to the function the advisory names. The statement cites that chain in status_notes.
  • not_affected, with the justification vulnerable_code_not_in_execute_path, only when the advisory names its vulnerable functions and no entry point reaches one.
  • under_investigation in every other case, including the common one where the advisory does not say which functions are vulnerable. No signal is never read as safe.

Here is what that looks like in OpenVEX:

{
"@context": "https://openvex.dev/ns/v0.2.0",
"statements": [
{
"vulnerability": { "name": "CVE-2023-29401" },
"products": [ { "@id": "pkg:golang/github.com/gin-gonic/gin@1.7.7" } ],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
},
{
"vulnerability": { "name": "CVE-2021-23337" },
"products": [ { "@id": "pkg:npm/lodash@4.17.20" } ],
"status": "affected",
"status_notes": "Reachability evidence for lodash: call chain: POST /invoices (routes/invoice.ts:12) -> buildInvoice (routes/invoice.ts:47) ..."
}
]
}

The affected row is the one a reviewer can act on: it names the route and the function. The not_affected row is one a reviewer can challenge, because it rests on a named function and a call graph rather than on someone's reading of an advisory.

Reachability is static analysis and it has limits. Reflection, dynamic dispatch and aliasing can hide a call. We covered how Vulkro handles this per advisory in imported is not reachable, and how the verdict feeds into ranking in ranking dependency alerts by real risk. The short version: treat affected as "look here first", and treat not_affected as a claim with stated grounds, not a guarantee.

How do you generate an SBOM and VEX?​

With Vulkro Core, both come from the same scan, on your machine, from the lockfiles on disk:

# the inventory
vulkro sbom . --format cyclonedx > sbom.cdx.json # CycloneDX 1.6
vulkro sbom . --format spdx > sbom.spdx.json # SPDX 2.3

# the verdicts
vulkro sbom . --format openvex > vex.openvex.json # OpenVEX 0.2.0
vulkro sbom . --format cyclonedx-vex > vex.cdx.json # CycloneDX 1.6 VEX

# check an SBOM you already have (yours or a supplier's)
vulkro sbom --check supplier-sbom.json

# re-match an existing SBOM as new advisories land
vulkro match-cve --sbom sbom.cdx.json --format table

--format also accepts cyclonedx-1.7 and spdx3 (SPDX 3.0.1) for pipelines that want the newer specifications, plus cbom for weak cryptography and aibom for AI components. --check reads a CycloneDX or SPDX file and names each missing NTIA minimum element with a fix, exiting 1 when a required field is missing, which makes it a reasonable gate on SBOMs your suppliers send you. Add --offline to any of these to guarantee no network call is made.

A licence the scanner could not resolve is written as unknown, never guessed, and a dependency declared in a format Vulkro does not read is reported as unread rather than clean.

A checklist before you send one​

  • The SBOM lists resolved versions from a lockfile, not declared ranges.
  • Every component has a package URL (purl) or another unique identifier.
  • The SBOM was generated for the exact release you are shipping, and is stored with it.
  • Every not_affected statement carries a justification you could defend in a meeting.
  • Anything you have not analysed is under_investigation, not silently omitted.
  • You have a plan to re-match the SBOM when new advisories land, not only at release.

If the request comes from a regulator rather than a customer, the context changes but the files do not. Our post on the EU Cyber Resilience Act covers where SBOMs and VEX fit in CRA reporting.

Where to see it​

The SBOM and VEX page shows the formats side by side, and the supply chain use case covers the rest of the dependency picture: malicious packages and leaked keys. The vulkro sbom reference lists every flag, and the dependencies and CVEs reference explains how reachability is computed. To run it against your own repository, start here.

Frequently asked questions​

What is the difference between an SBOM and a VEX?​

An SBOM lists the components in a product and their versions. A VEX states, for each known vulnerability in those components, whether the product is affected. The SBOM is the inventory; the VEX is the verdict on each advisory that matches it.

Is VEX part of the SBOM or a separate file?​

Either. CycloneDX can carry vulnerability analysis inside the SBOM or in a separate VEX document, while OpenVEX is always a standalone file that refers to products by identifier. A separate file is easier to update as new advisories arrive without reissuing the inventory.

When can a supplier claim not_affected?​

Only with a justification: the component is absent, the vulnerable code is absent, it is not in the execute path, an adversary cannot control it, or inline mitigations exist. CISA's guidance and the OpenVEX specification both require that reason. A claim of "not in the execute path" should rest on evidence that nothing reachable calls the vulnerable code.

Should I trust a VEX I receive from a vendor?​

Treat it as the vendor's stated position, and ask what it is based on. A not_affected statement that names the vulnerable function and the analysis behind it is far easier to rely on than one with no notes. Where the stakes are high, re-match the SBOM yourself and compare.