Skip to main content

SBOM and VEX

An SBOM lists what you ship. A VEX says what actually affects you.

Both are generated on the machine that runs the scan, from the versions your lockfiles resolved to rather than the ranges your manifests declare. The inventory comes out as CycloneDX or SPDX, the cryptographic inventory as a CBOM, and the exploitability document as OpenVEX or CycloneDX VEX. The not_affected statements are backed by call-graph reachability instead of somebody working through a spreadsheet. Your component list, your findings and your source stay on your disk.

  • CycloneDX 1.6 and 1.7
  • SPDX 2.3 and 3.0.1
  • OpenVEX 0.2.0
  • Generated offline

01 / What an SBOM is

An inventory of what you ship, in a format other tools read

A Software Bill of Materials is a list, not a verdict. It says what is in the product and says nothing about whether any of it is a problem.

For every component that ends up in what you ship, an SBOM records a name, the version your lockfile actually resolved to, an identifier other tools agree on (a package URL, or purl), and the licence where one could be resolved. That is the whole idea. The value is in the fact that a second tool, written by someone else, can read it without a conversation.

It is worth generating for one practical reason. When an advisory lands on a Friday afternoon, the question is not academic: do we ship this component, at this version, anywhere. A current SBOM turns that into a lookup. Without one it is an afternoon of grepping lockfiles across repositories, and the answer tends to arrive after the decision had to be made. The second reason is that somebody asks. Procurement questionnaires, vendor security reviews and a growing number of regulations expect a machine-readable component list in a standard format, and expect it to match the artifact you actually shipped.

Two limits are worth knowing before you hand one to a reviewer. A dependency declared in a manifest format the scanner does not parse is absent from the document, and absence is reported as unread rather than as clean. And the dependency graph asserts only the edge it can prove, which is that the root application depends on every resolved component. The tree between components is left unstated rather than invented, because a graph that guesses at its edges is worse than one that omits them.

vulkro sbom . --format cyclonedxCycloneDX 1.6
{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "serialNumber": "urn:uuid:8f2a...",
  "components": [
    {
      "type": "library",
      "bom-ref": "comp-0",
      "name": "lodash",
      "version": "4.17.20",
      "purl": "pkg:npm/[email protected]",
      "licenses": [ { "license": { "id": "MIT" } } ],
      "scope": "required"
    }
  ],
  "dependencies": [
    { "ref": "root-component", "dependsOn": [ "comp-0", "comp-1", "..." ] }
  ]
}
One component from a CycloneDX document: the resolved version, the purl another tool matches on, the licence where one resolved, and whether it is a build-only dependency.

A licence that could not be resolved emits the SPDX NOASSERTION sentinel rather than a guess, which downstream tooling reads as unknown rather than as permissive.

02 / The formats

CycloneDX, SPDX, and a CBOM for cryptography

Which format you need is usually decided by whoever asked for it. All of these come out of the same scan data, so the choice costs nothing.

The SBOM, CBOM and VEX documents Vulkro emits, with the spec version each targets and what each document carries.
ArtifactSpec versionWhat it carries
CycloneDX SBOM1.6 default, 1.7 opt-inOne library component per resolved package: name, version, purl, licence where one resolved, and whether it is build-only. Dependency CVEs matched against the local bundle ride along in the same document.
SPDX SBOM2.3 default, 3.0.1 opt-inThe same inventory in the SPDX shape, for a reviewer whose pipeline reads SPDX. 3.0.1 is the JSON-LD serialisation, a different document shape rather than a version bump, so it is a separate emitter.
CBOMCycloneDX 1.6, 1.7 opt-inA cryptographic inventory: one cryptographic-asset component per weak algorithm found in your own code (MD5, SHA-1, ECB, RC4, DES, static IV, insecure random), each with the file and line it was seen at. Asked for as a separate artifact from the library SBOM in post-quantum and federal reviews.
OpenVEX0.2.0One exploitability statement per product and vulnerability: affected, not_affected, or under_investigation, with the justification and impact statement attached.
CycloneDX VEX1.6The same verdicts inside a CycloneDX vulnerabilities[].analysis document, so the VEX lines up with a CycloneDX SBOM rather than sitting beside it in a second vocabulary.
Source: the spec-version catalogue baked into the binary and printed by `vulkro formats`. The same catalogue generates the output-formats page in the docs, and tests/format_docs_drift.rs fails the build if the two disagree, so a version bump cannot drift from what the emitter writes. Nothing is fetched to produce the catalogue or any document in it.

These sit inside a catalogue of 24 output formats in total. The rest are report, CI and SIEM formats: SARIF for a code-scanning tab, JUnit for a test panel, NDJSON for a log pipeline, and the pull-request comment formats. Run vulkro formats to print the catalogue with the exact spec version each emitter targets. The 1.6 and 2.3 emitters stay the defaults so an existing pipeline keeps its bytes; 1.7 and 3.0.1 are additive opt-ins.

03 / VEX

The document that says which advisories actually affect you

An SBOM tells a reviewer that you ship a component. VEX tells them whether the advisory filed against it reaches your code, which most of the time it does not.

Somebody has to answer that question, and without a document the answer gets re-derived by every team that reads your SBOM. VEX (Vulnerability Exploitability eXchange) is the answer written down. Per product and vulnerability it carries one of three statements: affected, not_affected, or under_investigation. Vulkro emits it as OpenVEX 0.2.0 or as a CycloneDX analysis document, both from the same verdicts, so it lines up with whichever SBOM you handed over.

A hand-written VEX is a spreadsheet of judgements, and it starts ageing the moment someone refactors. The verdicts here come from the reachability pass the scanner already runs. A not_affected statement is emitted only where the forward closure from your entry points contains no call into the vulnerable symbol, and the document carries that reason: the justification is vulnerable_code_not_in_execute_path, and the impact statement names the package the claim was made about. An affected statement cites the call chain wherever the call graph could reconstruct one, and labels it as static call-graph reachability rather than as a confirmed exploit, because that is what it is.

The conservative half matters more than the confident half. Absence of a reachability signal maps to under_investigation, never to not_affected. Where the reachable and unreachable signals genuinely conflict, affected wins: an over-claimed not_affected forwarded to an auditor is the dangerous direction, while an over-claimed affected only prompts an upgrade.

VULKRO_SCA_REACHABLE=1 vulkro scan .offline bundle
  • CRITorg.apache.commons:commons-text@1.9MavenCVE-2022-42889reachable

    pom.xmlStringSubstitutor.replace called from ReportController.render at src/main/java/com/acme/report/ReportController.java:88

  • HIGHlodash@4.17.20npmCVE-2021-23337GHSA-35jh-r3h4-6jhmreachable

    package-lock.jsontemplate() called from buildInvoice at routes/invoice.ts:47, two hops from the POST /invoices route

  • MEDurllib3@1.26.4PyPICVE-2021-33503unknown

    poetry.lockadvisory declares no vulnerable symbols, so no reachability claim is made and the severity is left as matched

  • INFOgithub.com/gin-gonic/gin@1.7.7GoCVE-2023-29401unreachable

    go.sumContext.FileAttachment is not called from the forward closure of any route, main, or plugin hookdowngraded from medium

  • INFOtime@0.1.44crates.ioCVE-2020-26235unreachable

    Cargo.lockpulled in transitively by chrono; no project function in the forward closure calls the affected symboldowngraded from high

Unreachable findings are downgraded, not dropped. Severity falls to Info, the row keeps its call-graph reason, and it stays in the report and in the JSON. A call site missed through dynamic dispatch shows up as down-ranked rather than as absent.

Manifests and lockfiles read: npm, PyPI, Go modules, crates.io and Maven. A dependency declared anywhere else is absent from the SBOM and unmatched against the bundle, so it is reported as unread rather than as clean. The default published CVE bundle currently carries npm and PyPI advisories, and the wider bundle adds Go modules, crates.io and Maven: check what your bundle covers before reading an empty result as a clean one.

The verdicts a VEX document is built from: reachable rows keep their severity and rank first, unreachable rows are downgraded and stay visible, and a package whose advisory names no symbols gets no claim in either direction.

04 / Reachability

Unreachable is a downgrade, never a silent deletion

The pass that produces the verdicts is opt-in, and what it does to a finding it cannot vouch for is the part worth reading twice.

Set VULKRO_SCA_REACHABLE=1 and dependency findings gain a forward-closure verdict. Leave it off and every finding keeps the severity the version match gave it. It is opt-in on purpose: a gate that quietly rewrote severities would move a baseline under a team that never asked for it.

A dependency CVE is marked reachable only when a project function inside the forward closure from a real entry point calls a symbol the advisory names. That is a stronger claim than the symbol appearing somewhere in the tree: a vulnerable call in a file no entry point reaches does not count. The symbols come from the local CVE bundle, with a small built-in catalogue as the fallback so a fresh install still gets a signal with the network off.

  • reachable

    A project function inside the forward closure from a real entry point (an HTTP route, main, a plugin hook) calls a symbol the advisory names. Severity is kept, the finding ranks first, and the VEX statement is affected with an action statement, plus the call chain cited as static reachability evidence wherever the call graph could reconstruct one.

  • unreachable

    The advisory names its vulnerable symbols and nothing in the forward closure calls one. Severity falls to Info, the row keeps its call-graph reason, and it stays in the report and in the JSON. The VEX statement is not_affected, justified as vulnerable_code_not_in_execute_path.

  • unknown

    The advisory declares no vulnerable symbols, which is the common case. There is nothing to look for in the call graph, so no claim is made in either direction, the severity is left exactly as the version match set it, and the VEX statement is under_investigation.

Dropping the unreachable rows is available and it is a different thing. --reachable-only removes dependency findings whose vulnerable symbol is not called, and it removes them because you asked for a quieter report. The default posture is a down-rank you can still see, because a call site missed through dynamic dispatch, reflection or an import alias should look down-ranked rather than absent.

VULKRO_SCA_REACHABLE=1 vulkro sbom . --format openvexOpenVEX 0.2.0
{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "author": "vulkro",
  "role": "tool",
  "version": 1,
  "statements": [
    {
      "vulnerability": { "name": "CVE-2023-29401" },
      "products": [ { "@id": "pkg:golang/github.com/gin-gonic/[email protected]" } ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "Reachability analysis found no call path from the project into the vulnerable code of gin."
    },
    {
      "vulnerability": { "name": "CVE-2021-23337" },
      "products": [ { "@id": "pkg:npm/[email protected]" } ],
      "status": "affected",
      "action_statement": "Update lodash to a fixed version; see the advisory.",
      "status_notes": "Reachability evidence for lodash: call chain: POST /invoices (routes/invoice.ts:12) -> buildInvoice (routes/invoice.ts:47) (static call-graph reachability, used to rank reachable findings first)."
    }
  ]
}
Two statements from the emitted document: one not_affected carrying its justification, one affected carrying the call chain it was derived from.

05 / The evidence pack

One directory a reviewer can open, diff and archive

The SBOM and the VEX are usually asked for as part of a larger request. The pack is that request answered as files rather than as a dashboard someone has to be given access to.

vulkro compliance-pack writes a directory that maps findings onto the control identifiers of a framework: a manifest, a summary, a flat finding-to-control CSV, one JSON file per control, and a control-by-control HTML table for the deeper frameworks. vulkro cra-bundle runs that same pack and staples it into one zip alongside the CycloneDX and SPDX SBOMs, the OpenVEX document and a self-contained readiness one-pager.

Every file is generated locally and each control file cites the findings and endpoints it was built from, so a reviewer can follow a claim back to a file and a line rather than taking a summary on trust. What the bundle is not is stated on its own front page: readiness evidence, not a conformance attestation.

vulkro cra-bundle . --framework soc2-fullexit 1, findings present
  • cra-readiness.zip/built on this machine
    • index.htmlreadiness one-pager
    • compliance/soc2-full, 61 controls
      • manifest.jsonframework, scan id, control summary
      • summary.mdevery control with its status
      • findings.csvfinding to control mapping, flat
      • soc2-full.htmlper-control evidence table
      • controls/one file per control
        • CC6.1.jsonPass
        • CC6.6.jsonPartial, 2 findings
        • CC7.2.jsonFail, 1 finding
        • CC8.1.jsonPass
        • P4.1.jsonPass
        • ...56 more control files
      • README.mdwhat each file is, and what it is not
    • sbom/
      • cyclonedx.jsonCycloneDX 1.6
      • spdx.jsonSPDX 2.3
    • vex/
      • openvex.jsonOpenVEX 0.2.0, per CVE

$ vulkro compliance-pack . --framework soc2-full --output ./compliance/

vulkro cra-bundle runs the same pack and writes it into one zip with the SBOM and VEX documents beside it. Every file is generated locally: nothing is uploaded, and each control file cites the findings and endpoints it was built from.

The bundle as it lands on disk: the SBOM pair, the VEX document, and the per-control evidence beside them.

If the request that brought you here mentioned the EU Cyber Resilience Act, the CRA readiness page covers what that regulation asks for and which parts of it a scanner can and cannot produce.

06 / Generating them

The commands, in the order you would run them

Produce the inventory once, keep matching it against the bundle as advisories land, and staple the whole set together when somebody asks for the paperwork.

# the inventory, in whichever shape was asked for
$ vulkro sbom . --format cyclonedx > sbom.cdx.json
$ vulkro sbom . --format spdx > sbom.spdx.json
$ vulkro sbom . --format cbom > cbom.json

# the VEX. the reachability gate is opt-in, so ask for it
$ VULKRO_SCA_REACHABLE=1 vulkro sbom . --format openvex > vex.json

# re-match an SBOM you already have, whenever the bundle updates
$ vulkro match-cve --sbom sbom.cdx.json --format table
  [HIGH]  CVE-2021-23337  lodash 4.17.20
       package-lock.json

# SBOM + VEX + evidence pack, stapled into one zip
$ vulkro cra-bundle . --framework iso27001 -o cra-readiness.zip
Every command here reads your disk and the local CVE bundle. None of them opens a socket to send your component list anywhere.

Exit codes are the same contract as the rest of the CLI: 0 for a clean run, 1 when the run completed and findings were reported, 2 for an error. A bundle written with findings inside it exits 1, which is a state, not a failure.