diff --git a/package.json b/package.json index a17673404..97bd5575f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "prettier": "^3.9.6", "react-doctor": "^0.2.14", "secretlint": "^11.3.0", + "semver": "7.8.5", "turbo": "^2.10.9" }, "packageManager": "pnpm@8.15.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e844197a..838affb9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: secretlint: specifier: ^11.3.0 version: 11.3.1 + semver: + specifier: 7.8.5 + version: 7.8.5 turbo: specifier: ^2.10.9 version: 2.10.9 diff --git a/scripts/ci/check-override-advisories.mjs b/scripts/ci/check-override-advisories.mjs index c97236c09..c6e2ef830 100644 --- a/scripts/ci/check-override-advisories.mjs +++ b/scripts/ci/check-override-advisories.mjs @@ -26,20 +26,49 @@ // `pnpm audit` reports advisories against the *resolved* tree, which is exactly // the tree the overrides produced. So an advisory whose installed version equals // an override's pinned value is, by construction, an override pinning a -// vulnerable version. Matching on the exact installed version rather than on -// semver ranges keeps the check precise and dependency-free: `uuid` has an -// advisory for the 7.x/8.x copies in the tree, but the two `uuid` overrides pin -// 11.1.1, so they are correctly left alone. +// vulnerable version. +// +// There are two ways an override block can leave an advisory open, and this +// script reports both: +// +// stale-pin The pin itself is vulnerable. `fast-uri` was raised +// 3.1.2 -> 3.1.3 to clear the advisory of the day; 3.1.4 +// superseded it and the override held 3.1.3 in place. +// +// uncovered-copy The pin is patched, but the override *key* is too narrow to +// match the copies that are actually vulnerable, so the +// override never applies to them. `uuid` was pinned to a +// patched 11.1.1 under the exact keys `uuid@11.1.0` and +// `uuid@9.0.1`, while the vulnerable copies resolved at 7.0.3, +// 8.0.0 and 8.3.2 via xcode, aws-sdk and sockjs. Neither key +// matches anything in the 7.x or 8.x lines, so six high alerts +// sat open while this check stayed silent. Answering only "is +// any pin stale" misses that entirely; the second question is +// "does any pin fail to COVER a vulnerable copy". +// +// Deciding the second question means comparing the override key as a semver +// range, which is delegated to `semver` - the same implementation pnpm resolves +// with. An earlier revision hand-rolled it to keep this file dependency-free +// and review found eight defects in that comparator, every one a silent false +// negative, so the constraint was dropped; other scripts here already take +// dependencies. Every range form semver understands is therefore evaluated, +// including compound ranges, x-ranges, caret and tilde, and prereleases. +// +// A selector semver cannot parse as a range at all - a workspace protocol, an +// npm alias - is treated as covering the version, which errs towards silence +// rather than a false alarm; the stale-pin check still watches that key. // // Known limitation: an override pinning a package that nothing actually resolves // to is invisible here, because it never appears in the audited tree. Such an // override is also inert, so it carries no runtime risk. // // Exit codes: -// 0 no un-accepted override pins a vulnerable version +// 0 no un-accepted override pins or fails to cover a vulnerable version // 1 at least one does (or the manifest/baseline could not be read) // 2 advisory data was unavailable and --strict was passed +import semver from 'semver'; + import { existsSync, readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; @@ -73,26 +102,37 @@ function fail(message) { // '@aws-cdk/toolkit-lib>yaml' -> yaml (parent>child form) // 'brace-expansion@>=5.0.0' -> brace-expansion (the '>' is a range operator, // not a parent separator) -export function parseOverrideKey(key) { +export function splitOverrideKey(key) { const trimmed = key.trim(); - // Scan right to left for the parent>child separator. A '>' belonging to a - // range selector ('@>=5.0.0', '@>1.2.3') is followed by '=' or a digit; a real - // separator is followed by the first character of a package name. + // Scan right to left for the parent>child separator. A '>' can also be a + // range operator, and the two are told apart by the character BEFORE it, not + // after: an operator '>' always follows '@', '<', '>' or '='. Keying off the + // character after instead would reject a perfectly valid child whose name + // starts with a digit, such as 'foo>2fa', and index the whole string as a + // package name so every advisory for that child is skipped. let child = trimmed; + let parent = null; for (let i = trimmed.length - 1; i >= 0; i -= 1) { - const next = trimmed[i + 1]; - if (trimmed[i] === '>' && next && !/[=\d]/.test(next)) { - child = trimmed.slice(i + 1); - break; - } + if (trimmed[i] !== '>') continue; + const prev = trimmed[i - 1]; + if (prev === '@' || prev === '<' || prev === '>' || prev === '=') continue; + if (i + 1 >= trimmed.length) continue; + child = trimmed.slice(i + 1); + parent = trimmed.slice(0, i); + break; } // On a scoped name the leading '@' is part of the name, so the selector // separator is the *next* '@'. const searchFrom = child.startsWith('@') ? 1 : 0; const at = child.indexOf('@', searchFrom); - return at === -1 ? child : child.slice(0, at); + if (at === -1) return { name: child, selector: null, parent }; + return { name: child.slice(0, at), selector: child.slice(at + 1) || null, parent }; +} + +export function parseOverrideKey(key) { + return splitOverrideKey(key).name; } const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; @@ -101,6 +141,96 @@ export function isExactVersion(value) { return EXACT_VERSION.test(value); } +// Version ordering and range matching are delegated to `semver`, the same +// implementation pnpm resolves with. An earlier revision hand-rolled both to +// keep this file dependency-free, and review found four separate defects in it: +// partial comparator bounds padded with zeros rather than expanded, prereleases +// admitted into ordinary ranges, caret/tilde selectors carrying a prerelease +// falling through to the permissive fallback, and prerelease identifiers +// compared lexicographically so 1.0.0-alpha.10 sorted below 1.0.0-alpha.2. +// Each was a silent false negative in a security check. Other scripts here +// already take dependencies (scripts/ci/coverage uses istanbul-lib-*), so the +// constraint was self-imposed and not worth the correctness cost. +export function compareVersions(a, b) { + const left = String(a); + const right = String(b); + // Unorderable input sorts equal rather than throwing: callers compare audit + // findings, and one malformed version should not abort the whole check. + if (!semver.valid(left) || !semver.valid(right)) return 0; + return semver.compare(left, right); +} + +// Does an override key's selector actually match this installed version? +// +// This is the question the stale-pin check never asks. `uuid@9.0.1` selects one +// version and nothing else, so it can never apply to an installed 8.3.2, which +// is precisely how three vulnerable uuid copies stayed put behind a patched pin. +// +// A null selector is a blanket override and covers everything. A selector +// semver cannot parse as a range returns true, so an unusual key produces +// silence rather than a false alarm; the stale-pin check still covers that key +// on its own. +export function selectorCovers(selector, version) { + if (!selector) return true; + const trimmed = selector.trim(); + if (!semver.valid(version)) return true; + if (!semver.validRange(trimmed)) return true; + // includePrerelease is deliberately NOT set: semver keeps a prerelease out of + // an ordinary range, and so does pnpm, so `pkg@<2.0.0` genuinely would not be + // applied to 1.2.3-alpha.1. A selector naming a prerelease still matches its + // own, which is what `semver.satisfies` does by default. + return semver.satisfies(version, trimmed); +} + +export function overrideKeyCovers(key, version, paths = null) { + if (!paths || paths.length === 0) return overrideKeyCoversPath(key, version, null); + return paths.every((path) => overrideKeyCoversPath(key, version, path)); +} + +// The version-level question, asked with the quantifiers the right way round: +// a version is covered when EVERY path it arrives by is covered by SOME key. +export function versionIsCovered(pins, version, paths) { + if (!paths || paths.length === 0) { + return pins.some((pin) => overrideKeyCoversPath(pin.key, version, null)); + } + return paths.every((path) => pins.some((pin) => overrideKeyCoversPath(pin.key, version, path))); +} + +export function overrideKeyCoversPath(key, version, path) { + const { name, selector, parent } = splitOverrideKey(key); + if (!selectorCovers(selector, version)) return false; + if (!parent) return true; + if (!path) return true; + + // A pnpm parent selector overrides the matched parent's OWN dependency, so + // `foo>child` cannot reach a child that some intermediate package depends on: + // in 'app > foo > intermediate > child@1.0.0' the direct parent is + // `intermediate`, not `foo`. Requiring adjacency keeps a patched pin from + // suppressing a copy the override never rewrites. + // + // Segments are compared as package identities rather than substrings, so + // `foo` does not match a `foobar` segment. + const { name: parentName, selector: parentSelector } = splitOverrideKey(parent); + const segments = String(path) + .split('>') + .map((segment) => segment.trim()); + + // A version-scoped parent key only applies when the parent's own version + // satisfies that selector, so `foo@1>child` must not be credited for a path + // through foo@2.0.0. Where the path carries no version for the segment there + // is nothing to disprove, so the selector passes. + const isParent = (segment) => { + if (segment !== parentName && !segment.startsWith(`${parentName}@`)) return false; + if (!parentSelector) return true; + const at = segment.indexOf('@', segment.startsWith('@') ? 1 : 0); + if (at === -1) return true; + return selectorCovers(parentSelector, segment.slice(at + 1)); + }; + const isChild = (segment) => segment === name || segment.startsWith(`${name}@`); + + return segments.some((segment, i) => isParent(segment) && isChild(segments[i + 1] ?? '')); +} + // package name -> [{ key, pinned }], because a single package is routinely // overridden more than once (a blanket entry plus selector-scoped entries). export function indexOverrides(overrides) { @@ -128,10 +258,36 @@ export function advisoryId(advisory) { return advisory.github_advisory_id || (advisory.id != null ? String(advisory.id) : 'unknown'); } +// An uncovered-copy finding is not identified by a pinned version - the pin is +// fine, the key is not - so it is keyed on the versions left uncovered. Stale-pin +// entries keep their original shape so existing baseline records still match. export function baselineKey(finding) { + if (finding.kind === 'uncovered-copy') { + // Sorted, because the versions arrive in whatever order pnpm audit listed + // its findings. An unsorted join would make an accepted entry go stale the + // day that order changes, failing CI on an advisory nobody touched. + const versions = [...finding.uncovered].sort((a, b) => a.localeCompare(b)); + return `${finding.package}@uncovered:${versions.join(',')}:${finding.advisory}`; + } return `${finding.package}@${finding.pinned}:${finding.advisory}`; } +// The key a human should paste into package.json to actually cover the copies +// that are currently escaping. `=2.0.5' has no single such bound - suggesting "<2.0.0": "2.0.0" +// there would both miss a vulnerable 2.0.3 and pin copies to a version outside +// the patched set. Better to print no suggestion and let the advisory be read. +const SIMPLE_PATCHED_RANGE = /^\s*>=\s*v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\s*$/; + +export function suggestRangeKey(packageName, fixedIn, patchedVersions = '') { + if (!fixedIn) return null; + if (patchedVersions && !SIMPLE_PATCHED_RANGE.test(patchedVersions)) return null; + return `"${packageName}@<${fixedIn}": "${fixedIn}"`; +} + // Cross-reference the overrides against the audited tree. export function findVulnerablePins(overrides, audit) { const index = indexOverrides(overrides); @@ -143,6 +299,51 @@ export function findVulnerablePins(overrides, audit) { const installed = [...new Set((advisory.findings ?? []).map((f) => f.version).filter(Boolean))]; + // Second class of finding: the pins may all be patched, yet none of the + // override KEYS selects the copies that are actually vulnerable, so the + // override never applies to them. Reported once per advisory rather than + // once per pin, because it is the key set as a whole that fell short. + // + // A version equal to one of the pinned values is excluded first. An override + // rewrites resolution TO its pinned value, so that version being installed + // is proof the override applied - and an exact-version key never selects its + // own target ('vite@7.3.3': '7.3.5' installs 7.3.5, which 'vite@7.3.3' does + // not match). Without this, every ordinary stale pin would also be reported + // as an uncovered copy, with remediation prose contradicting the stale-pin + // finding printed beside it. That case is already covered, correctly, by the + // stale-pin check below. + const pinnedValues = new Set(pins.map((pin) => pin.pinned)); + const pathsByVersion = new Map(); + for (const entry of advisory.findings ?? []) { + if (!entry?.version) continue; + const seen = pathsByVersion.get(entry.version) ?? []; + pathsByVersion.set(entry.version, seen.concat(entry.paths ?? [])); + } + const uncovered = installed.filter( + (version) => + !pinnedValues.has(version) && !versionIsCovered(pins, version, pathsByVersion.get(version)) + ); + if (uncovered.length > 0) { + const fixedIn = fixedVersionFrom(advisory.patched_versions); + findings.push({ + kind: 'uncovered-copy', + package: advisory.module_name, + overrideKey: pins.map((pin) => pin.key).join(', '), + pinned: [...new Set(pins.map((pin) => pin.pinned))].join(', '), + pinIsRange: false, + installed, + uncovered, + suggestedKey: suggestRangeKey(advisory.module_name, fixedIn, advisory.patched_versions), + advisory: advisoryId(advisory), + severity: advisory.severity ?? 'unknown', + title: advisory.title ?? '', + vulnerableVersions: advisory.vulnerable_versions ?? 'unknown', + patchedVersions: advisory.patched_versions ?? '', + fixedIn, + url: advisory.url ?? '', + }); + } + for (const pin of pins) { const exact = isExactVersion(pin.pinned); // An exact pin is vulnerable when the tree actually resolved to it. A @@ -152,6 +353,7 @@ export function findVulnerablePins(overrides, audit) { if (!hit) continue; findings.push({ + kind: 'stale-pin', package: advisory.module_name, overrideKey: pin.key, pinned: pin.pinned, @@ -181,10 +383,21 @@ export function findVulnerablePins(overrides, audit) { // ones it does not. A baseline entry is keyed on package + pinned version + // advisory id, so it expires by construction: bump the override, or let a new // advisory land against the same pin, and the entry stops matching. +// +// An uncovered-copy entry is keyed on the uncovered versions instead, and +// expires the same way: cover one of them, or let a new copy appear, and the +// entry stops matching. Entries are run through the same baselineKey() the +// findings use, so the two can never drift apart. export function applyBaseline(findings, baseline) { const accepted = new Map( (baseline?.accepted ?? []).map((entry) => [ - `${entry.package}@${entry.pinned}:${entry.advisory}`, + baselineKey({ + kind: entry.kind ?? 'stale-pin', + package: entry.package, + pinned: entry.pinned, + uncovered: entry.uncovered ?? [], + advisory: entry.advisory, + }), entry, ]) ); @@ -210,13 +423,20 @@ export function applyBaseline(findings, baseline) { } export function formatFinding(finding) { + const uncoveredCopy = finding.kind === 'uncovered-copy'; const lines = [ - ` ${finding.package} [${finding.severity}]`, - ` override key: ${JSON.stringify(finding.overrideKey)}`, + ` ${finding.package} [${finding.severity}]` + + (uncoveredCopy ? ' - vulnerable copies no override key covers' : ''), + uncoveredCopy + ? ` override keys: ${JSON.stringify(finding.overrideKey)}` + : ` override key: ${JSON.stringify(finding.overrideKey)}`, ` pinned at: ${finding.pinned}${finding.pinIsRange ? ' (range, not an exact pin)' : ''}`, ` installed: ${finding.installed.join(', ') || 'unknown'}`, - ` advisory: ${finding.advisory}`, ]; + if (uncoveredCopy) { + lines.push(` NOT covered: ${finding.uncovered.join(', ')}`); + } + lines.push(` advisory: ${finding.advisory}`); if (finding.title) lines.push(` title: ${finding.title}`); lines.push(` vulnerable: ${finding.vulnerableVersions}`); lines.push( @@ -224,6 +444,9 @@ export function formatFinding(finding) { ? ` fixed in: ${finding.fixedIn} (patched range ${finding.patchedVersions})` : ` fixed in: no patched version published (${finding.patchedVersions || 'none'})` ); + if (uncoveredCopy && finding.suggestedKey) { + lines.push(` suggested key: ${finding.suggestedKey}`); + } if (finding.url) lines.push(` url: ${finding.url}`); return lines.join('\n'); } @@ -373,9 +596,13 @@ export function main(argv, { readAudit = readAuditJson } = {}) { args.useBaseline && existsSync(args.baseline) ? readJson(args.baseline, 'baseline') : null; const { unaccepted, known, stale } = applyBaseline(findings, baseline); + const stalePins = findings.filter((finding) => finding.kind !== 'uncovered-copy'); + const uncoveredCopies = findings.filter((finding) => finding.kind === 'uncovered-copy'); + console.log(`check-override-advisories: ${overrideCount} override entries checked`); console.log(` advisories in tree: ${Object.keys(audit.advisories ?? {}).length}`); - console.log(` vulnerable pins: ${findings.length}`); + console.log(` vulnerable pins: ${stalePins.length}`); + console.log(` uncovered copies: ${uncoveredCopies.length}`); if (baseline) console.log(` accepted (baseline): ${known.length}`); for (const entry of stale) { @@ -397,25 +624,73 @@ export function main(argv, { readAudit = readAuditJson } = {}) { } if (unaccepted.length === 0) { + // Deliberately phrased as "nothing unreviewed" rather than "everything is + // patched". Baselined findings are still live vulnerabilities, and claiming + // they are patched would contradict the accepted-drift block printed just + // above it. console.log( - '\ncheck-override-advisories: OK - no unreviewed override pins a vulnerable version' + known.length > 0 + ? `\ncheck-override-advisories: OK - no unreviewed findings (${known.length} accepted in the baseline, still vulnerable)` + : '\ncheck-override-advisories: OK - every override pins a patched version and covers ' + + 'every vulnerable copy' ); return 0; } - console.error( - `\ncheck-override-advisories: ${unaccepted.length} override entr` + - `${unaccepted.length === 1 ? 'y pins' : 'ies pin'} a version with a known advisory\n` - ); + const unacceptedStale = unaccepted.filter((finding) => finding.kind !== 'uncovered-copy'); + const unacceptedUncovered = unaccepted.filter((finding) => finding.kind === 'uncovered-copy'); + const parts = []; + if (unacceptedStale.length > 0) { + parts.push( + `${unacceptedStale.length} override entr${unacceptedStale.length === 1 ? 'y pins' : 'ies pin'} ` + + 'a version with a known advisory' + ); + } + if (unacceptedUncovered.length > 0) { + parts.push( + `${unacceptedUncovered.length} advisor${unacceptedUncovered.length === 1 ? 'y has' : 'ies have'} ` + + 'a vulnerable copy that no override key covers' + ); + } + console.error(`\ncheck-override-advisories: ${parts.join(', and ')}\n`); + for (const finding of unaccepted) { console.error(formatFinding(finding)); console.error(''); } + + if (unacceptedStale.length > 0) { + console.error( + 'For a stale pin, raise the value in the pnpm.overrides block of package.json to the ' + + "'fixed in' version above, then re-run pnpm install. Bumping the dependent " + + 'package alone will not work: the override pins resolution regardless.' + ); + } + if (unacceptedUncovered.length > 0) { + const withSuggestion = unacceptedUncovered.filter((finding) => finding.suggestedKey); + console.error( + 'For an uncovered copy, the override KEY is too narrow to select the versions listed under ' + + '"NOT covered", so the override never applies to them.' + ); + if (withSuggestion.length > 0) { + console.error( + 'Where a "suggested key" is printed, the pinned value is already patched: replace the ' + + 'narrow keys with that range key and re-run pnpm install.' + ); + } + // Without a suggestion there is no release to pin to, so telling anyone to + // paste a range key would be advice that cannot be followed - and the pin + // itself may still be vulnerable. + if (withSuggestion.length < unacceptedUncovered.length) { + console.error( + 'Where none is printed, the advisory publishes no single patched release to pin to ' + + '(no fix, a disjoint range, or an exclusive bound). Read the advisory and either raise ' + + 'the dependency that pulls the copy in, drop it, or record it in the baseline.' + ); + } + } console.error( - 'Fix by raising the value in the pnpm.overrides block of package.json to the ' + - "'fixed in' version above, then re-running pnpm install. Bumping the dependent " + - 'package alone will not work: the override pins resolution regardless.\n' + - 'If a bump is genuinely blocked, record it in ' + + 'If a fix is genuinely blocked, record it in ' + `${path.relative(REPO_ROOT, args.baseline)} with a reason.` ); return 1; diff --git a/scripts/ci/check-override-advisories.test.mjs b/scripts/ci/check-override-advisories.test.mjs index 27624e784..d1c1542e3 100644 --- a/scripts/ci/check-override-advisories.test.mjs +++ b/scripts/ci/check-override-advisories.test.mjs @@ -14,13 +14,20 @@ import { after, describe, it } from 'node:test'; import { applyBaseline, CheckError, + compareVersions, findVulnerablePins, fixedVersionFrom, formatFinding, indexOverrides, isExactVersion, main, + overrideKeyCovers, + overrideKeyCoversPath, parseOverrideKey, + selectorCovers, + splitOverrideKey, + suggestRangeKey, + versionIsCovered, } from './check-override-advisories.mjs'; const workdir = mkdtempSync(path.join(tmpdir(), 'override-advisories-')); @@ -101,6 +108,23 @@ describe('parseOverrideKey', () => { it('handles a parent>child key whose child carries a range selector', () => { assert.equal(parseOverrideKey('parent>child@>=1.0.0'), 'child'); }); + + // A '>' straight after '@' is always an operator. Reading 'pkg@>v1.2.3' as + // parent>child would index the entry under 'v1.2.3' and skip every advisory + // for pkg, which is silent and total. + // A child package name may start with a digit ('2fa' is a real package). + // Rejecting the separator on that basis indexed the whole key as one name and + // skipped every advisory for the child. + it('accepts a child package name that starts with a digit', () => { + assert.equal(parseOverrideKey('foo>2fa'), '2fa'); + assert.deepEqual(splitOverrideKey('foo>2fa'), { name: '2fa', selector: null, parent: 'foo' }); + }); + + it('does not mistake >v or "> " spellings for a parent separator', () => { + assert.equal(parseOverrideKey('pkg@>v1.2.3'), 'pkg'); + assert.equal(parseOverrideKey('pkg@> 1.2.3'), 'pkg'); + assert.equal(parseOverrideKey('pkg@>=v1.2.3'), 'pkg'); + }); }); describe('isExactVersion', () => { @@ -118,6 +142,380 @@ describe('isExactVersion', () => { }); }); +describe('splitOverrideKey', () => { + it('separates the name from an exact-version selector', () => { + assert.deepEqual(splitOverrideKey('axios@1.15.2'), { + name: 'axios', + selector: '1.15.2', + parent: null, + }); + }); + + it('separates the name from a range selector on a scoped package', () => { + assert.deepEqual(splitOverrideKey('@tiptap/core@<=3.27.0'), { + name: '@tiptap/core', + selector: '<=3.27.0', + parent: null, + }); + }); + + it('reports no selector for a blanket override', () => { + assert.deepEqual(splitOverrideKey('protobufjs'), { + name: 'protobufjs', + selector: null, + parent: null, + }); + }); + + it('takes the child of a parent>child key and keeps its selector and parent', () => { + assert.deepEqual(splitOverrideKey('parent>child@>=1.0.0'), { + name: 'child', + selector: '>=1.0.0', + parent: 'parent', + }); + }); +}); + +describe('compareVersions', () => { + it('orders by major, then minor, then patch', () => { + assert.equal(compareVersions('1.0.0', '2.0.0'), -1); + assert.equal(compareVersions('1.2.0', '1.10.0'), -1); // numeric, not lexical + assert.equal(compareVersions('3.3.18', '3.3.9'), 1); + assert.equal(compareVersions('1.2.3', '1.2.3'), 0); + }); + + it('sorts a prerelease below the release it precedes', () => { + assert.equal(compareVersions('3.3.18-rc.1', '3.3.18'), -1); + assert.equal(compareVersions('3.3.18', '3.3.18-rc.1'), 1); + }); + + it('ignores build metadata', () => { + assert.equal(compareVersions('1.2.3+build.5', '1.2.3'), 0); + }); +}); + +describe('selectorCovers', () => { + // A blanket override applies to whatever resolves, so it can never leave a + // copy uncovered. + it('treats a missing selector as covering everything', () => { + assert.equal(selectorCovers(null, '7.0.3'), true); + assert.equal(selectorCovers('', '7.0.3'), true); + }); + + // The uuid failure in one line: an exact selector reaches exactly one version. + it('matches an exact selector only against that version', () => { + assert.equal(selectorCovers('9.0.1', '9.0.1'), true); + assert.equal(selectorCovers('9.0.1', '8.3.2'), false); + assert.equal(selectorCovers('11.1.0', '7.0.3'), false); + }); + + it('treats a bare major as covering its whole line', () => { + assert.equal(selectorCovers('3', '3.3.17'), true); + assert.equal(selectorCovers('3', '4.0.0'), false); + assert.equal(selectorCovers('6', '6.12.6'), true); + }); + + it('treats a major.minor as covering that line', () => { + assert.equal(selectorCovers('3.3', '3.3.17'), true); + assert.equal(selectorCovers('3.3', '3.4.0'), false); + }); + + it('applies the < and <= comparators', () => { + assert.equal(selectorCovers('<11.1.1', '8.3.2'), true); + assert.equal(selectorCovers('<11.1.1', '11.1.1'), false); + assert.equal(selectorCovers('<=3.27.0', '3.27.0'), true); + assert.equal(selectorCovers('<=3.27.0', '3.27.1'), false); + }); + + it('applies the > and >= comparators', () => { + assert.equal(selectorCovers('>=5.0.0', '5.0.9'), true); + assert.equal(selectorCovers('>=5.0.0', '4.9.9'), false); + assert.equal(selectorCovers('>2.0.0', '2.0.0'), false); + }); + + // An explicit '=' takes the comparator path, unlike a bare '1.2.3' which is + // read as a prefix. Both have to end up meaning the same thing. + it('applies an explicit = comparator', () => { + assert.equal(selectorCovers('=1.2.3', '1.2.3'), true); + assert.equal(selectorCovers('=1.2.3', '1.2.4'), false); + assert.equal(selectorCovers('= 1.2.3', '1.2.3'), true); + }); + + it('tolerates a v prefix on the bound', () => { + assert.equal(selectorCovers('=v5.0.0', '4.9.9'), false); + }); + + // A partial bound names a whole line, not a zero-padded point. semver reads + // '>1' as "from 2.0.0" and '<=1.2' as "all of 1.2.x"; zero-padding gets both + // backwards, calling 1.5.0 covered by '>1' and 1.2.5 uncovered by '<=1.2'. + it('expands a partial comparator bound the way semver does', () => { + assert.equal(selectorCovers('>1', '1.5.0'), false); + assert.equal(selectorCovers('>1', '2.0.0'), true); + assert.equal(selectorCovers('<=1.2', '1.2.5'), true); + assert.equal(selectorCovers('<=1.2', '1.3.0'), false); + assert.equal(selectorCovers('<1.2', '1.1.9'), true); + assert.equal(selectorCovers('<1.2', '1.2.0'), false); + assert.equal(selectorCovers('>=1.2', '1.2.0'), true); + assert.equal(selectorCovers('>=1.2', '1.1.9'), false); + assert.equal(selectorCovers('=1.2', '1.2.9'), true); + assert.equal(selectorCovers('=1.2', '1.3.0'), false); + }); + + // semver keeps a prerelease out of an ordinary range, and so does pnpm, so an + // exact 'pkg@1.2.3' key genuinely would not be applied to 1.2.3-alpha.1. + // Claiming coverage there would let a vulnerable prerelease pass unreported. + it('does not let a plain selector cover a prerelease', () => { + assert.equal(selectorCovers('1.2.3', '1.2.3-alpha.1'), false); + assert.equal(selectorCovers('1.2.3', '1.2.3'), true); + assert.equal(selectorCovers('3', '3.3.18-rc.1'), false); + assert.equal(selectorCovers('3', '3.3.18'), true); + // Not just the bare branch: semver keeps a prerelease out of every ordinary + // range, so the comparator and caret/tilde paths must refuse it too. + assert.equal(selectorCovers('<2.0.0', '1.2.3-alpha.1'), false); + assert.equal(selectorCovers('^1.0.0', '1.2.3-alpha.1'), false); + assert.equal(selectorCovers('~1.2.0', '1.2.3-alpha.1'), false); + }); + + // A selector that IS a prerelease is a point and has to be compared as one. + // Its tag is not a numeric part, so without an explicit branch it reaches the + // permissive fallback and reads as covering the entire tree. + // SemVer precedence compares dot-separated identifiers numerically, so + // alpha.10 outranks alpha.2. A lexicographic compare reverses that and lets + // `<1.0.0-alpha.2` claim to cover an installed 1.0.0-alpha.10. + it('orders prerelease identifiers by SemVer precedence, not lexically', () => { + assert.equal(compareVersions('1.0.0-alpha.10', '1.0.0-alpha.2'), 1); + assert.equal(compareVersions('1.0.0-alpha.2', '1.0.0-alpha.10'), -1); + assert.equal(selectorCovers('<1.0.0-alpha.2', '1.0.0-alpha.10'), false); + }); + + // A caret or tilde selector carrying a prerelease is a real range and must be + // evaluated, not dropped to the permissive fallback where it would cover + // every installed version. + it('evaluates caret and tilde selectors that carry a prerelease', () => { + assert.equal(selectorCovers('^1.2.3-alpha.1', '9.9.9'), false); + assert.equal(selectorCovers('^1.2.3-alpha.1', '1.2.3'), true); + assert.equal(selectorCovers('~1.2.3-alpha.1', '9.9.9'), false); + }); + + it('matches an exact prerelease selector as a point', () => { + assert.equal(selectorCovers('1.2.3-alpha.1', '1.2.3-alpha.1'), true); + assert.equal(selectorCovers('1.2.3-alpha.1', '1.2.3-alpha.2'), false); + assert.equal(selectorCovers('1.2.3-alpha.1', '9.9.9'), false); + assert.equal(selectorCovers('1.2.3-alpha.1', '1.2.3'), false); + }); + + // ^ and ~ are handled rather than left to the catch-all, because the catch-all + // direction is a false negative: a '^8.0.0' key silently treated as covering a + // 7.0.3 copy is exactly the uuid failure again, one operator along. + it('applies caret ranges, including the 0.x special cases', () => { + assert.equal(selectorCovers('^8.0.0', '7.0.3'), false); + assert.equal(selectorCovers('^8.0.0', '8.5.0'), true); + assert.equal(selectorCovers('^8.0.0', '9.0.0'), false); + // ^ pins the left-most non-zero element, so 0.x behaves differently. + assert.equal(selectorCovers('^0.2.3', '0.2.9'), true); + assert.equal(selectorCovers('^0.2.3', '0.3.0'), false); + assert.equal(selectorCovers('^0.0.3', '0.0.4'), false); + assert.equal(selectorCovers('^0', '0.9.9'), true); + assert.equal(selectorCovers('^0', '1.0.0'), false); + }); + + it('applies tilde ranges at each level of precision', () => { + assert.equal(selectorCovers('~8.3.0', '8.3.9'), true); + assert.equal(selectorCovers('~8.3.0', '8.4.0'), false); + assert.equal(selectorCovers('~1.2', '1.2.9'), true); + assert.equal(selectorCovers('~1.2', '1.3.0'), false); + assert.equal(selectorCovers('~1', '1.9.9'), true); + assert.equal(selectorCovers('~1', '2.0.0'), false); + }); + + // Erring towards silence: a selector semver cannot parse as a range must not + // manufacture a finding, because the stale-pin check still covers that key. + it('treats an unparseable selector as covering the version', () => { + assert.equal(selectorCovers('workspace:*', '1.0.0'), true); + assert.equal(selectorCovers('npm:other@1.0.0', '1.0.0'), true); + assert.equal(selectorCovers('*', '1.0.0'), true); + }); + + // A compound range IS parseable, so it is evaluated rather than waved through. + // The hand-rolled predicate this replaced let '>=1 <2' cover every version. + it('evaluates a compound range instead of waving it through', () => { + assert.equal(selectorCovers('>=1 <2', '9.9.9'), false); + assert.equal(selectorCovers('>=1 <2', '1.5.0'), true); + assert.equal(selectorCovers('1.0.0 - 2.0.0', '1.5.0'), true); + assert.equal(selectorCovers('1.0.0 - 2.0.0', '9.9.9'), false); + assert.equal(selectorCovers('11 || 12', '7.0.3'), false); + assert.equal(selectorCovers('11 || 12', '12.1.0'), true); + }); + + // Regression guard on the prefix branch: a bare '1' selects the 1.x line and + // must not be read as a string prefix of '10.0.0'. + it('does not let a bare major prefix-match a longer major', () => { + assert.equal(selectorCovers('1', '10.0.0'), false); + assert.equal(selectorCovers('3', '30.0.0'), false); + }); + + // `uuid@8` and `uuid@8.x` mean the same thing. Letting only the first through + // would leave the second in the catch-all, silently reinstating the blind spot + // this whole check exists to close. + it('reads an x-range as the prefix it stands for', () => { + assert.equal(selectorCovers('8.x', '7.0.3'), false); + assert.equal(selectorCovers('8.x', '8.3.2'), true); + assert.equal(selectorCovers('8.x', '9.0.0'), false); + assert.equal(selectorCovers('8.*', '7.0.3'), false); + assert.equal(selectorCovers('8.X', '8.1.0'), true); + assert.equal(selectorCovers('8.x.x', '7.0.3'), false); + assert.equal(selectorCovers('1.2.x', '1.2.7'), true); + assert.equal(selectorCovers('1.2.x', '1.3.0'), false); + }); + + it('treats a lone wildcard as covering everything', () => { + assert.equal(selectorCovers('x', '9.9.9'), true); + assert.equal(selectorCovers('*', '9.9.9'), true); + }); +}); + +describe('overrideKeyCovers', () => { + it('reads the selector straight off the key', () => { + assert.equal(overrideKeyCovers('uuid@9.0.1', '8.3.2'), false); + assert.equal(overrideKeyCovers('uuid@<11.1.1', '8.3.2'), true); + assert.equal(overrideKeyCovers('uuid', '8.3.2'), true); + assert.equal(overrideKeyCovers('nanoid@3', '3.3.17'), true); + }); +}); + +describe('overrideKeyCovers with a parent-scoped key', () => { + const key = 'react-native>jest-environment-node'; + + it('covers a copy reached through that parent', () => { + assert.equal( + overrideKeyCovers(key, '1.2.3', ['apps/m > react-native > jest-environment-node@1.2.3']), + true + ); + }); + + // A parent-scoped override only rewrites the copy under that parent, so it + // must not be credited with covering one that arrives another way. This key + // has no blanket sibling in the repo, so without the check every + // jest-environment-node in the tree would look covered. + it('does not cover a copy reached through a different parent', () => { + assert.equal( + overrideKeyCovers(key, '1.2.3', ['apps/m > jest > jest-environment-node@1.2.3']), + false + ); + }); + + it('credits the key when no path information is available', () => { + assert.equal(overrideKeyCovers(key, '1.2.3', null), true); + assert.equal(overrideKeyCovers(key, '1.2.3', []), true); + }); + + // The same version can arrive both under the scoped parent and elsewhere. + // Crediting the key because one path matched would suppress the finding for + // the occurrence the override cannot rewrite. + it('requires every path to go through the parent, not just one', () => { + assert.equal( + overrideKeyCovers(key, '1.2.3', [ + 'apps/m > react-native > jest-environment-node@1.2.3', + 'apps/m > jest > jest-environment-node@1.2.3', + ]), + false + ); + }); + + // Package identity, not substring: 'foo' must not match the 'foobar' segment, + // which would credit an override pnpm cannot apply and swallow the finding. + // pnpm's parent selector overrides the matched parent's OWN dependency, so + // `foo>child` cannot reach a child that an intermediate package depends on. + // Crediting it there would let a patched pin suppress a copy the override + // never rewrites. + it('requires the parent to directly own the child', () => { + assert.equal(overrideKeyCoversPath('foo>child', '1.0.0', 'app > foo > child@1.0.0'), true); + assert.equal( + overrideKeyCoversPath('foo>child', '1.0.0', 'app > foo > mid > child@1.0.0'), + false + ); + }); + + // pnpm applies a version-scoped parent key only when the parent's own version + // satisfies the selector, so foo@1>child must not be credited for foo@2.0.0. + it("honours the parent selector's version", () => { + assert.equal( + overrideKeyCoversPath('foo@1>child', '1.0.0', 'app > foo@2.0.0 > child@1.0.0'), + false + ); + assert.equal( + overrideKeyCoversPath('foo@1>child', '1.0.0', 'app > foo@1.5.0 > child@1.0.0'), + true + ); + // No version in the path segment means there is nothing to disprove. + assert.equal(overrideKeyCoversPath('foo@1>child', '1.0.0', 'app > foo > child@1.0.0'), true); + }); + + it('matches a parent segment by identity, not by substring', () => { + assert.equal(overrideKeyCoversPath('foo>child', '1.0.0', 'app > foobar > child@1.0.0'), false); + assert.equal(overrideKeyCoversPath('foo>child', '1.0.0', 'app > foo > child@1.0.0'), true); + assert.equal( + overrideKeyCoversPath('foo>child', '1.0.0', 'app > foo@2.1.0 > child@1.0.0'), + true + ); + }); +}); + +describe('versionIsCovered', () => { + // The quantifiers are the whole point and are easy to inverse: coverage is + // "for EVERY path, SOME key covers it". Asking "some key covers every path" + // makes two sibling parent-scoped keys each fail on the other's path and + // reports an uncovered copy the pair actually covers between them. + it('lets two parent-scoped keys cover a version between them', () => { + const pins = [ + { key: 'foo>child', pinned: '2.0.0' }, + { key: 'bar>child', pinned: '2.0.0' }, + ]; + assert.equal( + versionIsCovered(pins, '1.0.0', ['a > foo > child@1.0.0', 'a > bar > child@1.0.0']), + true + ); + }); + + it('reports a version whose path no key reaches', () => { + const pins = [{ key: 'foo>child', pinned: '2.0.0' }]; + assert.equal( + versionIsCovered(pins, '1.0.0', ['a > foo > child@1.0.0', 'a > baz > child@1.0.0']), + false + ); + }); + + it('falls back to the key check when there are no paths', () => { + assert.equal(versionIsCovered([{ key: 'child', pinned: '2.0.0' }], '1.0.0', []), true); + assert.equal(versionIsCovered([{ key: 'child@9.9.9', pinned: '2.0.0' }], '1.0.0', []), false); + }); +}); + +describe('suggestRangeKey', () => { + it('suggests a range key bounded by the first patched release', () => { + assert.equal(suggestRangeKey('uuid', '11.1.1', '>=11.1.1'), '"uuid@<11.1.1": "11.1.1"'); + }); + + it('suggests nothing when no fix has been published', () => { + assert.equal(suggestRangeKey('uuid', null), null); + }); + + // '<2.0.0 || >=2.0.5' has no single lower bound. Suggesting "<2.0.0": "2.0.0" + // would miss a vulnerable 2.0.3 and pin copies to a version outside the + // patched set, so no suggestion is better than a wrong one. + it('suggests nothing for a disjoint patched range', () => { + assert.equal(suggestRangeKey('pkg', '2.0.0', '<2.0.0 || >=2.0.5'), null); + }); + + // '>1.2.3' says the fix is ABOVE 1.2.3, so 1.2.3 itself is still vulnerable. + // Only '>=' names a release that can be pasted straight into package.json. + it('suggests nothing for an exclusive patched bound', () => { + assert.equal(suggestRangeKey('pkg', '1.2.3', '>1.2.3'), null); + assert.equal(suggestRangeKey('pkg', '1.2.3', '>=1.2.3'), '"pkg@<1.2.3": "1.2.3"'); + }); +}); + describe('indexOverrides', () => { it('groups every entry that targets the same package', () => { const index = indexOverrides({ @@ -179,12 +577,38 @@ describe('findVulnerablePins', () => { assert.deepEqual(findings, []); }); - // The uuid case in this repo: old copies of an overridden package are - // vulnerable, but the override itself pins a patched version, so the override - // is not the thing at fault and must not be reported. - it('ignores a vulnerable installed copy that the override does not pin', () => { + // The real uuid incident. Both override keys selected an exact version and + // pinned a patched 11.1.1, so the stale-pin check was satisfied, while the + // copies that were actually vulnerable resolved at 7.0.3 and 8.3.2 and matched + // neither key. Six high alerts stayed open behind a check that said OK. + it('reports a vulnerable copy that no override key covers', () => { const findings = findVulnerablePins( - { 'uuid@9.0.1': '11.1.1' }, + { 'uuid@9.0.1': '11.1.1', 'uuid@11.1.0': '11.1.1' }, + { + advisories: { + 1000: advisory({ + module_name: 'uuid', + vulnerable_versions: '<11.1.1', + patched_versions: '>=11.1.1', + findings: [{ version: '8.3.2' }, { version: '7.0.3' }], + }), + }, + } + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'uncovered-copy'); + assert.equal(findings[0].package, 'uuid'); + assert.deepEqual(findings[0].uncovered, ['8.3.2', '7.0.3']); + assert.equal(findings[0].suggestedKey, '"uuid@<11.1.1": "11.1.1"'); + }); + + // The other half of the same rule: a key that genuinely selects the vulnerable + // copy is doing its job, and the pin it points at is patched, so there is + // nothing to report. This is what stops the new check crying wolf on the + // range keys the repo already uses. + it('stays quiet when a range key does cover the vulnerable copy', () => { + const findings = findVulnerablePins( + { 'uuid@<11.1.1': '11.1.1' }, { advisories: { 1000: advisory({ @@ -199,6 +623,137 @@ describe('findVulnerablePins', () => { assert.deepEqual(findings, []); }); + // A blanket override with no selector at all covers every version by + // definition, so it can never leave a copy uncovered. + it('treats a selector-less override as covering every copy', () => { + const findings = findVulnerablePins( + { uuid: '11.1.1' }, + { + advisories: { + 1000: advisory({ + module_name: 'uuid', + vulnerable_versions: '<11.1.1', + patched_versions: '>=11.1.1', + findings: [{ version: '8.3.2' }], + }), + }, + } + ); + assert.deepEqual(findings, []); + }); + + // A bare-major key covers its whole line, so `nanoid@3` covers 3.3.17 and the + // only thing wrong there is the pin. Reporting it twice would be noise. + it('reports only a stale pin, not an uncovered copy, for a covering major key', () => { + const findings = findVulnerablePins( + { 'nanoid@3': '3.3.17' }, + { + advisories: { + 1000: advisory({ + module_name: 'nanoid', + vulnerable_versions: '<3.3.18', + patched_versions: '>=3.3.18', + findings: [{ version: '3.3.17' }], + }), + }, + } + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'stale-pin'); + }); + + // Both problems can be true at once: one key pins a vulnerable version while + // the key set as a whole still fails to reach another vulnerable copy. + it('reports a stale pin and an uncovered copy independently', () => { + const findings = findVulnerablePins( + { 'uuid@8.0.0': '8.0.0' }, + { + advisories: { + 1000: advisory({ + module_name: 'uuid', + vulnerable_versions: '<11.1.1', + patched_versions: '>=11.1.1', + findings: [{ version: '8.0.0' }, { version: '7.0.3' }], + }), + }, + } + ); + assert.equal(findings.length, 2); + assert.deepEqual(findings.map((finding) => finding.kind).sort(), [ + 'stale-pin', + 'uncovered-copy', + ]); + assert.deepEqual(findings.find((finding) => finding.kind === 'uncovered-copy').uncovered, [ + '7.0.3', + ]); + }); + + // The commonest override shape in this repo: an exact-version key pinned to a + // HIGHER version, e.g. "vite@7.3.3": "7.3.5". The installed 7.3.5 is by + // construction not selected by the key `vite@7.3.3`, so a naive coverage test + // calls it uncovered and prints "no override key covers 7.3.5" right next to a + // stale-pin finding for the same version. The override plainly did apply: 7.3.5 + // is installed BECAUSE of it. Only the stale pin should be reported. + it('does not double-report a stale pin as an uncovered copy', () => { + const findings = findVulnerablePins( + { 'vite@7.3.3': '7.3.5' }, + { + advisories: { + 1000: advisory({ + module_name: 'vite', + vulnerable_versions: '<7.3.6', + patched_versions: '>=7.3.6', + findings: [{ version: '7.3.5' }], + }), + }, + } + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'stale-pin'); + }); + + // The two exclusions are independent: a pinned value is forgiven, but a copy + // that is neither the pin nor covered by a key is still reported. + it('still reports a genuinely uncovered copy alongside a stale pin', () => { + const findings = findVulnerablePins( + { 'vite@7.3.3': '7.3.5' }, + { + advisories: { + 1000: advisory({ + module_name: 'vite', + vulnerable_versions: '<7.3.6', + patched_versions: '>=7.3.6', + findings: [{ version: '7.3.5' }, { version: '6.0.0' }], + }), + }, + } + ); + assert.equal(findings.length, 2); + const uncoveredFinding = findings.find((finding) => finding.kind === 'uncovered-copy'); + assert.deepEqual(uncoveredFinding.uncovered, ['6.0.0']); + }); + + // No patched release means no range key can be suggested; the finding still + // has to surface rather than being dropped for lack of a suggestion. + it('reports an uncovered copy even when no fix has been published', () => { + const findings = findVulnerablePins( + { 'uuid@9.0.1': '11.1.1' }, + { + advisories: { + 1000: advisory({ + module_name: 'uuid', + vulnerable_versions: '<11.1.1', + patched_versions: '<0.0.0', + findings: [{ version: '7.0.3' }], + }), + }, + } + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'uncovered-copy'); + assert.equal(findings[0].suggestedKey, null); + }); + it('ignores a vulnerable package that is not overridden at all', () => { const findings = findVulnerablePins( { axios: '1.18.0' }, @@ -290,6 +845,33 @@ describe('applyBaseline', () => { assert.equal(result.unaccepted.length, 1); }); + // pnpm audit lists findings in whatever order it likes. An order-sensitive key + // would make an accepted entry go stale the day that order changes, failing CI + // on an advisory nobody touched. + it('accepts an uncovered-copy entry regardless of version order', () => { + const finding = { + kind: 'uncovered-copy', + package: 'uuid', + pinned: '11.1.1', + uncovered: ['8.3.2', '7.0.3'], + advisory: 'GHSA-uuid', + }; + const baseline = { + accepted: [ + { + kind: 'uncovered-copy', + package: 'uuid', + uncovered: ['7.0.3', '8.3.2'], + advisory: 'GHSA-uuid', + reason: 'blocked upstream', + }, + ], + }; + const { unaccepted, known } = applyBaseline([finding], baseline); + assert.deepEqual(unaccepted, []); + assert.equal(known.length, 1); + }); + it('reports every finding when there is no baseline', () => { const result = applyBaseline([finding], null); assert.equal(result.unaccepted.length, 1); @@ -351,7 +933,10 @@ describe('main', () => { '--no-baseline', ]); assert.equal(code, 0); - assert.match(output, /OK - no unreviewed override pins a vulnerable version/); + assert.match( + output, + /OK - every override pins a patched version and covers every vulnerable copy/ + ); }); it('passes when the only finding is recorded in the baseline', () => { diff --git a/scripts/ci/override-advisory-baseline.json b/scripts/ci/override-advisory-baseline.json index df77ce85f..e61419c2a 100644 --- a/scripts/ci/override-advisory-baseline.json +++ b/scripts/ci/override-advisory-baseline.json @@ -1,10 +1,24 @@ { "$schema": "accepted override drift - see scripts/ci/check-override-advisories.mjs", "$comment": [ - "Override pins that are known to be vulnerable and are accepted for now.", - "An entry is matched on package + pinned + advisory, so it expires by itself:", - "raise the override, or let a new advisory land against the same pin, and the", - "check goes red again. Adding an entry is a deliberate decision that needs a reason." + "Override findings that are known to be a problem and are accepted for now.", + "Adding an entry is a deliberate decision that needs a reason.", + "", + "There are two shapes, one per kind of finding, and both expire by themselves.", + "", + "A stale-pin entry (the pinned version is itself vulnerable) is matched on", + "package + pinned + advisory. Raise the override, or let a new advisory land", + "against the same pin, and the entry stops matching:", + " { \"package\": \"fast-uri\", \"pinned\": \"3.1.3\", \"advisory\": \"GHSA-...\",", + " \"reason\": \"why this is accepted\" }", + "", + "An uncovered-copy entry (the pin is patched, but no override key selects the", + "vulnerable copies) is matched on package + the uncovered versions + advisory,", + "and needs the kind field or it will be read as a stale-pin and never match.", + "Cover one of the versions, or let a new copy appear, and it stops matching:", + " { \"kind\": \"uncovered-copy\", \"package\": \"uuid\",", + " \"uncovered\": [\"7.0.3\", \"8.0.0\"], \"advisory\": \"GHSA-...\",", + " \"reason\": \"why this is accepted\" }" ], "accepted": [] }