Declare once, apply to many: scoping VEX decisions without lying
A vulnerability scanner does not remember what you decided yesterday. Every build re-reports the same CVEs against the same dependencies, and someone re-reads the same advisory to reach the same conclusion. VEX exists to record that conclusion once, but a VEX statement is an assertion about a concrete product at a concrete version — so the moment anything bumps, you either re-triage from zero or you quietly reuse a claim you never re-checked. Neither of those is a process; the first is attrition and the second is a lie with good formatting.
I hit this on services where the scanner output is not decoration. In payment and compliance-adjacent systems, someone eventually asks for the basis of a specific claim about a specific artifact that was running on a specific day. "We marked it not affected" is not an answer. "We marked it not affected in March against version 2.4.1, and the artifact you are asking about shipped 2.4.9, and nobody looked again" is at least a true answer — but only if the system was built to know that.
The two ways to be wrong#
The first way is to pin every decision to the exact tuple of product version and dependency version. This is honest and unusable. A patch bump on a transitive dependency invalidates a decision that is still obviously correct, the queue fills with re-triage that produces the same verdict, and within two months the team is clicking through it without reading. An honest model that nobody follows produces dishonest data.
The second way is to declare component-wide: this CVE against this library is not affected, full stop, whatever version. This is usable and false. It keeps asserting after the fact underneath it stopped being true, and it fails silently, which is the worst failure mode available for a security claim.
Both failures come from treating scope as a UI convenience — a checkbox for "apply to all versions" — rather than as part of the claim itself. Scope is part of the claim. A decision without an explicit applies_to is not a compact decision; it is an ambiguous one.
Scope follows the justification#
Here is the part that made the model tractable for me. Under OpenVEX, a not_affected status carries a justification (or a written impact statement), and each justification is a statement about a different thing:
| Justification | It is a property of | Survives a dependency bump? |
|---|---|---|
component_not_present |
your build and packaging | yes |
vulnerable_code_not_present |
that exact dependency version | no |
vulnerable_code_not_in_execute_path |
your code's call graph | usually |
vulnerable_code_cannot_be_controlled_by_adversary |
your input and deployment path | usually |
inline_mitigations_already_exist |
your configuration | usually |
Four of the five are assertions about your product. They are legitimately component-wide with respect to the dependency, and they break when you change — when packaging changes, when someone starts calling the function you swore you never call, when a mitigating config option gets flipped in one environment. Exactly one, vulnerable_code_not_present, is an assertion about the dependency's source at a version. Carrying that one across a version bump is not a shortcut; it is a fabricated claim, because the whole content of the claim was "this code is not in this version".
So the scope is not a preference. It follows from the justification — and a decision whose scope contradicts its justification is a bug you can detect mechanically. vexdesk does not enforce that check yet; today it is a review rule I apply by hand when a decision lands in the file, and encoding it is the next change on the list. I will come back to why it matters more than anything else here.
The scope a decision actually carries#
In vexdesk I keep decisions as source rather than as scanner-tool state: a JSON file in the repository, one entry per judgement, applied to whatever the current scan finds. The tool is Go, standard library only, and the scope model it ships today is deliberately the small one — a single axis, on the component:
// Scope says how far one decision reaches.
type Scope string
const (
// ScopeVersion keeps the decision on the exact product it names. It is the
// default: a judgement made about one build does not travel unless asked.
ScopeVersion Scope = "version"
// ScopeComponent applies the decision to the same component at any version,
// so a justification written once survives the next scan. A version-scoped
// decision naming a particular version wins over it, which is how a rule is
// taken back for that version.
ScopeComponent Scope = "component"
)
type Decision struct {
Vulnerability string `json:"vulnerability"`
Product string `json:"product"`
AppliesTo Scope `json:"applies_to,omitempty"`
Status openvex.Status `json:"status"`
Justification openvex.Justification `json:"justification,omitempty"`
Impact string `json:"impact_statement,omitempty"`
Action string `json:"action_statement,omitempty"`
}A decision file entry looks like this:
{
"vulnerability": "CVE-0000-00000",
"product": "pkg:pypi/somelib@2.4.1",
"applies_to": "component",
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "the vulnerable parser is never invoked; we only use the encoder"
}applies_to defaults to version: a judgement made about one build does not travel unless you asked it to. "Every version" is a value you had to type, not a default you inherited. And an unreadable scope is refused at load time rather than narrowed to the default — a typo would otherwise silently shrink a rule, which is the quiet kind of wrong this whole design exists to avoid.
Resolution: the exact decision wins#
Precedence is specificity, not recency. A decision naming the exact product is used as written; only when there is none does a component-wide rule for the same component apply. That is also how you take a blanket rule back for one version — write the version-specific decision, and it outranks the rule without deleting it:
if d, ok := exact[[2]string{f.Advisory, f.Component.PURL}]; ok {
apply(&s, d, "")
} else if d, ok := componentWide[[2]string{f.Advisory, componentKey(f.Component.PURL)}]; ok {
apply(&s, d, d.Product)
}Anything no decision reaches comes out as under_investigation — the honest status for "we have seen it and have not finished looking". The tool never upgrades that on its own: deciding that vulnerable code is unreachable is an engineering judgement, and a tool that guesses it produces documents that look authoritative and are not.
Every carried decision must say it was carried#
A status is not enough. It matters how the status reached this artifact, because "declared for exactly this build" and "inherited from a rule written months ago against a different version" are different epistemic states and belong in different review queues. So when a component-wide rule fires, the emitted statement says so, in the statement itself:
note := fmt.Sprintf("carried from the decision recorded for %s by an applies_to=%s rule",
carriedFrom, ScopeComponent)That line ends up in the published document, so a reader of the VEX file — not just a reader of my internal state — can tell a pinned judgement from an inherited one. It is a deliberately blunt mechanism: provenance as text, in the field people actually read. A structured carried_from object with the source decision, its date and a review deadline is the obvious next step, and it belongs in an internal store rather than the published document — OpenVEX products are concrete identifiers, there is no wildcard with agreed semantics, and the published document should say what is true about this artifact while the store says how you came to believe it.
One consequence of the current model worth stating plainly: the carried note lands in impact_statement, which OpenVEX defines for not_affected. For rules with other statuses that placement is questionable, and it is on the list to move.
What I would do differently#
My first version of this was a table keyed by (product, cve) with a status column and a notes field, because that is what the triage session in front of me needed. Ranges got bolted on later as a nullable string, and the meaning of NULL drifted between "all versions" and "nobody filled this in" depending on which script wrote the row. Cleaning that up cost more than modelling scope explicitly from the start would have.
The model I keep converging on has two independent version axes — which versions of my product, and which versions of the vulnerable component — with precedence by specificity on both, and a tie between two equally specific decisions treated as a build failure rather than resolved by recency. Picking the newest one is how you get an audit trail that is technically complete and substantively meaningless. vexdesk today ships the one-axis version of that model on purpose: it covers the cases my triage actually produces, and every scope concept it does have is spelled out rather than implied. The two-axis model earns its complexity the day a vulnerable_code_not_present claim needs to coexist with a product-side rule for the same CVE — and that is exactly the day the justification check from earlier stops being a review habit and must become code, refusing at authoring time the one blanket rule that is always a fabrication.
The other thing I would change earlier: make a review deadline mandatory on any decision that can be carried. An unbounded assertion about your own call graph is fine on the day you verify it and slowly rots afterwards, and the only cheap defence is that carried decisions expire into a review queue instead of quietly aging into permanence. A carried decision past its review date should degrade to under_investigation rather than keep asserting.
None of this makes triage disappear. It makes triage compound: the component-wide rules absorb the churn they are entitled to absorb, the version-specific facts stay pinned to the versions they are facts about, and everything inherited is labelled as inherited. An audit trail that distinguishes what you checked from what you assumed is worth having. One that does not is a guess with timestamps on it.