The SBOM you ship is half an answer: pairing CycloneDX with VEX
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 that matters 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 go and look, and you get an answer you can't fully trust, because somebody's vendored copy didn't make 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, who accepted it, and when that acceptance lapses. 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."
acceptedBy: "security@acme.example"
expires: "2026-12-31"
vex:
status: not_affected
justification: vulnerable_code_not_in_execute_path
draugr scan acme.saga.yaml --report vex
{
"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": "accepted by security@acme.example; expires 2026-12-31"
}
Note the last line. VEX has no field for who accepted a risk or when the acceptance expires, so most documents in the wild don't carry it. Draugr already had both, for governance reasons that predate any of this, and puts them where a reader will find them.
Why it won't guess#
One deliberate refusal, because it's the difference between a document you can stand behind and one you can't.
"Not reachable" and "we're living with this until Q3" are both perfectly good reasons to set a finding aside. They are also opposite VEX statuses. The only thing separating them is 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 first pass is real. Deciding not_affected for a CVE means establishing it, and a
reachability argument, a build flag, a configuration you enforce. That's 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 that was true in 2.3 says nothing about 2.4.
This is why the assessment belongs in the descriptor next to the code rather than in a document
somebody maintains separately, so it gets re-evaluated when the thing it describes changes,
and Draugr's expires: brings a lapsed decision back as a finding rather than letting it
quietly become permanent.
Identifiers have to line up. A VEX statement is matched by product identifier. Set
config.vex.product to whatever the SBOM your customer holds calls this product, or your
document will be 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:
- Turn on SBOM generation at
scope: project. One document, in the unit people ask for. - Emit VEX from what you already suppress. Every exclusion you've written with a reason becomes a statement; the ones you haven't triaged say so honestly.
- Upgrade the ones that matter. Add a
vex:block to the exclusions covering the CVEs your customers keep asking about. That's a small list, and it's 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.