Skip to content

fix: rank pre-release versions below their associated release - #1084

Merged
sonukapoor merged 2 commits into
OWASP:mainfrom
SomSamantray:feature/issue-1077-prerelease-version-precedence
Sep 5, 2026
Merged

fix: rank pre-release versions below their associated release#1084
sonukapoor merged 2 commits into
OWASP:mainfrom
SomSamantray:feature/issue-1077-prerelease-version-precedence

Conversation

@SomSamantray

Copy link
Copy Markdown
Contributor

What changed and why

compareVersions in src/utils/version.ts inverted semver §11.3: a pre-release version (e.g. 1.2.3-beta.1) compared as greater than its associated release (1.2.3), because parseVersionTuple flattened both into ./+/--split tokens and padded the shorter tuple's missing slots with the number 0 — so a pre-release identifier like "beta" ended up compared against 0 as 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 looksLikeVersion admission 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 in tests/lowest-safe-version.test.ts.

No consumer source files were changed; only the comparator and its tests.

Verification

npm ci
npm run lint:tests
npm run build
node dist/index.js advisories sync
npm test

All 133 test suites / 1675 tests pass.

Closes #1077

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 sonukapoor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonukapoor
sonukapoor merged commit b9cb8c5 into OWASP:main Sep 5, 2026
6 checks passed
@sonukapoor

Copy link
Copy Markdown
Collaborator

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 next canary meant silently clearing a critical middleware auth bypass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] compareVersions ranks a pre-release above its release, so a pre-release install before the fix is reported not vulnerable

2 participants