A date is not a version: strict parsing under OSV ranges
A vulnerability range is a claim about ordering, and it is worth no more than the version parser sitting underneath it. Walk an OSV range's events in whatever order they happen to sit in the file, compare them against leniently parsed versions, and you get two silent failures: verdicts that flip when the advisory is re-serialised, and a component calling itself 2023-08-01 that lands outside every affected range forever.
Neither failure announces itself, and both land on the one verdict nobody rechecks. An affected finding gets read, argued about, and then either fixed or justified in writing. not_affected gets counted. So a comparator that quietly produces not_affected out of an ordering it invented is worse than one that refuses to answer. A good part of the design work in the internal/match package of vexdesk (the tool I wrote to turn an SBOM and a pile of OSV advisories into an OpenVEX document) goes into making the refusal the cheap path rather than the embarrassing one.
not_affected is version arithmetic and nothing else#
Worth pinning down what the verdict means before I defend it. When the matcher says not_affected, it says this version sits outside every range the advisory gives. It is not saying the vulnerable code is unreachable in the product. That is an engineering judgement, it belongs to a person, and it arrives later as a decision file: anything still undecided leaves the document as under_investigation, not as not_affected.
The narrowness is deliberate. A claim about arithmetic over an ordering is the kind of claim you can get exactly right, and the kind that is indefensible to get wrong, because there is nothing subjective left to hide behind. So the ordering has to be real, and where you cannot establish it, the honest output is unknown with the reason attached.
A lenient parser invents an ordering#
vexdesk's parseSemver demands exactly major.minor.patch and refuses leading zeros. 1.2 is an error. 2023-08-01 is an error. Both come out as unknown with the parse error as the reason, and the first reaction you hear is that this is pedantry inflating the unknown count. It runs the other way: this is the one place where pedantry carries load.
Think about what a tolerant parser does with a date. Split on non-digit separators, coerce the parts, and 2023-08-01 turns into something that orders as 2023.8.1, a version above every real version anyone will ever publish. Now measure it against a perfectly ordinary range, introduced: 0, fixed: 2.1.4. The date sits above the fix, so it falls outside the interval, so the component is not_affected. Permanently, for every advisory, for a version string that carries no ordering information at all. The tolerant parser did not fail to compare: it succeeded at comparing a number it made up.
The leading-zero refusal is the same argument at smaller scale. Accept 1.08.0 and read it numerically, and now two spellings of one version exist, equality and ordering stop agreeing with each other, and whether a component sits inside a range starts depending on which spelling reached you. Cheaper to have no answer than an answer that depends on that.
Everything the comparison cannot establish ends up in one list, and the list is kept whole:
var unknown []string
for _, r := range aff.Ranges {
switch r.Type {
case osv.RangeSemver:
// fall through to comparison
case osv.RangeEcosystem:
if !eco.semverOrdered {
unknown = append(unknown, fmt.Sprintf(
"ECOSYSTEM range for %s needs that ecosystem's own version ordering, which is not implemented",
eco.name))
continue
}
case osv.RangeGit:
unknown = append(unknown, "GIT range: commit ranges cannot be compared against a version string")
continue
default:
unknown = append(unknown, fmt.Sprintf("range type %q is not recognised", r.Type))
continue
}
hit, err := inSemverRange(version, r)
if err != nil {
unknown = append(unknown, err.Error())
continue
}
if hit {
return Affected, "version falls inside the advisory's affected range"
}
}
if len(unknown) > 0 {
// Every reason survives: the person clearing this finding needs the
// whole list, not the first item of it.
return Unknown, strings.Join(unknown, "; ")
}The ECOSYSTEM branch is the same discipline applied to a whole ecosystem instead of one string. An ECOSYSTEM range means "use this ecosystem's own version ordering", and only a few ecosystems in the tool are marked semverOrdered: golang, npm, cargo. Anything else leaves the range unknown, carrying the reason, rather than quietly compared with semver rules that do not apply to it. Here is what a PyPI component looks like on the way out:
$ 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 implementedFile order is not a sort order#
The second failure is subtler, because nothing about it looks like a parsing problem.
An OSV range is a JSON array of events: introduced opens an interval, fixed and last_affected close it. JSON preserves array order, so walking the array and flipping a boolean as you go feels natural. The trouble: the schema's meaning does not live in the array order. A well-formed range can list its events in an order that has nothing to do with their version order, and any tool that reads, filters, merges or re-emits an advisory feed is free to hand you the same claim with the array arranged differently.
This range is the same claim whichever way round you print it:
{
"affected": [
{
"package": { "ecosystem": "Go", "name": "github.com/example/widget" },
"ranges": [
{
"type": "SEMVER",
"events": [
{ "introduced": "1.5.0" },
{ "fixed": "2.0.0" },
{ "introduced": "0" },
{ "fixed": "1.2.0" }
]
}
]
}
]
}Walked in array order, 1.1.0 opens at 1.5.0, closes at 2.0.0, opens again at 0, closes at 1.2.0, and whether it comes out inside depends entirely on how you carried that state. Shuffle the four lines and the same advisory gives you a different answer. Not a wrong answer: an unstable one, which is harder to notice and much harder to explain to someone who has already shipped a document built on it.
So inSemverRange sorts the events before walking them. Two rules do the work. First, "introduced": "0" sorts ahead of everything: it is the schema's sentinel for "from the beginning", it is not a parseable major.minor.patch triple, and it needs its own case rather than a parser loose enough to swallow it. Second, at equal versions the closing event sorts before the introduced event that reopens. That pair is a real shape and not a hypothetical: a branch gets closed at a version and a new line of releases opens at the same version. Sorted that way the walk closes and then reopens, so the interval is open at that version, and the verdict holds regardless of the order the file used. internal/match/range_order_test.go exists to hold that property down: the same range, shuffled, one verdict.
The strongest verdict wins across entries#
One more place where order would otherwise leak in. Advisories routinely split a single claim across several affected entries: one per branch, one per range type, one per distribution. Stating each separately would put several statements in the document for one claim, and a claim-by-claim reader keeps only the last one it sees, which puts the output back at the mercy of entry order. So the entries fold into one verdict, and the verdict that asks most of a person wins:
for _, aff := range a.Affected {
if !samePackage(aff.Package.Ecosystem, aff.Package.Name, eco, name) {
continue
}
s, reason := evaluate(version, aff, eco)
switch {
case !stated || rank(s) > rank(status):
stated, status, reasons = true, s, []string{reason}
case rank(s) == rank(status) && !slices.Contains(reasons, reason):
reasons = append(reasons, reason)
}
}// rank orders verdicts by how much they ask of a person.
func rank(s Status) int {
switch s {
case Affected:
return 2
case Unknown:
return 1
default:
return 0
}
}unknown outranking not_affected is the thesis of this whole post compressed into one integer. An entry that could not be compared does not get cleared by a sibling entry that could. And because match exits 1 when anything needs attention, an unknown stops a pipeline instead of sitting as a line in a report nobody opens.
Where leniency does belong#
Strictness is not a general virtue here, and it is worth being precise about where it stops.
An explicit affected version list is exact set membership, not ordering, and there I do apply one normalisation: the leading v gets trimmed on both sides, because Go components carry it and OSV lists mostly do not, and a string mismatch there would clear a version that is plainly on the list. Safe, because nothing is being ordered, and deliberately nowhere near the comparator.
The same split runs through the document side of the tool. openvex.New validates what it builds, and Statement.Validate is severe about it: a not_affected with an impact_statement and no justification code gets refused, because prose cannot be compared against every other not_affected the way a code can. But openvex.Parse never calls Validate, so documents already issued are read leniently, including ones that break the rules the tool enforces on write. Refusing them would hide statements someone needs to read.
The rule is not "be strict everywhere". It is: never let leniency manufacture a fact. A tolerant read of a document's shape loses nothing. A tolerant read of a version string produces an ordering that was not in the input, and then a verdict that was not in the input either.
What I would do differently#
I would stop treating the unknown count as a defect to engineer down. My first instinct here was the wrong one: a long unknown list reads like the tool failing, and the obvious fix looks like a cleverer parser that gets an answer out of 2023-08-01 somehow. That instinct comes from the wrong domain. In payment systems I have spent years on the opposite reflex: an unsettled transfer is not a successful one, and the unknown state is the expensive one precisely because you cannot collapse it into either neighbour without lying. Vulnerability matching has the same shape. unknown with a reason attached is a work item for a named person; not_affected derived from a guess is a liability with a signature under it.
And I would write the order-independence test before the comparator, not after it. The property (same range, any permutation, one verdict) runs about ten lines, it fails immediately against a naive in-order walk, and it is probably the cheapest way to find out that your evaluator's answer depends on something as fragile as how a feed happened to serialise an array.
The parser is not a detail underneath the range. It is the range.