fix: rank pre-release versions below their associated release - #1084
Conversation
compareVersions inverted semver 11.3: parseVersionTuple flattened a
version into dot/plus/hyphen-split tokens and padded a shorter tuple
with the number 0, so a pre-release identifier like "beta" compared
against 0 as a string ("beta".localeCompare("0") is 1) and sorted
above the release it should sort below.
versionMatchesRange (src/advisory/local-db.ts) relies on this
comparator, so an installed pre-release sitting before the fix
version was reported not vulnerable -- a false negative in a
vulnerability scanner.
Parse each version into a numeric release tuple plus an optional
pre-release identifier list, and apply semver 11.4 precedence when
both sides share a release. When either operand doesn't match the
expected X.Y.Z[-pre][+build] shape, both fall back to the previous
flat-token comparison so a malformed input degrades consistently
instead of comparing two different parse shapes against each other.
resolvePublishedFixVersion (src/remediation/npm-registry.ts) sorts
packument versions with the same comparator without pre-filtering
pre-releases, so its nearest-published-version lookup also becomes
correct as a byproduct.
Closes OWASP#1077
- Widen VERSION_SHAPE's pre-release character class to match looksLikeVersion's admission gate (anything but '/' or '+') instead of a narrower ASCII set. A pre-release containing a character outside the old set (e.g. an underscore) passed looksLikeVersion but failed VERSION_SHAPE, silently routing both operands to the flat-token fallback and reintroducing the exact ranking-inversion bug this file fixes. Caught by the independent adversarial review pass. - Compare non-numeric pre-release identifiers with ASCII/code-unit order instead of localeCompare, so precedence doesn't vary by runtime locale. - Add test coverage for semver 11.4 identifier precedence (numeric vs numeric, numeric vs alphanumeric, shared-prefix length tiebreak) and for the shape-mismatch regression above. - Replace the fallback-path test's not.toThrow() checks with concrete expected values so it asserts the fallback's actual behavior.
sonukapoor
left a comment
There was a problem hiding this comment.
This is good work. I ran it against semver as an oracle: 362,000 version pairs, zero mismatches, where the old comparator missed 84 out of 1,764 on the same set. The spec's own §11 chain passes end to end, including the beta.2 < beta.11 trap.
Three things worth calling out because they show you thought past the obvious fix. You kept the memoisation from #740 and used a cached !== undefined guard, so malformed strings get memoised too instead of being re-parsed forever. You anticipated the fallback-inversion trap and wrote a test for it. And the numeric release tuple is about 35% faster on the pairwise path than what it replaced, which I was not expecting from a correctness fix.
It also fixes something nobody filed: npm-registry.ts:192 could recommend a pre-release as the fix version when the advisory's hint was unpublished. Your lowest-safe-version test pins that.
I am merging as-is. Two gaps I found that I am handling separately rather than holding this up for:
Build metadata is correct but untested. I deleted the (?:\+.*)? from VERSION_SHAPE and all 1,675 tests still passed, so a regression there would be silent.
The test for the headline case hand-copies versionMatchesRange into the test body rather than calling local-db.ts. It proves the comparator is right but not that the scanner path is. I mutated the real function and that test stayed green.
One consequence worth knowing about, which neither of us caught in the first pass: §11.3 applies to the lower bound too, so 1.2.0-beta.1 now falls outside a range introduced at 1.2.0 and stops being reported. That is precedence-correct and node-semver does the same, but it is a new false negative in the opposite direction. Tracked as #1086, not a mark against this PR.
|
Merged, thank you @SomSamantray. This fixed a genuine false negative: an installed pre-release sitting below the fix version was reported as not vulnerable, which on a |
What changed and why
compareVersionsinsrc/utils/version.tsinverted semver §11.3: a pre-release version (e.g.1.2.3-beta.1) compared as greater than its associated release (1.2.3), becauseparseVersionTupleflattened both into./+/--split tokens and padded the shorter tuple's missing slots with the number0— so a pre-release identifier like"beta"ended up compared against0as a string, and"beta".localeCompare("0")is positive.versionMatchesRange(src/advisory/local-db.ts) relies on this comparator, so an installed pre-release sitting before the fix version was reported not vulnerable — a false negative in a vulnerability scanner.The fix parses each version into a numeric release tuple plus an optional dot-separated pre-release identifier list, and applies semver §11.4 precedence when both sides share the same release (no pre-release outranks a pre-release; between two pre-releases, numeric identifiers compare numerically, alphanumeric ones by ASCII order, and a longer identifier list outranks a shorter one on an equal shared prefix). When either operand doesn't match the expected
X.Y.Z[-pre][+build]shape, both operands fall back to the previous flat-token comparison, so a malformed input degrades consistently instead of comparing two different parse shapes against each other.An independent adversarial review pass caught that the initial version of this fix used a narrower character class for the pre-release portion than the pre-existing
looksLikeVersionadmission gate, so a pre-release containing a character outside that narrower set (e.g. an underscore) would silently fall through to the flat-token fallback and reintroduce the exact bug being fixed. That's corrected, with a regression test.resolvePublishedFixVersion(src/remediation/npm-registry.ts) sorts packument versions with the same comparator without pre-filtering pre-releases, so its nearest-published-version lookup near a pre-release also becomes correct as a byproduct — covered by a new test intests/lowest-safe-version.test.ts.No consumer source files were changed; only the comparator and its tests.
Verification
All 133 test suites / 1675 tests pass.
Closes #1077