What did you know, and when? Why our VEX statements carried the wrong date
A VEX statement that says 'not affected' without a rationale is not a conclusion, it is an opinion with a timestamp. And if that timestamp is the export time, it does not even prove what you knew. We found both problems in our own implementation. Here is what we changed.
A thread went around LinkedIn last week that asked exactly the right question. The occasion: in ten days the first Cyber Resilience Act obligation for manufacturers takes effect, and in almost every conversation about it the same sentence comes up, we have an SBOM.
The observation that followed is sharper than it first looks. In Annex VII, the list of what your technical documentation must contain, the SBOM is item eight of eight. The seven items ahead of it are the product description, the design and architecture, the risk assessment against each essential requirement, the justification for your support period, the standards applied, the test reports and the declaration of conformity. One item covers what is inside. The other seven collectively show whether you knew what you were doing.
Then came four questions you should be able to answer about any SBOM:
- Which artefact is it for?
- At what point in the build was it generated?
- Using which identifiers?
- Who established that it was correct?
If you cannot answer those four, you have a file, not an inventory.
We export SBOMs and VEX documents, so we held our own implementation against that bar. Two things did not hold up. This post is about what we found and what we did about it.
First, a distinction that keeps getting lost
There are two kinds of SBOM that both go by the name SBOM, and the difference decides what you are allowed to use them for.
A build-time SBOM is generated in your pipeline, from your own sources and lockfiles. It describes what you shipped. That is the artefact Annex VII asks a manufacturer for.
A runtime SBOM is derived from a running system. It describes what is actually installed today, including the distribution packages and the kernel that arrived long after your build, and including whatever somebody installed by hand. That is what operations needs.
They answer different questions and they go stale at different speeds. A build SBOM is final the moment your release ships. A runtime SBOM from last month tells you nothing about today.
Monsys generates the second kind. That is not a shortcoming, it is the job: we look outward from the host, not inward from the build server. But it has to be stated in the document, otherwise an auditor reads your operational inventory as product documentation. So we put it in the file rather than leave it to the reader:
"lifecycles": [{ "phase": "operations" }],
"properties": [
{ "name": "monsys:generation_context", "value": "after-build (runtime host inventory)" },
{ "name": "monsys:component_hashes", "value": "unknown (metadata-only, no artifact access)" }
]
That second line answers question three, and it is uncomfortable. CISA has asked for component hashes since the 2026 Minimum Elements. We read package metadata, not the artefacts themselves, so we do not know those hashes. Then we write "unknown". An invented hash is worse than a missing one, because a missing hash is honest and an invented hash is an error that only surfaces when somebody tries to check it.
Why VEX is the harder half
An SBOM is a list. A VEX document is a judgement: per vulnerability, it states whether this system is actually affected.
In that same thread came the remark that stuck with us most. Too many organisations use "not affected" as a mere statement rather than a defensible engineering conclusion. A well-documented VEX statement is tied to a specific vulnerability, a component, a product version, a status, a rationale and ideally the evidence underneath it. Skip that and you have merely reduced dashboard noise without improving the quality of your decision record.
That is exactly where VEX goes wrong, because the temptation is real. "Not affected" makes a red box disappear. It is the cheapest way to make a report look better, and nobody can tell a reasoned assessment from a guess until the moment it matters.
Our default is therefore inverted. A matched CVE stays affected unless there is positive evidence otherwise:
| Source of evidence | Resulting status |
|---|---|
| OSV matches the installed version, nothing else known | affected, with a concrete action |
| Distribution backported the fix into this package version | fixed |
| Kernel mitigation active in the running kernel | not_affected, with inline_mitigations_already_exist |
| Operator explicitly accepted the risk, with a reason | not_affected, with that reason as the impact statement |
| Kernel status unknown at the distribution | under_investigation |
Silence is not in that table. Absence of evidence is not a justification, which is why our exporter cannot produce not_affected without something concrete attached to it.
The two things we had wrong
That much we already had in place when the thread came by. What we found next was less comfortable.
Problem one: every statement carried the export time.
The OpenVEX specification gives each statement a timestamp. We filled it with the moment you pressed the download button. Every statement in the document therefore had the same time, and that time was this afternoon.
That is technically valid JSON and substantively worthless. It proves what you claim now, not what you knew then. And the latter is precisely what a later investigation turns on: what did the organisation know, when did it know it, and was what it did next proportionate. A document in which everything happened today cannot answer that question by construction.
Problem two: the standalone VEX file was not signed.
Our SBOMs have carried an Ed25519 signature over the canonical form of the document since July. Our Audit Packs are signed. The VEX inside an Audit Pack fell under that signature. But the VEX file you download separately and forward by email, the copy that in practice travels the most, had none. An inconsistency nobody had reported, sitting in exactly the wrong place.
What we changed
The timestamp now carries the meaning it should. Per statement it is the date the evidence behind the status came into being, not the date of the document:
| Status | timestamp is now |
|---|---|
affected | the moment of first detection on this host |
under_investigation | the moment of first detection |
fixed | the moment the fix was established |
not_affected via an accepted exception | the moment the operator recorded that decision |
On top of that, each statement gets a last_updated as soon as the underlying finding has been reconfirmed. That separates two things that often get merged: when did we first know this, and when did we last check. A finding from March that was seen again this morning looks very different from a finding from March that nobody has looked at since.
In CycloneDX this lands in analysis.firstIssued and analysis.lastUpdated. In OpenVEX it lands in timestamp and last_updated on the statement itself, while the document timestamp keeps doing what it should, which is to say when this document was made.
Concretely, before and after:
{
"vulnerability": { "name": "CVE-2026-21708" },
"products": [{ "@id": "pkg:deb/ubuntu/libxml2@2.9.13+dfsg-1ubuntu0.4?distro=jammy" }],
"status": "affected",
"action_statement": "Upgrade libxml2 to 2.9.13+dfsg-1ubuntu0.5 or later.",
"timestamp": "2026-04-11T02:14:56Z",
"last_updated": "2026-09-01T04:03:12Z"
}
Previously both fields held the export time. Now it says we first saw this vulnerability on this host on 11 April and that it was still present this morning. That is a checkable claim. The previous version was not.
And the standalone VEX file is now signed, in both formats. CycloneDX has a native field for it. OpenVEX does not, so there the signature rides in its own namespace, stating exactly what was signed:
"monsys:signature": {
"algorithm": "Ed25519",
"keyId": "b5e1f5546d660fda",
"canonical": "RFC8785-JCS",
"excludes": ["monsys:signature"],
"value": "…"
}
It is the same key as for the SBOMs and the Audit Packs, and the same canonicalisation (RFC 8785). One verifier covers all three. The public key sits at /api/v1/keys, and verification does not involve us:
curl -s "https://api.monsys.ai/api/v1/agents/$AGENT/vex?format=openvex" -b cookies.txt > vex.json
# lift out monsys:signature, canonicalise the rest per RFC 8785,
# check the Ed25519 signature against the key at /api/v1/keys
We rebuilt that ourselves in a standalone verifier that shares none of our code, including a test that flips a single status field and checks that the signature then breaks. A signature you have never seen fail is a signature you have not tested.
What this does and does not mean
This makes nobody CRA compliant. Conformity is a judgement about your whole product and your whole process, and it does not get settled by a JSON file.
What it does do is make the four questions above answerable in a way that survives a forwarded file. Which artefact this is about is in there. At which point in the lifecycle it was generated is in there. Which identifiers were used is in there, including what we do not know. And who established that it was correct is no longer a name in a field but a signature you can check without us.
For VEX there is a fifth question on top, and it is really the most important one: on what basis, and since when. A status without a rationale is an opinion. A status with a rationale but with this afternoon's date is an opinion with a timestamp. Only when the date says when you knew does it become a record.
We did not have that right until last week. Now we do.