How do I use VEX with sbomify?

TL;DR

VEX is the companion to your SBOM that says "this CVE is in our dependency tree but it does not actually affect our product, here is why." sbomify stores VEX documents per component the same way it stores SBOMs, so consumers see both side-by-side in the Trust Center.

Walkthrough

The problem VEX solves

Run any modern vulnerability scanner against a real-world SBOM and you will get a list. A long list. Most of those CVEs do not actually affect your product:

  • The vulnerable function is in a code path your application never calls.
  • Your build configuration disables the vulnerable feature.
  • You have already applied a backport patch but the version string still reports the original.
  • The CVE applies to a different operating system or runtime than the one you ship.

Telling that signal-from-noise story by hand - in spreadsheets, GitHub issues, ticket comments - does not scale. Every customer who downloads your SBOM has to re-derive the same conclusions, and every conclusion is just one engineer’s opinion attached to no machine-readable artifact.

VEX (Vulnerability Exploitability eXchange) is the standardized fix. It is a separate document that says, for each known vulnerability in your dependency tree, whether the vulnerability actually affects your product and why. Consumers feed your SBOM and your VEX into the same scanner together, and the scanner produces a triaged finding list instead of a raw one.

What a VEX document looks like

VEX is most commonly produced in CycloneDX VEX format (a CycloneDX BOM with vulnerabilities[] populated) or CSAF VEX (an OASIS standard JSON document). sbomify stores CycloneDX VEX today; CSAF can be uploaded as a regulatory document via the Document feature.

A minimal CycloneDX VEX statement looks like this:

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "vulnerabilities": [
    {
      "id": "CVE-2024-12345",
      "source": { "name": "NVD" },
      "ratings": [{ "severity": "high" }],
      "affects": [{ "ref": "pkg:pypi/[email protected]" }],
      "analysis": {
        "state": "not_affected",
        "justification": "code_not_reachable",
        "detail": "We use requests only for outbound HTTPS to a fixed allowlist of internal hosts. The vulnerable XML parser path is never exercised."
      }
    }
  ]
}

The analysis.state field is the heart of VEX. CycloneDX defines five states:

StateWhen to use
not_affectedVulnerability is present in the dependency tree but not exploitable in your product
affectedVulnerability is present and your product is exposed - patch is in flight
exploitableActive exploitation is possible right now - alert your customers
under_investigationYou are still triaging - publish to acknowledge but do not commit yet
fixedVulnerability was present in an earlier release; the version your customer is using has the patch

When you mark a finding not_affected, you must also provide a justification from the standard set: code_not_present, code_not_reachable, requires_configuration, requires_dependency, requires_environment, protected_by_compiler, protected_at_runtime, protected_at_perimeter, protected_by_mitigating_control. Free-form prose goes in detail.

This structure means a VEX statement is auditable: a regulator or a security team can ask “why did you mark this not_affected” and the JSON has the answer.

VEX and the EU Cyber Resilience Act

BSI TR-03183-2 - the technical specification the CRA points at - explicitly forbids embedding vulnerability data inside the SBOM itself (§3.1, §8.1.14). Vulnerability handling is the job of VEX or CSAF, not of the SBOM.

In practice this means a CRA-compliant manufacturer publishes:

  1. An SBOM describing the product’s components (no vulnerability fields).
  2. A VEX document (or CSAF) describing the current exploitability status of the CVEs that affect those components.
  3. A VDR (Vulnerability Disclosure Report) summarizing the manufacturer’s stance over time.

sbomify’s CRA Compliance plugin checks for the SBOM/VEX split and flags SBOMs that smuggle vulnerability data inline as non-compliant.

Uploading VEX to sbomify

sbomify treats a VEX document as a separate artifact attached to the same component as its SBOM. There is no separate “VEX component” - the relationship is artifact-to-component, the same as SBOMs.

Via the API

Use the same artifact endpoint as a regular SBOM upload, with bom_type=vex:

curl -X POST "https://app.sbomify.com/api/v1/sboms/artifact/cyclonedx/<component_id>?bom_type=vex" \
  -H "Authorization: Bearer ${SBOMIFY_TOKEN}" \
  -H "Content-Type: application/json" \
  --data-binary "@my-product.vex.json"

A successful upload returns the new artifact’s id. The component detail page now lists the VEX alongside the SBOM, and the Trust Center exposes it under the same component.

VEX uploads are CycloneDX-only today - the unified BOM-as-VEX model is a CycloneDX feature. SPDX users who need to publish exploitability data should use CSAF VEX and upload it via sbomify’s compliance documents feature instead.

Via CI

In your existing sbomify-action workflow, generate the VEX after the SBOM and post it to the same component:

- name: Generate SBOM
  uses: sbomify/sbomify-action@master
  env:
    TOKEN: ${{ secrets.SBOMIFY_TOKEN }}
    COMPONENT_ID: ${{ vars.SBOMIFY_COMPONENT_ID }}
    LOCK_FILE: 'requirements.txt'
    OUTPUT_FILE: sbom.cdx.json
    UPLOAD: true

- name: Generate VEX
  run: ./scripts/produce-vex.sh sbom.cdx.json > vex.cdx.json

- name: Upload VEX to sbomify
  run: |
    curl -fsS -X POST \
      "https://app.sbomify.com/api/v1/sboms/artifact/cyclonedx/${{ vars.SBOMIFY_COMPONENT_ID }}?bom_type=vex" \
      -H "Authorization: Bearer ${{ secrets.SBOMIFY_TOKEN }}" \
      -H "Content-Type: application/json" \
      --data-binary "@vex.cdx.json"

The shape of produce-vex.sh is up to you - tools like Dependency-Track’s VEX export, vexctl, or your own triage workflow can produce the input.

Producing VEX statements without a manual triage

VEX statements should be produced by the people closest to the code, but the wiring does not have to be artisanal. Two practical patterns:

  • Triage-then-export. Use Dependency-Track or a similar vulnerability database as the system of record. Mark findings as Not Affected, False Positive, or Exploitable with justification through the UI, then export the analysis as CycloneDX VEX. The exported file becomes the artifact you upload to sbomify on every release.
  • Source-controlled VEX. Keep a vex/ directory in the same repo that produces the SBOM, with one CycloneDX VEX statement per CVE you have triaged. The CI pipeline merges the per-CVE files into a single VEX document and uploads it alongside the SBOM. Pull requests on vex/ carry the audit trail.

Either way, the result is the same: when a customer downloads your SBOM, the VEX is right next to it, machine-readable, and dated.

Signing your VEX

A VEX is a security-relevant claim about your product. Sign it with the same machinery you use to sign your SBOM - typically actions/attest-build-provenance against the VEX file path - so consumers can verify the VEX came from your CI pipeline and was not modified in transit. Without a signature, a VEX is only as trustworthy as the channel that delivered it.

What sbomify does with VEX

Today, sbomify stores and distributes VEX:

  • Per-component listing. The component detail page shows VEX artifacts alongside SBOMs.
  • Trust Center exposure. Customers downloading from your Trust Center see and can fetch the VEX with the same access controls as the SBOM.
  • Audit trail. Every VEX upload is logged the same way as an SBOM upload, with content hash, timestamp, and uploader identity.

VEX-aware filtering of the OSV and Dependency-Track scanner results - automatically suppressing not_affected findings on the SBOM detail page - is on the roadmap. Until that lands, sbomify is the durable, audit-friendly storage and distribution layer for your VEX, and the scanner UIs continue to show the raw findings.

Further reading