Your SBOM is half an answer: pair CycloneDX with VEX
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.
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 cost | What it actually involves |
|---|---|
| The first pass is real | deciding 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 release | a 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 up | a 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:
| Do | Why it pays first | |
|---|---|---|
| 1 | Turn on SBOM generation at scope: project | one document, in the unit people ask for |
| 2 | Emit VEX from what you already suppress | every exclusion written with a reason becomes a statement, and the ones you have not triaged say so honestly |
| 3 | Upgrade the ones that matter | add 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.