Skip to content

Commit 1108212

Browse files
committed
refactor(ci): use semver instead of a hand-rolled range matcher
Review found four more defects in the hand-rolled comparator, on top of the four in the previous round. Every one was a silent false negative in a security check: - a caret or tilde selector carrying a prerelease matched no branch and fell to the permissive fallback, so pkg@^1.2.3-alpha.1 covered every version - prerelease identifiers were compared lexicographically, so 1.0.0-alpha.10 sorted below 1.0.0-alpha.2 and <1.0.0-alpha.2 claimed to cover alpha.10 - a compound range like >=1 <2 was waved through as covering everything - partial comparator bounds and prerelease eligibility each needed their own special case, and each was wrong before it was fixed Eight defects across four rounds is the approach failing, not bad luck. This swaps the whole comparator for semver, which is what pnpm resolves with. The dependency-free constraint turned out to be self-imposed rather than a repo rule: scripts/ci already imports istanbul-lib-* for coverage. Also fixes two findings semver does not cover: - a pnpm parent selector overrides the matched parent's OWN dependency, so foo>child cannot reach a child that an intermediate package depends on. The path check now requires the parent to sit directly before the child rather than anywhere in the ancestry. - the uncovered-copy remediation text claimed the pin was patched and promised a suggested key even when none was printed, which happens for a no-fix, disjoint or exclusively bounded patched range. It is now conditional, and the no-suggestion case says to read the advisory instead. 91 tests in this file, 121 across test:scripts. Behaviour on the real data is unchanged: the uuid case is still caught with the same suggested key, and the nine real escaping pinned targets still produce zero uncovered copies.
1 parent c549817 commit 1108212

4 files changed

Lines changed: 136 additions & 189 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"prettier": "^3.9.6",
3838
"react-doctor": "^0.2.14",
3939
"secretlint": "^11.3.0",
40+
"semver": "7.8.5",
4041
"turbo": "^2.10.8"
4142
},
4243
"packageManager": "pnpm@8.15.6",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/ci/check-override-advisories.mjs

Lines changed: 73 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
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+
6769
import { existsSync, readFileSync } from 'node:fs';
6870
import { spawnSync } from 'node:child_process';
6971
import 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.
145152
export 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-9A-Za-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.
208169
export 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+|[xX*])$/.test(part))) {
282-
const want = [];
283-
for (const part of parts) {
284-
if (/^[xX*]$/.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

324181
export 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.
330188
export 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).
339222
export 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

Comments
 (0)