Summary
satisfiesRange() treats ^ as "same major, >= floor". That's right for ^1.2.0 but wrong for 0.x: npm resolves ^0.25.0 to >=0.25.0 <0.26.0, because with a zero major the minor is the breaking position. The caret branch only compares majors, so 0 === 0 matches every 0.y.z above the floor.
src/utils/version.ts:223:
if (trimmedRange.startsWith("^")) {
...
return vMajor === tMajor; // true for every 0.x
}
isBreakingUpgrade() in the same file already handles the 0.x rule (from #990). satisfiesRange() never got it. The ~ branch is fine, it compares major and minor.
Actual vs expected
satisfiesRange("0.26.0", "^0.25.0") -> true npm: false
satisfiesRange("0.99.0", "^0.25.0") -> true npm: false
satisfiesRange("0.3.0", "^0.2.3") -> true npm: false
satisfiesRange("0.0.4", "^0.0.3") -> true npm: false
satisfiesRange("0.25.1", "^0.25.0") -> true npm: true (fine)
Why it matters
satisfiesRange is the semver oracle for four OA rules, and caret pins on 0.x are common in overrides (esbuild and its @esbuild/* platform binaries, vite plugins, most of the current toolchain).
The one that bites hardest is OA010. admitted-scan.ts:73 builds the admitted set with published.filter(v => satisfiesRange(v, range)), so for "esbuild": "^0.25.0" it admits every published 0.26.x, 0.27.x, ... and then reports "this floor admits vulnerable esbuild@0.2X.Y" for versions npm can never resolve under that override. False positive on a rule whose whole job is judging a floor.
Same wrong answer reaches OA006 (oa006:144, "installed copy is consistent with the pin"), OA005 (oa005:165) and OA008 (oa008:38), in the false-negative direction.
Reproduction
import { satisfiesRange } from "./src/utils/version.js";
satisfiesRange("0.26.0", "^0.25.0"); // true, npm says false
Fix
Take the ceiling from the leftmost non-zero component of the target: major > 0 -> same major; 0.y.z with y > 0 -> same major and minor; 0.0.z -> exact match.
tests/utils/version-extensions.test.ts only covers ^1.2.0, which is why this went unnoticed. Worth a 0.x case per branch.
Environment
Node v26.7.0, macOS, npm, cve-lite-cli 1.35.0 (main @ dad43cd). Pure version logic, no lockfile involved.
Separate but adjacent: the same function's doc comment promises pre-release exclusion it doesn't implement (coerceVersion strips the tag, so satisfiesRange("1.0.0-beta", "^1.0.0") is true; npm says false). Different branch, different fix. Say the word and I'll file it on its own.
Happy to pick this up if you want a PR.
Summary
satisfiesRange()treats^as "same major,>=floor". That's right for^1.2.0but wrong for0.x: npm resolves^0.25.0to>=0.25.0 <0.26.0, because with a zero major the minor is the breaking position. The caret branch only compares majors, so0 === 0matches every0.y.zabove the floor.src/utils/version.ts:223:isBreakingUpgrade()in the same file already handles the 0.x rule (from #990).satisfiesRange()never got it. The~branch is fine, it compares major and minor.Actual vs expected
Why it matters
satisfiesRangeis the semver oracle for four OA rules, and caret pins on0.xare common in overrides (esbuild and its@esbuild/*platform binaries, vite plugins, most of the current toolchain).The one that bites hardest is OA010.
admitted-scan.ts:73builds the admitted set withpublished.filter(v => satisfiesRange(v, range)), so for"esbuild": "^0.25.0"it admits every published0.26.x,0.27.x, ... and then reports "this floor admits vulnerable esbuild@0.2X.Y" for versions npm can never resolve under that override. False positive on a rule whose whole job is judging a floor.Same wrong answer reaches OA006 (
oa006:144, "installed copy is consistent with the pin"), OA005 (oa005:165) and OA008 (oa008:38), in the false-negative direction.Reproduction
Fix
Take the ceiling from the leftmost non-zero component of the target: major > 0 -> same major;
0.y.zwith y > 0 -> same major and minor;0.0.z-> exact match.tests/utils/version-extensions.test.tsonly covers^1.2.0, which is why this went unnoticed. Worth a 0.x case per branch.Environment
Node v26.7.0, macOS, npm, cve-lite-cli 1.35.0 (
main@ dad43cd). Pure version logic, no lockfile involved.Separate but adjacent: the same function's doc comment promises pre-release exclusion it doesn't implement (
coerceVersionstrips the tag, sosatisfiesRange("1.0.0-beta", "^1.0.0")istrue; npm saysfalse). Different branch, different fix. Say the word and I'll file it on its own.Happy to pick this up if you want a PR.