Summary
compareVersions in src/utils/version.ts ranks a pre-release above its associated release, inverting semver §11.3 ("a pre-release version has lower precedence than the associated normal version"). Because versionMatchesRange in src/advisory/local-db.ts short-circuits on compareVersions(version, fixed) >= 0, an installed pre-release that sits before the fix version is silently classified as not vulnerable.
That is a false negative in a vulnerability scanner, so I am reporting it rather than sitting on it.
Root cause: parseVersionTuple splits on /[.+-]/ into one flat tuple, and compareVersions pads the shorter tuple with the number 0. A pre-release tag therefore ends up compared against 0 as a string, and "beta".localeCompare("0") is 1.
// src/utils/version.ts L28-38
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const av = pa[i] ?? 0;
const bv = pb[i] ?? 0;
...
Pre-releases do reach the matcher: findMatchingVulnerabilityIds gates only on looksLikeVersion, whose regex ^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][^/]+)?$ explicitly admits them. isPreReleaseVersion is applied only to candidate fix versions in the remediation path, never to the installed version.
Environment
- OS: Windows 11
- Node.js version: v24.11.0
- Package manager: npm 11.6.1
- Lockfile type: n/a (defect is in the version comparator, reproducible directly)
- CVE Lite CLI version: 1.33.0 (
main @ 16d746c)
Command used
Expected behavior
Per semver §11.3, 1.2.3-beta.1 < 1.2.3, so an advisory with fixed: "1.2.3" should still report an installed 1.2.3-beta.1 as vulnerable — the fix has not landed in that build.
Actual behavior
compareVersions("1.2.3-beta.1", "1.2.3") returns 1, and the advisory matcher reports 1.2.3-beta.1 as not vulnerable.
Reproduction
repro.ts at the repo root — it imports the real compareVersions / looksLikeVersion and inlines the exact body of versionMatchesRange (src/advisory/local-db.ts L215-233) so the matcher can be exercised without a sqlite database:
import { compareVersions, looksLikeVersion } from "./src/utils/version.js";
function versionMatchesRange(version: string, row: { introduced: string | null; fixed: string | null; last_affected: string | null }): boolean {
const introduced = row.introduced;
const fixed = row.fixed;
const lastAffected = row.last_affected;
if (introduced && introduced !== "0" && compareVersions(version, introduced) < 0) return false;
if (fixed && compareVersions(version, fixed) >= 0) return false;
if (lastAffected && compareVersions(version, lastAffected) > 0) return false;
return true;
}
console.log(looksLikeVersion("1.2.3-beta.1"), "beta".localeCompare("0"));
console.log(compareVersions("1.2.3-beta.1", "1.2.3"));
const row = { introduced: "1.0.0", fixed: "1.2.3", last_affected: null };
for (const installed of ["1.2.2", "1.2.3-beta.1", "1.2.3-rc.2", "1.2.3"]) {
console.log(installed, versionMatchesRange(installed, row));
}
Relevant files or output
looksLikeVersion('1.2.3-beta.1') = true
"beta".localeCompare("0") = 1
compareVersions('1.2.3-beta.1','1.2.3') = 1 (semver 11.3 requires < 0)
compareVersions('1.2.3','1.2.3-beta.1') = -1 (semver 11.3 requires > 0)
compareVersions('2.0.0-rc.1','2.0.0') = 1 (semver 11.3 requires < 0)
advisory { introduced: '1.0.0', fixed: '1.2.3' }
installed 1.2.2 -> reported vulnerable: true
installed 1.2.3-beta.1 -> reported vulnerable: false <-- wrong, the fix is in 1.2.3
installed 1.2.3-rc.2 -> reported vulnerable: false <-- wrong
installed 1.2.3 -> reported vulnerable: false
The same inversion reaches the online npm-advisory path at src/remediation/npm-registry.ts L404-408 (upperBoundOk), and affects compareVersions used as a sort comparator at L178 / L261-262.
Notes for whoever picks this up:
- Nothing in
tests/ pins the current behavior. The only pre-release assertion is tests/helpers.test.ts L351, compareVersions("1.2.3-beta", "1.2.3-alpha") > 0, which stays true under a semver-correct comparator.
- Fixing it is contained to
src/utils/version.ts; the two consumers become correct without edits.
I have a fix ready and will open a PR against this issue.
Summary
compareVersionsinsrc/utils/version.tsranks a pre-release above its associated release, inverting semver §11.3 ("a pre-release version has lower precedence than the associated normal version"). BecauseversionMatchesRangeinsrc/advisory/local-db.tsshort-circuits oncompareVersions(version, fixed) >= 0, an installed pre-release that sits before the fix version is silently classified as not vulnerable.That is a false negative in a vulnerability scanner, so I am reporting it rather than sitting on it.
Root cause:
parseVersionTuplesplits on/[.+-]/into one flat tuple, andcompareVersionspads the shorter tuple with the number0. A pre-release tag therefore ends up compared against0as a string, and"beta".localeCompare("0")is1.Pre-releases do reach the matcher:
findMatchingVulnerabilityIdsgates only onlooksLikeVersion, whose regex^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][^/]+)?$explicitly admits them.isPreReleaseVersionis applied only to candidate fix versions in the remediation path, never to the installed version.Environment
main@ 16d746c)Command used
Expected behavior
Per semver §11.3,
1.2.3-beta.1 < 1.2.3, so an advisory withfixed: "1.2.3"should still report an installed1.2.3-beta.1as vulnerable — the fix has not landed in that build.Actual behavior
compareVersions("1.2.3-beta.1", "1.2.3")returns1, and the advisory matcher reports1.2.3-beta.1as not vulnerable.Reproduction
repro.tsat the repo root — it imports the realcompareVersions/looksLikeVersionand inlines the exact body ofversionMatchesRange(src/advisory/local-db.ts L215-233) so the matcher can be exercised without a sqlite database:Relevant files or output
The same inversion reaches the online npm-advisory path at
src/remediation/npm-registry.tsL404-408 (upperBoundOk), and affectscompareVersionsused as a sort comparator at L178 / L261-262.Notes for whoever picks this up:
tests/pins the current behavior. The only pre-release assertion istests/helpers.test.tsL351,compareVersions("1.2.3-beta", "1.2.3-alpha") > 0, which stays true under a semver-correct comparator.src/utils/version.ts; the two consumers become correct without edits.I have a fix ready and will open a PR against this issue.