6464// 1 at least one does (or the manifest/baseline could not be read)
6565// 2 advisory data was unavailable and --strict was passed
6666
67+ import semver from 'semver' ;
68+
6769import { existsSync , readFileSync } from 'node:fs' ;
6870import { spawnSync } from 'node:child_process' ;
6971import path from 'node:path' ;
@@ -137,63 +139,21 @@ export function isExactVersion(value) {
137139 return EXACT_VERSION . test ( value ) ;
138140}
139141
140- // Deliberately small: enough to order the versions that appear in override keys
141- // and in `pnpm audit` findings, and nothing more. Build metadata is ignored, as
142- // semver requires. A prerelease sorts below the release it precedes, so
143- // 3.3.18-rc.1 < 3.3.18; two prereleases fall back to a string compare of their
144- // tags, which is right for the numeric-suffix tags in practice.
142+ // Version ordering and range matching are delegated to `semver`, the same
143+ // implementation pnpm resolves with. An earlier revision hand-rolled both to
144+ // keep this file dependency-free, and review found four separate defects in it:
145+ // partial comparator bounds padded with zeros rather than expanded, prereleases
146+ // admitted into ordinary ranges, caret/tilde selectors carrying a prerelease
147+ // falling through to the permissive fallback, and prerelease identifiers
148+ // compared lexicographically so 1.0.0-alpha.10 sorted below 1.0.0-alpha.2.
149+ // Each was a silent false negative in a security check. Other scripts here
150+ // already take dependencies (scripts/ci/coverage uses istanbul-lib-*), so the
151+ // constraint was self-imposed and not worth the correctness cost.
145152export function compareVersions ( a , b ) {
146- const parse = ( value ) => {
147- const [ core , pre = '' ] = String ( value ) . split ( '+' ) [ 0 ] . split ( '-' ) ;
148- const parts = core . split ( '.' ) . map ( ( n ) => Number . parseInt ( n , 10 ) ) ;
149- return { parts, pre } ;
150- } ;
151- const left = parse ( a ) ;
152- const right = parse ( b ) ;
153-
154- for ( let i = 0 ; i < 3 ; i += 1 ) {
155- const l = Number . isFinite ( left . parts [ i ] ) ? left . parts [ i ] : 0 ;
156- const r = Number . isFinite ( right . parts [ i ] ) ? right . parts [ i ] : 0 ;
157- if ( l !== r ) return l < r ? - 1 : 1 ;
158- }
159- if ( left . pre === right . pre ) return 0 ;
160- if ( ! left . pre ) return 1 ; // a release outranks any prerelease of itself
161- if ( ! right . pre ) return - 1 ;
162- return left . pre < right . pre ? - 1 : 1 ;
163- }
164-
165- const COMPARATOR = / ^ ( < = | > = | < | > | = ) \s * v ? ( \d + (?: \. \d + ) { 0 , 2 } (?: [ - + ] [ 0 - 9 A - Z a - z . - ] + ) ? ) $ / ;
166- const CARET_TILDE = / ^ ( [ \^ ~ ] ) \s * v ? ( \d + ) (?: \. ( \d + ) ) ? (?: \. ( \d + ) ) ? $ / ;
167- const VERSION_PREFIX = / ^ v ? ( \d + ) (?: \. ( \d + ) ) ? (?: \. ( \d + ) ) ? / ;
168-
169- // The exclusive upper bound of a ^ or ~ range, following npm's rules: ~ allows
170- // patch drift, ^ allows changes that do not modify the left-most non-zero
171- // element. Worth supporting rather than leaving to the catch-all, because these
172- // are the two commonest range operators and the catch-all direction is a false
173- // negative: a `^8.0.0` key would silently be treated as covering a 7.x copy.
174- // Expand a possibly-partial bound to a full version. `next` gives the exclusive
175- // end of the line the bound names: '1' -> 2.0.0, '1.2' -> 1.3.0, and a complete
176- // version is already a point so it is returned unchanged.
177- function padVersion ( bound , next ) {
178- const core = bound . split ( '+' ) [ 0 ] . split ( '-' ) [ 0 ] ;
179- const suffix = bound . slice ( core . length ) ;
180- const parts = core . split ( '.' ) . map ( ( n ) => Number . parseInt ( n , 10 ) ) ;
181- const [ major = 0 , minor = 0 , patch = 0 ] = parts ;
182- if ( ! next ) return `${ major } .${ minor } .${ patch } ${ parts . length === 3 ? suffix : '' } ` ;
183- if ( parts . length === 1 ) return `${ major + 1 } .0.0` ;
184- if ( parts . length === 2 ) return `${ major } .${ minor + 1 } .0` ;
185- return `${ major } .${ minor } .${ patch } ${ suffix } ` ;
186- }
187-
188- function caretTildeUpperBound ( operator , major , minor , patch , minorGiven , patchGiven ) {
189- if ( operator === '~' ) {
190- return minorGiven ? `${ major } .${ minor + 1 } .0` : `${ major + 1 } .0.0` ;
191- }
192- if ( major !== 0 ) return `${ major + 1 } .0.0` ;
193- if ( ! minorGiven ) return '1.0.0' ;
194- if ( minor !== 0 ) return `0.${ minor + 1 } .0` ;
195- if ( ! patchGiven ) return '0.1.0' ;
196- return `0.0.${ patch + 1 } ` ;
153+ const left = semver . coerce ( a , { includePrerelease : true } ) ? String ( a ) : null ;
154+ const right = semver . coerce ( b , { includePrerelease : true } ) ? String ( b ) : null ;
155+ if ( ! left || ! right || ! semver . valid ( left ) || ! semver . valid ( right ) ) return 0 ;
156+ return semver . compare ( left , right ) ;
197157}
198158
199159// Does an override key's selector actually match this installed version?
@@ -202,138 +162,61 @@ function caretTildeUpperBound(operator, major, minor, patch, minorGiven, patchGi
202162// version and nothing else, so it can never apply to an installed 8.3.2, which
203163// is precisely how three vulnerable uuid copies stayed put behind a patched pin.
204164//
205- // A null selector is a blanket override and covers everything. Anything this
206- // does not recognise returns true, so an unusual key produces silence rather
207- // than a false alarm; the stale-pin check still covers that key on its own.
165+ // A null selector is a blanket override and covers everything. A selector
166+ // semver cannot parse as a range returns true, so an unusual key produces
167+ // silence rather than a false alarm; the stale-pin check still covers that key
168+ // on its own.
208169export function selectorCovers ( selector , version ) {
209170 if ( ! selector ) return true ;
210171 const trimmed = selector . trim ( ) ;
211-
212- // semver keeps a prerelease out of every ordinary range, not just the bare
213- // ones: `<2.0.0` does not admit 1.2.3-alpha.1 either. Checked once here so
214- // the comparator and caret/tilde branches cannot return true past it. A
215- // selector that names a prerelease itself is the exception and is handled by
216- // the exact-point branch below.
217- const versionIsPrerelease = / - / . test ( String ( version ) . split ( '+' ) [ 0 ] ) ;
218- const selectorNamesPrerelease = / - / . test ( trimmed . split ( '+' ) [ 0 ] ) ;
219- if ( versionIsPrerelease && ! selectorNamesPrerelease ) return false ;
220-
221- const comparator = COMPARATOR . exec ( trimmed ) ;
222- if ( comparator ) {
223- const [ , operator , bound ] = comparator ;
224- // A partial bound is a whole line, not a zero-padded point. semver reads
225- // '>1' as "everything from 2.0.0" and '<=1.2' as "all of 1.2.x", so padding
226- // with zeros would call 1.5.0 covered by '>1' and 1.2.5 uncovered by '<=1.2'
227- // - wrong in both directions.
228- const given = bound . split ( '+' ) [ 0 ] . split ( '-' ) [ 0 ] . split ( '.' ) . length ;
229- const lower = padVersion ( bound , false ) ;
230- const upper = padVersion ( bound , true ) ; // exclusive end of the named line
231- if ( operator === '<' ) return compareVersions ( version , lower ) < 0 ;
232- if ( operator === '>=' ) return compareVersions ( version , lower ) >= 0 ;
233- if ( operator === '<=' ) {
234- return given === 3
235- ? compareVersions ( version , lower ) <= 0
236- : compareVersions ( version , upper ) < 0 ;
237- }
238- if ( operator === '>' ) {
239- return given === 3
240- ? compareVersions ( version , lower ) > 0
241- : compareVersions ( version , upper ) >= 0 ;
242- }
243- // '=' is a point on a complete bound, and the whole line on a partial one
244- // ('=1.2' means all of 1.2.x), same as a bare prefix.
245- if ( given === 3 ) return compareVersions ( version , lower ) === 0 ;
246- return compareVersions ( version , lower ) >= 0 && compareVersions ( version , upper ) < 0 ;
247- }
248-
249- const caretTilde = CARET_TILDE . exec ( trimmed ) ;
250- if ( caretTilde ) {
251- const [ , operator , majorRaw , minorRaw , patchRaw ] = caretTilde ;
252- const major = Number ( majorRaw ) ;
253- const minor = minorRaw === undefined ? 0 : Number ( minorRaw ) ;
254- const patch = patchRaw === undefined ? 0 : Number ( patchRaw ) ;
255- const lower = `${ major } .${ minor } .${ patch } ` ;
256- const upper = caretTildeUpperBound (
257- operator ,
258- major ,
259- minor ,
260- patch ,
261- minorRaw !== undefined ,
262- patchRaw !== undefined
263- ) ;
264- return compareVersions ( version , lower ) >= 0 && compareVersions ( version , upper ) < 0 ;
265- }
266-
267- // A bare `3` or `3.3` is a prefix selector covering that whole line, and so is
268- // the x-range spelling of the same thing: `3.x`, `3.*`, `3.x.x`. Both forms
269- // have to be understood, because `uuid@8` and `uuid@8.x` express one intent
270- // and letting the second fall through to the catch-all would reinstate the
271- // exact blind spot this check was added to close.
272- // An exact selector carrying a prerelease tag is a point, and has to be
273- // compared as one. It matches none of the branches above and its tag is not a
274- // plain numeric part, so without this it would reach the permissive fallback
275- // and be read as covering every version in the tree.
276- if ( isExactVersion ( trimmed . replace ( / ^ v / , '' ) ) ) {
277- return compareVersions ( version , trimmed . replace ( / ^ v / , '' ) ) === 0 ;
278- }
279-
280- const parts = trimmed . replace ( / ^ v / , '' ) . split ( '.' ) ;
281- if ( parts . length <= 3 && parts . every ( ( part ) => / ^ (?: \d + | [ x X * ] ) $ / . test ( part ) ) ) {
282- const want = [ ] ;
283- for ( const part of parts ) {
284- if ( / ^ [ x X * ] $ / . test ( part ) ) break ; // everything from here on is a wildcard
285- want . push ( part ) ;
286- }
287- const got = VERSION_PREFIX . exec ( version ) ?. slice ( 1 ) ?? [ ] ;
288- return want . every ( ( part , i ) => part === got [ i ] ) ;
289- }
290-
291- return true ;
292- }
293-
294- // `paths` is the advisory's dependency paths for this specific version, e.g.
295- // 'apps/backend > react-native > jest-environment-node@1.2.3'.
296- //
297- // A parent-scoped key only rewrites the copy reached through that parent, so it
298- // cannot be credited with covering a copy that arrives some other way. Without
299- // the path check, 'react-native>jest-environment-node' (which has no blanket
300- // sibling here) would be read as covering every jest-environment-node in the
301- // tree. When no path information is available the parent cannot be disproved,
302- // so the key is credited - silence rather than a guess.
303- // True when this key applies to ONE dependency path of the version. The
304- // quantifiers matter and are easy to get backwards: coverage of a version is
305- // "for every path, SOME key covers that path", so the per-path question asked
306- // here has to stay per-path. Folding `every` in here instead made two keys like
307- // `foo>child` and `bar>child` each fail on the other's path, reporting an
308- // uncovered copy that the pair actually covers between them.
309- export function overrideKeyCoversPath ( key , version , path ) {
310- const { selector, parent } = splitOverrideKey ( key ) ;
311- if ( ! selectorCovers ( selector , version ) ) return false ;
312- if ( ! parent ) return true ;
313- if ( ! path ) return true ;
314- // Compare package identities, not substrings: `foo` must not match the
315- // `foobar` segment of 'app > foobar > child@1.0.0', which would credit an
316- // override pnpm cannot apply and swallow the finding.
317- const parentName = splitOverrideKey ( parent ) . name ;
318- return String ( path )
319- . split ( '>' )
320- . map ( ( segment ) => segment . trim ( ) )
321- . some ( ( segment ) => segment === parentName || segment . startsWith ( `${ parentName } @` ) ) ;
172+ if ( ! semver . valid ( version ) ) return true ;
173+ if ( ! semver . validRange ( trimmed ) ) return true ;
174+ // includePrerelease is deliberately NOT set: semver keeps a prerelease out of
175+ // an ordinary range, and so does pnpm, so `pkg@<2.0.0` genuinely would not be
176+ // applied to 1.2.3-alpha.1. A selector naming a prerelease still matches its
177+ // own, which is what `semver.satisfies` does by default.
178+ return semver . satisfies ( version , trimmed ) ;
322179}
323180
324181export function overrideKeyCovers ( key , version , paths = null ) {
325182 if ( ! paths || paths . length === 0 ) return overrideKeyCoversPath ( key , version , null ) ;
326183 return paths . every ( ( path ) => overrideKeyCoversPath ( key , version , path ) ) ;
327184}
328185
329- // The version-level question, asked with the quantifiers the right way round.
186+ // The version-level question, asked with the quantifiers the right way round:
187+ // a version is covered when EVERY path it arrives by is covered by SOME key.
330188export function versionIsCovered ( pins , version , paths ) {
331189 if ( ! paths || paths . length === 0 ) {
332190 return pins . some ( ( pin ) => overrideKeyCoversPath ( pin . key , version , null ) ) ;
333191 }
334192 return paths . every ( ( path ) => pins . some ( ( pin ) => overrideKeyCoversPath ( pin . key , version , path ) ) ) ;
335193}
336194
195+ export function overrideKeyCoversPath ( key , version , path ) {
196+ const { name, selector, parent } = splitOverrideKey ( key ) ;
197+ if ( ! selectorCovers ( selector , version ) ) return false ;
198+ if ( ! parent ) return true ;
199+ if ( ! path ) return true ;
200+
201+ // A pnpm parent selector overrides the matched parent's OWN dependency, so
202+ // `foo>child` cannot reach a child that some intermediate package depends on:
203+ // in 'app > foo > intermediate > child@1.0.0' the direct parent is
204+ // `intermediate`, not `foo`. Requiring adjacency keeps a patched pin from
205+ // suppressing a copy the override never rewrites.
206+ //
207+ // Segments are compared as package identities rather than substrings, so
208+ // `foo` does not match a `foobar` segment.
209+ const parentName = splitOverrideKey ( parent ) . name ;
210+ const segments = String ( path )
211+ . split ( '>' )
212+ . map ( ( segment ) => segment . trim ( ) ) ;
213+ const isPkg = ( segment , pkg ) => segment === pkg || segment . startsWith ( `${ pkg } @` ) ;
214+
215+ return segments . some (
216+ ( segment , i ) => isPkg ( segment , parentName ) && isPkg ( segments [ i + 1 ] ?? '' , name )
217+ ) ;
218+ }
219+
337220// package name -> [{ key, pinned }], because a single package is routinely
338221// overridden more than once (a blanket entry plus selector-scoped entries).
339222export function indexOverrides ( overrides ) {
@@ -770,12 +653,27 @@ export function main(argv, { readAudit = readAuditJson } = {}) {
770653 ) ;
771654 }
772655 if ( unacceptedUncovered . length > 0 ) {
656+ const withSuggestion = unacceptedUncovered . filter ( ( finding ) => finding . suggestedKey ) ;
773657 console . error (
774- 'For an uncovered copy, the pinned value is already patched but the override KEY is too ' +
775- 'narrow to select the versions listed under "NOT covered", so the override never applies ' +
776- 'to them. Replace the exact-version keys with a range key, then re-run pnpm install. ' +
777- 'The suggested key is printed with each finding.'
658+ 'For an uncovered copy, the override KEY is too narrow to select the versions listed under ' +
659+ '"NOT covered", so the override never applies to them.'
778660 ) ;
661+ if ( withSuggestion . length > 0 ) {
662+ console . error (
663+ 'Where a "suggested key" is printed, the pinned value is already patched: replace the ' +
664+ 'narrow keys with that range key and re-run pnpm install.'
665+ ) ;
666+ }
667+ // Without a suggestion there is no release to pin to, so telling anyone to
668+ // paste a range key would be advice that cannot be followed - and the pin
669+ // itself may still be vulnerable.
670+ if ( withSuggestion . length < unacceptedUncovered . length ) {
671+ console . error (
672+ 'Where none is printed, the advisory publishes no single patched release to pin to ' +
673+ '(no fix, a disjoint range, or an exclusive bound). Read the advisory and either raise ' +
674+ 'the dependency that pulls the copy in, drop it, or record it in the baseline.'
675+ ) ;
676+ }
779677 }
780678 console . error (
781679 'If a fix is genuinely blocked, record it in ' +
0 commit comments