under_investigation is a status, not an excuse
A scanner tells you a component in your build carries a known advisory. It cannot tell you whether the vulnerable code is reachable in your product, and that judgement is the entire content of a VEX document. So the interesting design question is not how to express affected or not_affected. It is what your tooling does with the findings nobody has judged yet. The common answer, defaulting them to not_affected because the release is on Friday, turns a triage backlog into a signed assurance.
I build payments and crypto backends, so I ship services whose dependency lists are long, boring, and audited by people who did not write them. The Cyber Resilience Act expects manufacturers to answer the reachability question quickly and in writing for every product they ship, which is a schedule problem before it is a security problem. Schedule problems are where defaults do their damage: nobody decides to publish a false claim, they decide not to block the release, and the default publishes the claim for them.
What the default actually asserts#
not_affected is a claim about your code. It says: I looked, and the vulnerable path is not in the execute path, or the component is not present, or the adversary cannot control the input. under_investigation is a claim about your process. It says: this is in the queue and it has not been answered.
Both are publishable. Only one of them can be falsified by an attacker with a weekend and a debugger, and it is not the honest one.
The consequence shows up in the shape of the document rather than in any single statement. If unreviewed findings default to not_affected, the number of not_affected statements stops tracking how much review happened and starts tracking how big your SBOM is. A downstream reader (a customer's security team, an auditor, the person who inherits the service) cannot tell eighty components somebody worked through from eighty components nobody opened. That distinction is the only reason the document exists.
The other failure mode is quieter: dropping undecided findings entirely, so they simply do not appear. Absence is worse than an honest status, because absence is ambiguous. Did the matcher not find this advisory, or did it find it and get ignored?
Three kinds of unknown, each with a reason attached#
When I wrote vexdesk, the rule I started from was that nothing which cannot be determined gets quietly cleared. In practice "cannot be determined" is not one thing, and the reasons are not interchangeable:
$ vexdesk match -sbom sbom.cyclonedx.json -advisories ./advisories
4 component(s) compared against the advisory set
STATUS ADVISORY COMPONENT VERSION REASON
affected FIXTURE-0001 widget 1.2.3 version falls inside the advisory's affected range
affected FIXTURE-0004 cogwheel 4.0.0 version is in the advisory's affected version list
unknown FIXTURE-0003 Example_Fixture 3.1.2 ECOSYSTEM range for PyPI needs that ecosystem's own version ordering, which is not implemented
Not checked (1):
vendored-blob no package URL: nothing to look upThree different human actions hide behind those lines. A component with no package URL cannot be looked up in any advisory database, so somebody has to identify that vendored blob by hand, and no amount of tooling will do it for them. A PyPI ECOSYSTEM range needs PEP 440 ordering, which the tool does not implement; that is a gap in my matcher, not a property of your product, and the right response is to compare the version manually or fix the matcher. An affected line is a reachability question for whoever owns that code path.
The reason string is the payload here, not the status. An unknown with no reason is indistinguishable from a bug in the tool, and a status a reviewer cannot interrogate is a status they will either rubber-stamp or ignore. The same rule shapes version parsing: 2023-08-01 is not read as major version 2023, it is refused. A date that silently outranks every real version produces a confident wrong answer, which is the most expensive output a matcher can produce, worse than no answer, because it terminates the conversation.
The absence of a decision is itself a statement#
Decisions live in a file, separate from the matcher, because they are human output and the match is machine output:
{
"decisions": [
{
"vulnerability": "FIXTURE-0001",
"product": "pkg:golang/github.com/example/widget@v1.2.3",
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "the affected parser is only reached from the admin importer, which this build does not include"
}
]
}The part that matters is what happens to the findings this file does not mention. They are not dropped and they are not cleared: they are emitted as under_investigation. The undecided finding survives into the published document as an undecided finding, and the document stays a rendering of the triage queue rather than a summary of the parts of it somebody got around to.
This also removes the incentive that produces bad not_affected statements. not_affected requires one of the five OpenVEX justification codes (component_not_present, vulnerable_code_not_present, vulnerable_code_not_in_execute_path, vulnerable_code_cannot_be_controlled_by_adversary, inline_mitigations_already_exist) and the document refuses to build without one. affected requires an action statement: what should the user do. under_investigation requires nothing, because there is nothing to say yet.
That asymmetry is deliberate. If the only way to make the build pass were to write a justification, engineers under deadline would pick the code that sounds closest to true, and a reviewer six months later would have no way to tell a considered vulnerable_code_not_in_execute_path from a guessed one. Leaving one status available at zero prose cost means the pressure valve is honesty rather than a plausible code.
A status that survives review carries its why#
A VEX statement is read by someone who was not in the room. That reader has exactly two questions: what did you conclude, and on what basis. The justification code answers the second in a form they can compare against every other statement you have made, which is the whole reason to require a code alongside the prose rather than prose alone. Free text is unanswerable at scale. A code is sortable.
The same logic applies to the document as an artefact. In vexdesk the document id is derived from the statements, so an unchanged set of decisions rebuilds to the same id instead of looking newly issued on every CI run. That sounds cosmetic until you try to review a burn-down. If the id churns on every build, no consumer can tell a re-publish from a re-decision, and a finding flipping from under_investigation to not_affected, the single most important event in this whole workflow, disappears into noise. Document comparison lives in its own package in that repository for the same reason: the interesting object is the delta between two documents, not either document alone.
And because match exits 1 when anything needs attention, the backlog can gate a pipeline rather than sit in a wiki:
#!/usr/bin/env bash
set -euo pipefail
vexdesk match -sbom sbom.cyclonedx.json -advisories ./advisories || echo "findings need review"
vexdesk vex -sbom sbom.cyclonedx.json -advisories ./advisories \
-decisions decisions.json -author "Example Ltd" -o vex.jsonNote the shape: the match result informs, the document still builds. A gate that refuses to produce a VEX document until every finding is resolved gets disabled in a week. A gate that publishes the truth, including the unresolved parts, survives contact with a release schedule, which is probably the only property that matters, because a control nobody can ship past is a control nobody keeps.
What I would do differently#
under_investigation has a half-life. It is honest on the day you publish it and it is an indictment three months later, and nothing in the document as it stands expresses that difference. A statement carries what you concluded, not when you first saw the finding, so a stale queue and a fresh one look identical to a reader.
If I were extending this, that is where I would go next: record when a finding first appeared undecided, and let the age of an under_investigation statement be visible to the person reading it. Not as a gate, since an aging finding is not automatically a problem and plenty of them are waiting on an upstream fix, but as the thing a reviewer should look at first. That is not built, and I would rather say so than describe it as though it were.
The rest of the design I would keep unchanged. A tool that refuses to answer a question it cannot answer is more useful than one that answers everything, because the second kind trains you to stop reading its output. under_investigation is not the tool admitting defeat. It is the one status in the vocabulary that is always available and never a lie, and a document that uses it freely is a document a reviewer can actually work with.