CVEs, SBOM & supply chainintermediate6 min read

Writing a VEX statement (and why the date matters more than the status)

A scanner reports CVE-2026-1234 in libxml2. You know the vulnerable function is never called in your setup. A VEX statement is how you record that — machine-readable, with a reason, and with the date you knew it. This is the OpenVEX format in twenty lines, the four statuses and when to use them, how to sign with ssh-keygen, and how scanners use the statement to suppress noise.

Contents
  1. The four statuses
  2. The format: OpenVEX
  3. Why the date matters more than the status
  4. Signing
  5. Making scanners use the statement
  6. The process (because the format is the easy part)
  7. Pitfalls
  8. What you still don't have
  9. How monsys does it
  10. FAQ

An SBOM says what's inside. A scanner says which CVEs go with it. Neither says whether those CVEs matter in your product — and that's the question your customer, your auditor and (from 2027, under the CRA) the regulator ask. VEX, Vulnerability Exploitability eXchange, is the answer format: per product, per vulnerability, one statement with a status, a reason and a timestamp. It's small, boring and bureaucratic, and it's the only thing that reduces the stream of scanner reports to a list that's correct.

The four statuses

StatusMeaningWhen
not_affectedThe vulnerability doesn't affect this productThe vulnerable code isn't there, isn't called, or isn't reachable. Requires a justification.
affectedThe product is vulnerableYou know, and no fix has been rolled out (yet). Mandatory: what the user should do (action_statement).
fixedResolved in this versionAfter the upgrade or patch. State the version.
under_investigationNot assessed yetHonest placeholder; better than saying nothing. With a date by which you will know.

The five allowed justification values for not_affected (from the CISA specification):

  • component_not_present — the package is in the SBOM but the vulnerable component (e.g. an optional module) isn't compiled in or installed
  • vulnerable_code_not_present — the vulnerable function isn't in your build (e.g. compiled out, or a different version of that file)
  • vulnerable_code_not_in_execute_path — the code is there but is never reached in your usage
  • vulnerable_code_cannot_be_controlled_by_adversary — reachable, but the input never comes from an attacker
  • inline_mitigations_already_exist — reachable, but another measure (sandbox, firewall, seccomp) blocks exploitation

Choose the narrowest one that's true. inline_mitigations_already_exist is the weakest (the mitigation can disappear); component_not_present the strongest.

The format: OpenVEX

There are three VEX formats (CSAF VEX, CycloneDX VEX, OpenVEX). OpenVEX is the smallest and is read by grype, trivy and osv-scanner. One document, multiple statements:

mkdir -p /srv/vex && cat > /srv/vex/shop-2026.09.json <<'EOF'
{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://example.be/vex/shop-2026.09.json",
  "author": "Example BV Security <security@example.be>",
  "role": "Product Security",
  "timestamp": "2026-09-16T09:00:00+02:00",
  "version": 1,
  "statements": [
    {
      "vulnerability": { "name": "CVE-2026-1234" },
      "timestamp": "2026-09-16T09:00:00+02:00",
      "products": [
        { "@id": "pkg:docker/example/shop@2026.09.1",
          "subcomponents": [ { "@id": "pkg:deb/ubuntu/libxml2@2.9.14+dfsg-1.3ubuntu3.4?distro=ubuntu-24.04" } ] }
      ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "libxml2 is used exclusively by php-xml to parse our own static configuration files; user-supplied XML is never parsed anywhere. The vulnerable XInclude code is not reached."
    },
    {
      "vulnerability": { "name": "CVE-2026-5678" },
      "timestamp": "2026-09-16T09:00:00+02:00",
      "products": [ { "@id": "pkg:docker/example/shop@2026.09.1" } ],
      "status": "affected",
      "action_statement": "Set the environment variable SHOP_DISABLE_EXPORT=1 until version 2026.09.2 (expected 2026-09-20).",
      "action_statement_timestamp": "2026-09-16T09:00:00+02:00"
    },
    {
      "vulnerability": { "name": "CVE-2026-0042" },
      "timestamp": "2026-09-16T09:00:00+02:00",
      "products": [ { "@id": "pkg:docker/example/shop@2026.09.1" } ],
      "status": "fixed"
    }
  ]
}
EOF
jq . /srv/vex/shop-2026.09.json >/dev/null && echo "valid JSON"

The product identifiers are purls (package URLs): pkg:docker/…, pkg:deb/…, pkg:npm/…. Use the same purls as in your SBOM, otherwise a scanner can't link the statement. For an internal product without a registry: pkg:generic/example/shop@2026.09.1.

Why the date matters more than the status

A VEX statement is a claim at a moment. not_affected on 16 September can be false on 3 October: a new release adding XML upload, a correction of the CVE record, a new exploit path. Therefore:

  • Every statement has its own timestamp — the moment that judgement was made, not the moment you saved the document.
  • You never modify a statement; you add a new one with a later timestamp and bump version. The reader takes the newest per (product, vulnerability).
  • An auditor asking "what did you know on 20 September?" gets an answer from the document itself. Without dates a VEX file is an opinion.

Concretely: git log on /srv/vex isn't enough as time evidence (a commit date is settable). The timestamp in the statement plus a signature over the file is.

Signing

Whoever can edit a VEX file can turn an affected into a not_affected. Sign with a separate key, just like evidence archives:

# Once
sudo install -d -m 0700 /etc/vex && sudo ssh-keygen -q -t ed25519 -N '' -C 'vex@example.be' -f /etc/vex/key
# Per document
sudo ssh-keygen -Y sign -f /etc/vex/key -n vex /srv/vex/shop-2026.09.json
# → /srv/vex/shop-2026.09.json.sig
# Verify (customer, auditor, CI) with only the public key:
echo "vex@example.be $(cut -d' ' -f1,2 /etc/vex/key.pub)" > allowed_signers
ssh-keygen -Y verify -f allowed_signers -I vex@example.be -n vex -s shop-2026.09.json.sig < shop-2026.09.json

Publish key.pub at a fixed URL (e.g. https://example.be/.well-known/vex-signing-key.pub) so customers can verify the signature without calling you. If you use Sigstore/cosign: cosign sign-blob does the same with a transparency log on top.

Making scanners use the statement

The point of VEX is that your scanner stops showing the not_affected reports — and does show the affected ones, with your action attached.

# Trivy: scan an image with VEX filter
trivy image --vex /srv/vex/shop-2026.09.json --show-suppressed ghcr.io/example/shop:2026.09.1
# Grype
grype ghcr.io/example/shop:2026.09.1 --vex /srv/vex/shop-2026.09.json
# osv-scanner (lockfiles): OpenVEX support varies per version; check osv-scanner --help

--show-suppressed in Trivy shows what VEX filtered out, with the justification. That's exactly the overview you show in an audit: not "we have zero vulnerabilities", but "we assessed forty, here's why for each one".

The process (because the format is the easy part)

  1. Trigger: a new scanner report on a production artefact (weekly scan, or KEV update).
  2. Assessment within 5 working days, by someone who knows the code: is the vulnerable function reachable? If not: not_affected + justification + one paragraph impact_statement. If so: affected + what the user should do now + the planned fix version.
  3. Add the statement, sign the document, publish (internal customers: a path; external: a URL).
  4. Reassess at every release of the product and at every change of the CVE record. Set a quarterly reminder for all not_affected statements older than a year.
  5. Retain: every version of the document, with signature, at least as long as the product is supported. For the CRA: the support period plus the time the regulator may look back.

Pitfalls

  • not_affected without justification. Invalid per the spec, and worthless to an auditor. The justification is the statement.
  • inline_mitigations_already_exist as the default answer. A WAF rule is a mitigation until someone turns it off. Use it only if the mitigation is part of the product itself, and describe which.
  • Purls that don't match. pkg:deb/debian/libxml2 versus pkg:deb/ubuntu/libxml2, or a missing ?distro=: the scanner doesn't see the statement and shows the report anyway. Test with --show-suppressed.
  • One statement for all versions. products points to a version. A new release is a new assessment — even if it's just a copy with a new purl and a new timestamp.
  • VEX as an excuse. A not_affected because patching is hard isn't an assessment but a postponement. The justification must be defensible by someone with the code next to them.

What you still don't have

  • The overview. Ten products, twelve releases a year, forty CVEs per release: that's thousands of statements in JSON files. "Which not_affected judgements are older than a year?" is a jq over a directory someone has to keep tidy.
  • The link to inventory. The statement says "product X version Y is not_affected". Whether version Y still runs anywhere, and where, is in another system.
  • Evidence that the process works. An auditor wants not just the statements, but also: how long between report and assessment, and who assessed.

How monsys does it

In monsys you record a VEX statement on the CVE itself, in the context of the host or application where the scanner found it: status, justification, impact text, and the identity of whoever made the judgement. The hub generates an OpenVEX document per product next to the SBOM, signs both with the tenant key and includes them in the monthly audit pack. Because the hub knows which versions run where, a not_affected judgement automatically comes up for reassessment when the product gets a new release or the CVE record changes — and the lead time between report and judgement is a metric in the Trust Score.

FAQ

Is VEX mandatory?

Not by law yet, but the CRA (Annex I §11-12) requires manufacturers to document vulnerabilities in their products and communicate them to users, and the CISA 2026 Minimum Elements for SBOMs explicitly reference VEX as the companion format. Customers who fall under NIS2 themselves increasingly request it from suppliers.

Which VEX format do I choose?

OpenVEX for simplicity and scanner support (Trivy, Grype). CycloneDX VEX if your SBOM is already CycloneDX and you want everything in one file. CSAF VEX if your customers are in industrial or government sectors using CSAF tooling. The content (status, justification, date) is the same in all three.

How long do I keep VEX statements?

At least as long as the product is supported; under the CRA the full support period plus the retention the regulator may request (count on ten years after the last release). Keep every version, not just the latest.

Written by the monsys team — sysadmins who do this every day.

Done it by hand? Let monsys keep it running.

Everything in this guide runs in monsys as a continuous check, with history, alerts and audit evidence. 5 servers free, EU-hosted in Belgium, installed in 60 seconds.