|
46 | 46 | // any pin stale" misses that entirely; the second question is |
47 | 47 | // "does any pin fail to COVER a vulnerable copy". |
48 | 48 | // |
49 | | -// Deciding the second question needs the override key compared as a semver |
50 | | -// range, so this file carries a small comparator rather than taking a |
51 | | -// dependency. It handles the selector shapes the override block uses today plus |
52 | | -// the two commonest operators it does not: no selector, a bare major, a |
53 | | -// major.minor, an exact version, the < <= > >= = comparators, and ^ / ~ ranges. |
54 | | -// An unrecognised selector (a compound range, an x-range, a workspace protocol) |
55 | | -// is treated as covering the version, which errs towards silence rather than a |
56 | | -// false alarm; the stale-pin check still watches that key on its own. |
| 49 | +// Deciding the second question means comparing the override key as a semver |
| 50 | +// range, which is delegated to `semver` - the same implementation pnpm resolves |
| 51 | +// with. An earlier revision hand-rolled it to keep this file dependency-free |
| 52 | +// and review found eight defects in that comparator, every one a silent false |
| 53 | +// negative, so the constraint was dropped; other scripts here already take |
| 54 | +// dependencies. Every range form semver understands is therefore evaluated, |
| 55 | +// including compound ranges, x-ranges, caret and tilde, and prereleases. |
| 56 | +// |
| 57 | +// A selector semver cannot parse as a range at all - a workspace protocol, an |
| 58 | +// npm alias - is treated as covering the version, which errs towards silence |
| 59 | +// rather than a false alarm; the stale-pin check still watches that key. |
57 | 60 | // |
58 | 61 | // Known limitation: an override pinning a package that nothing actually resolves |
59 | 62 | // to is invisible here, because it never appears in the audited tree. Such an |
@@ -102,23 +105,22 @@ function fail(message) { |
102 | 105 | export function splitOverrideKey(key) { |
103 | 106 | const trimmed = key.trim(); |
104 | 107 |
|
105 | | - // Scan right to left for the parent>child separator. A '>' can also be a range |
106 | | - // operator, and telling them apart needs more than the next character: the |
107 | | - // supported spellings include '@>=5.0.0', '@>1.2.3', '@>v1.2.3' and '@> 1.2.3'. |
108 | | - // A '>' preceded by '@' is always an operator, never a separator - reading |
109 | | - // 'pkg@>v1.2.3' as parent>child would index the entry under 'v1.2.3' and skip |
110 | | - // every advisory for pkg. |
| 108 | + // Scan right to left for the parent>child separator. A '>' can also be a |
| 109 | + // range operator, and the two are told apart by the character BEFORE it, not |
| 110 | + // after: an operator '>' always follows '@', '<', '>' or '='. Keying off the |
| 111 | + // character after instead would reject a perfectly valid child whose name |
| 112 | + // starts with a digit, such as 'foo>2fa', and index the whole string as a |
| 113 | + // package name so every advisory for that child is skipped. |
111 | 114 | let child = trimmed; |
112 | 115 | let parent = null; |
113 | 116 | for (let i = trimmed.length - 1; i >= 0; i -= 1) { |
114 | 117 | if (trimmed[i] !== '>') continue; |
115 | | - if (trimmed[i - 1] === '@' || trimmed[i - 1] === '<' || trimmed[i - 1] === '>') continue; |
116 | | - const next = trimmed[i + 1]; |
117 | | - if (next && !/[=\d]/.test(next)) { |
118 | | - child = trimmed.slice(i + 1); |
119 | | - parent = trimmed.slice(0, i); |
120 | | - break; |
121 | | - } |
| 118 | + const prev = trimmed[i - 1]; |
| 119 | + if (prev === '@' || prev === '<' || prev === '>' || prev === '=') continue; |
| 120 | + if (i + 1 >= trimmed.length) continue; |
| 121 | + child = trimmed.slice(i + 1); |
| 122 | + parent = trimmed.slice(0, i); |
| 123 | + break; |
122 | 124 | } |
123 | 125 |
|
124 | 126 | // On a scoped name the leading '@' is part of the name, so the selector |
@@ -208,15 +210,25 @@ export function overrideKeyCoversPath(key, version, path) { |
208 | 210 | // |
209 | 211 | // Segments are compared as package identities rather than substrings, so |
210 | 212 | // `foo` does not match a `foobar` segment. |
211 | | - const parentName = splitOverrideKey(parent).name; |
| 213 | + const { name: parentName, selector: parentSelector } = splitOverrideKey(parent); |
212 | 214 | const segments = String(path) |
213 | 215 | .split('>') |
214 | 216 | .map((segment) => segment.trim()); |
215 | | - const isPkg = (segment, pkg) => segment === pkg || segment.startsWith(`${pkg}@`); |
216 | 217 |
|
217 | | - return segments.some( |
218 | | - (segment, i) => isPkg(segment, parentName) && isPkg(segments[i + 1] ?? '', name) |
219 | | - ); |
| 218 | + // A version-scoped parent key only applies when the parent's own version |
| 219 | + // satisfies that selector, so `foo@1>child` must not be credited for a path |
| 220 | + // through foo@2.0.0. Where the path carries no version for the segment there |
| 221 | + // is nothing to disprove, so the selector passes. |
| 222 | + const isParent = (segment) => { |
| 223 | + if (segment !== parentName && !segment.startsWith(`${parentName}@`)) return false; |
| 224 | + if (!parentSelector) return true; |
| 225 | + const at = segment.indexOf('@', segment.startsWith('@') ? 1 : 0); |
| 226 | + if (at === -1) return true; |
| 227 | + return selectorCovers(parentSelector, segment.slice(at + 1)); |
| 228 | + }; |
| 229 | + const isChild = (segment) => segment === name || segment.startsWith(`${name}@`); |
| 230 | + |
| 231 | + return segments.some((segment, i) => isParent(segment) && isChild(segments[i + 1] ?? '')); |
220 | 232 | } |
221 | 233 |
|
222 | 234 | // package name -> [{ key, pinned }], because a single package is routinely |
|
0 commit comments