← All articles

Your SBOM is half an answer: pair CycloneDX with VEX

August 6, 2026 · Wilson Santos · updated October 30, 2026
On this page

You added SBOM generation because a customer asked, or because a regulation did. It took an afternoon. Turn on a generator, attach the output to the release, done.

Then the questions started. A customer's scanner matched forty CVEs against your components and their security team wants to know about each one. You know most of them don't apply, but the vulnerable function is never called, the feature is compiled out, it's a build-time dependency that never ships. You just have no way to say so that their tooling will believe.

An SBOM is an inventory, not an assessment. Publishing one without the assessment doesn't answer the question; it distributes it.

What an SBOM is good for

Two things, and they're worth having on their own.

Incident response

The morning a name like log4shell breaks, the only question is are we affected, and where. With an inventory per release you answer in minutes with a query. Without one you answer in days, by asking teams to look, and you get an answer you cannot fully trust because somebody's vendored copy never made it into anyone's mental model.

Being allowed to sell

SBOMs have moved from nice-to-have to gate. EO 14028 for US federal buyers, the EU Cyber Resilience Act for anything with digital elements, and long before either bites, the enterprise security questionnaire that stalls a deal until you produce one.

Both are real. Neither requires anyone to read the document, which is why the afternoon version of the job feels complete.

The unit problem nobody mentions

Here's the first thing that goes wrong in practice.

SBOMs are requested per product. The customer wants the bill of materials for the thing you shipped them. But tools generate them per artifact, one per repository and one per image. A modest platform with four services and three containers produces seven documents, and a customer asking for "your SBOM" gets a zip file and a puzzle.

So Draugr will assemble one:

config:
  sbom:
    enabled: true
    scope: project        # component (default) | project | both
SBOM: 1 project document (cyclonedx-json)

The root component is the release; each of your components sits beneath it, each repository or image beneath that, and the packages beneath those. A library shared by three services appears once, with the dependency graph recording which parts of the product contain it, so the document answers both what do we ship and who ships it, which the naive merge destroys by picking one.

Assembling rather than merging is what makes that possible, because Draugr isn't guessing how a pile of documents relate, it's following the structure your descriptor already declares.

The other half

Now the part that turns the inventory into an answer.

VEX, or Vulnerability Exploitability eXchange, is a machine-readable statement about each vulnerability, saying whether it affects this product and on what grounds. Four statuses, of which not_affected is the one everyone wants, and the specification deliberately won't let you just assert it. You supply a justification from a fixed vocabulary, or a prose impact statement.

The value is that it applies automatically. Hand a consumer your VEX and their scanner drops what you've cleared:

$ trivy fs acme-api --scanners vuln -f json \
    | jq '[.Results[]?.Vulnerabilities[]? | select(.VulnerabilityID=="CVE-2018-18074")] | length'
1

$ trivy fs acme-api --scanners vuln --vex openvex.json -f json \
    | jq '[.Results[]?.Vulnerabilities[]? | select(.VulnerabilityID=="CVE-2018-18074")] | length'
0

That's forty emails you don't answer, per customer, for the supported life of the release.

From documents to an answer Three stages. Seven documents, one per repository and image, are an inventory. Assembled into one product SBOM they become an inventory of the product. Adding a VEX statement, which says which vulnerabilities apply and on what grounds, makes it an answer. 7 documents one per repository, one per image 1 product SBOM what you shipped, as one thing + VEX and which of it applies, with the grounds an inventory an inventory of the product an answer
The SBOM says what is in it. The VEX says which of it matters.

You have probably already written it

Here's the part we find most interesting, and it's an accident of a decision made for entirely different reasons.

Draugr's exclusions are not deletes. Set a finding aside and it stays in the report, marked suppressed, carrying the reason somebody gave and when that acceptance lapses, beside the commit that wrote the exclusion. That exists because the question an auditor asks is never "did the scanner run". It is "who decided this was acceptable, and when".

Look at what a VEX statement needs. It's the same fields.

config:
  vex:
    author: "Acme Ltd <security@acme.example>"
    product: "pkg:oci/acme/api@2.4.0"
  exclude:
    - rules: ["CVE-2018-18074"]
      reason: "The redirect that leaks the header is never taken; we pin the host."
      expires: "2026-12-31"
      vex:
        status: not_affected
        justification: vulnerable_code_not_in_execute_path
draugr scan acme.saga.yaml -o out/ --report vex     # out/openvex.json
{
  "vulnerability": {
    "name": "CVE-2018-18074"
  },
  "products": [
    {
      "@id": "pkg:oci/acme/api@2.4.0"
    }
  ],
  "status": "not_affected",
  "justification": "vulnerable_code_not_in_execute_path",
  "status_notes": "expires 2026-12-31"
}

Note the last line. VEX has no field for when an acceptance expires, so most documents in the wild don't carry it, and a temporary acceptance reads as a standing one. Draugr already had the date, for governance reasons that predate any of this, and puts it where a reader will find it.

Why it won't guess

One deliberate refusal, because it is the difference between a document you can stand behind and one you cannot. "Not reachable" and "we're living with this until Q3" are both perfectly good reasons to set a finding aside, and they are also opposite VEX statuses, separated only by English prose that nobody promised was about reachability.

So Draugr doesn't read your reason and decide. An untriaged finding is published as under_investigation; a suppressed one with nothing declared is published as affected, carrying your reason. To claim not_affected you say so, explicitly, in a vex: block.

The direction of that default is the whole safety argument. Overstating your exposure costs a consumer some wasted triage. Understating it tells a customer they're safe when they aren't, in a signed document, over your name. Where the two errors are that asymmetric, the default belongs on the side you can survive being wrong on.

What this costs you

Being straight about the work, because "it's free" would be a lie you'd discover in week two.

The costWhat it actually involves
The first pass is realdeciding not_affected means establishing it: a reachability argument, a build flag, a configuration you enforce. That is the analysis you were doing anyway, one email at a time. VEX makes you do it once and write it down
It expires with the releasea not_affected true in 2.3 says nothing about 2.4, which is why the assessment belongs in the descriptor beside the code. Draugr's expires: brings a lapsed decision back as a finding rather than letting it become permanent
Identifiers have to line upa statement is matched by product identifier. Set config.vex.product to whatever the SBOM your customer holds calls this product, or your document is read, understood, and applied to nothing

None of that is avoidable by a different tool. It's the actual shape of the problem, and the alternative is answering it repeatedly in prose.

Where to start

If you're doing none of this today, the order that pays fastest:

DoWhy it pays first
1Turn on SBOM generation at scope: projectone document, in the unit people ask for
2Emit VEX from what you already suppressevery exclusion written with a reason becomes a statement, and the ones you have not triaged say so honestly
3Upgrade the ones that matteradd a vex: block to the exclusions covering the CVEs customers keep asking about. A small list, and the one doing the damage

An SBOM tells people what you're made of. A VEX tells them what it means. The second one is what they were asking for all along.

Try it. Apache-2.0, and it runs entirely on your machine. See publish a VEX document and SBOM generation.