@dependably/npm-check 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/cli.js +1577 -0
- package/package.json +103 -0
- package/src/audit-config.js +331 -0
- package/src/audit.js +805 -0
- package/src/backup.js +235 -0
- package/src/checker.js +771 -0
- package/src/checksum-fixer.js +419 -0
- package/src/deprecation.js +325 -0
- package/src/fixer.js +207 -0
- package/src/format-library.js +132 -0
- package/src/index.js +138 -0
- package/src/integrity.js +519 -0
- package/src/migrator.js +263 -0
- package/src/npmrc-validator.js +205 -0
- package/src/overrides.js +71 -0
- package/src/package-json-validator.js +356 -0
- package/src/parallel-processor.js +374 -0
- package/src/parser.js +133 -0
- package/src/performance.js +283 -0
- package/src/pinner.js +248 -0
- package/src/pnpm-format.js +229 -0
- package/src/pnpm-workspace-validator.js +121 -0
- package/src/progress-reporter.js +245 -0
- package/src/pruner.js +177 -0
- package/src/remediate.js +389 -0
- package/src/report.js +663 -0
- package/src/schema.js +76 -0
- package/src/streaming-parser.js +251 -0
- package/src/updater.js +251 -0
- package/src/usage-scanner.js +215 -0
- package/src/validator.js +282 -0
- package/src/vuln.js +618 -0
- package/src/workers/dedupe-worker.js +20 -0
- package/src/workers/hash-upgrade-worker.js +20 -0
- package/src/workers/migration-worker.js +21 -0
- package/src/workers/validation-worker.js +20 -0
package/src/vuln.js
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
// src/vuln.js
|
|
2
|
+
// Known-vulnerability scan: checks each locked package against the npm registry's
|
|
3
|
+
// bulk advisory endpoint (POST {registry}/-/npm/v1/security/advisories/bulk).
|
|
4
|
+
//
|
|
5
|
+
// This complements the integrity check — integrity asks "is the lockfile what it
|
|
6
|
+
// claims to be?", this asks "do the versions it locks have published advisories?".
|
|
7
|
+
// It is lockfile-first (no node_modules), reuses the registry-base derivation and
|
|
8
|
+
// concurrency model. It deliberately does NOT shell out to `npm audit`.
|
|
9
|
+
//
|
|
10
|
+
// Fail-closed by default: when the scan CANNOT COMPLETE for an entry — the registry
|
|
11
|
+
// is unreachable (network/transport error) or doesn't implement the bulk advisory
|
|
12
|
+
// endpoint — that entry is `unresolved`, and an unresolved entry FAILS the run by
|
|
13
|
+
// default. We must never print "clean" for a package we could not actually scan.
|
|
14
|
+
// This is distinct from a package the registry successfully reports as having NO
|
|
15
|
+
// advisories, which is a normal `clean` result. Set `failOnUnresolved: false`
|
|
16
|
+
// (CLI `--allow-unresolved`) to opt back into lenient/offline-tolerant behavior.
|
|
17
|
+
import { createProgressReporter } from './progress-reporter.js';
|
|
18
|
+
import { forEachPackageEntry } from './format-library.js';
|
|
19
|
+
import { DEFAULT_REGISTRY, postJson } from './integrity.js';
|
|
20
|
+
import { buildEnvelope } from './schema.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Custom error class for vuln-scan operations
|
|
24
|
+
*/
|
|
25
|
+
export class VulnError extends Error {
|
|
26
|
+
constructor(message, code, context = {}) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'VulnError';
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.context = context;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Advisory severity ordering. Used to compare against the minSeverity threshold.
|
|
35
|
+
const SEVERITY_RANK = { info: 0, low: 1, moderate: 2, high: 3, critical: 4 };
|
|
36
|
+
|
|
37
|
+
// Fail-closed ranking: a severity we recognize maps to its ladder rank; a missing
|
|
38
|
+
// or off-vocabulary severity ranks ABOVE any threshold so it fails the run rather
|
|
39
|
+
// than being silently demoted to a warning (this module commits to fail-closed).
|
|
40
|
+
function severityRank(severity) {
|
|
41
|
+
const key = typeof severity === 'string' ? severity.toLowerCase() : '';
|
|
42
|
+
return key in SEVERITY_RANK ? SEVERITY_RANK[key] : Number.POSITIVE_INFINITY; // unknown → fail closed
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Preserve the advisory's raw severity for display; substitute a visible sentinel
|
|
46
|
+
// (never a fabricated 'low') when it is missing, so consumers see the real value.
|
|
47
|
+
function normalizeSeverity(severity) {
|
|
48
|
+
return typeof severity === 'string' && severity.trim() ? severity.trim().toLowerCase() : 'unknown';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- Dependency-free exact-version range matching -----------------------------
|
|
52
|
+
// The locked version is always a concrete semver; advisory `vulnerable_versions`
|
|
53
|
+
// ranges are the plain comparator grammar the npm/GitHub advisory API emits
|
|
54
|
+
// (`<4.17.21`, `>=1.0.0 <1.2.3`, `>=1 <2 || >=3 <4`, `*`). We match the exact
|
|
55
|
+
// locked version against that range WITHOUT pulling in a `semver` dependency.
|
|
56
|
+
// Anything outside this comparator grammar (^, ~, x-ranges) is treated as
|
|
57
|
+
// "unparseable" → the caller stays conservative rather than guessing.
|
|
58
|
+
// Prerelease/build are dot-separated identifiers. Matching them as
|
|
59
|
+
// `identifier(?:\.identifier)*` — where the `.` separator is NOT in the
|
|
60
|
+
// identifier class — is linear and free of the backtracking ambiguity a single
|
|
61
|
+
// `[0-9A-Za-z.-]+` greedy class invites (and it's the correct semver grammar:
|
|
62
|
+
// identifiers can't be empty).
|
|
63
|
+
const IDENT = '[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*';
|
|
64
|
+
const SEMVER_RE = new RegExp(`^v?(\\d+)\\.(\\d+)\\.(\\d+)(?:-(${IDENT}))?(?:\\+${IDENT})?$`);
|
|
65
|
+
const COMPARATOR_RE = new RegExp(`(<=|>=|<|>|=)?\\s*v?(\\d+)\\.(\\d+)\\.(\\d+)(?:-(${IDENT}))?(?:\\+${IDENT})?`, 'g');
|
|
66
|
+
|
|
67
|
+
function parseSemver(v) {
|
|
68
|
+
if (typeof v !== 'string') return null;
|
|
69
|
+
const m = SEMVER_RE.exec(v.trim());
|
|
70
|
+
if (!m) return null;
|
|
71
|
+
return { major: +m[1], minor: +m[2], patch: +m[3], prerelease: m[4] ? m[4].split('.') : [] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function comparePreReleaseId(a, b) {
|
|
75
|
+
const an = /^\d+$/.test(a);
|
|
76
|
+
const bn = /^\d+$/.test(b);
|
|
77
|
+
if (an && bn) return Number(a) - Number(b);
|
|
78
|
+
if (an) return -1; // numeric identifiers have lower precedence than alphanumeric
|
|
79
|
+
if (bn) return 1;
|
|
80
|
+
if (a < b) return -1;
|
|
81
|
+
return a > b ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function compareSemver(a, b) {
|
|
85
|
+
if (a.major !== b.major) return a.major - b.major;
|
|
86
|
+
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
87
|
+
if (a.patch !== b.patch) return a.patch - b.patch;
|
|
88
|
+
const ap = a.prerelease;
|
|
89
|
+
const bp = b.prerelease;
|
|
90
|
+
if (ap.length === 0 && bp.length === 0) return 0;
|
|
91
|
+
if (ap.length === 0) return 1; // a release outranks a prerelease of the same core
|
|
92
|
+
if (bp.length === 0) return -1;
|
|
93
|
+
const len = Math.min(ap.length, bp.length);
|
|
94
|
+
for (let i = 0; i < len; i++) {
|
|
95
|
+
const cmp = comparePreReleaseId(ap[i], bp[i]);
|
|
96
|
+
if (cmp !== 0) return cmp;
|
|
97
|
+
}
|
|
98
|
+
return ap.length - bp.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Parse one AND-term (space-separated comparators). Returns the comparator list,
|
|
102
|
+
// or null when the term contains anything outside the supported comparator grammar.
|
|
103
|
+
function parseComparatorTerm(term) {
|
|
104
|
+
const t = term.trim();
|
|
105
|
+
if (t === '' || t === '*') return [{ any: true }];
|
|
106
|
+
const matches = [...t.matchAll(COMPARATOR_RE)];
|
|
107
|
+
if (matches.length === 0) return null;
|
|
108
|
+
const comps = matches.map((m) => ({
|
|
109
|
+
op: m[1] || '=',
|
|
110
|
+
version: { major: +m[2], minor: +m[3], patch: +m[4], prerelease: m[5] ? m[5].split('.') : [] }
|
|
111
|
+
}));
|
|
112
|
+
// Reject the term if any non-comparator syntax (^, ~, x-range, ...) remains.
|
|
113
|
+
const leftover = t.replace(COMPARATOR_RE, '').replace(/\s+/g, '');
|
|
114
|
+
return leftover === '' ? comps : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Parse a full range into OR-terms of AND-comparators, or null if unparseable.
|
|
118
|
+
function parseRange(range) {
|
|
119
|
+
if (typeof range !== 'string') return null;
|
|
120
|
+
const trimmed = range.trim();
|
|
121
|
+
if (trimmed === '' || trimmed === '*') return [[{ any: true }]];
|
|
122
|
+
const terms = [];
|
|
123
|
+
for (const part of trimmed.split('||')) {
|
|
124
|
+
const comps = parseComparatorTerm(part);
|
|
125
|
+
if (comps === null) return null;
|
|
126
|
+
terms.push(comps);
|
|
127
|
+
}
|
|
128
|
+
return terms;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function satisfiesComparator(v, comp) {
|
|
132
|
+
if (comp.any) return true;
|
|
133
|
+
const cmp = compareSemver(v, comp.version);
|
|
134
|
+
switch (comp.op) {
|
|
135
|
+
case '<': return cmp < 0;
|
|
136
|
+
case '<=': return cmp <= 0;
|
|
137
|
+
case '>': return cmp > 0;
|
|
138
|
+
case '>=': return cmp >= 0;
|
|
139
|
+
default: return cmp === 0; // '='
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// True/false when we can decide, null ("uncertain") when either the range or the
|
|
144
|
+
// version can't be parsed with this dependency-free matcher.
|
|
145
|
+
function satisfiesRange(versionStr, range) {
|
|
146
|
+
const parsed = parseRange(range);
|
|
147
|
+
if (parsed === null) return null;
|
|
148
|
+
const v = parseSemver(versionStr);
|
|
149
|
+
if (v === null) return null;
|
|
150
|
+
for (const term of parsed) {
|
|
151
|
+
if (term.every((c) => satisfiesComparator(v, c))) return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Decide whether an advisory returned by the bulk endpoint actually applies to a
|
|
158
|
+
* specific locked version. The endpoint keys advisories by NAME and filters
|
|
159
|
+
* server-side to the set of versions we submitted, but does NOT say which of those
|
|
160
|
+
* versions each advisory covers. So when a single version was submitted for a name
|
|
161
|
+
* we trust the server verbatim; when MULTIPLE versions share a name we must match
|
|
162
|
+
* each version against the advisory's `vulnerable_versions` range ourselves, or a
|
|
163
|
+
* patched version sharing the name gets falsely flagged.
|
|
164
|
+
*
|
|
165
|
+
* Returns 'yes' (record by severity), 'no' (not this version — skip), or
|
|
166
|
+
* 'uncertain' (multi-version group but the range/version isn't matchable — record
|
|
167
|
+
* as a warning, never a run-failing error, to avoid a false CI failure).
|
|
168
|
+
*/
|
|
169
|
+
function advisoryAppliesTo(version, advisory, multiVersion) {
|
|
170
|
+
if (!multiVersion) return 'yes'; // single submitted version: server filtering is authoritative
|
|
171
|
+
const range = advisory.vulnerable_versions ?? advisory.vulnerableVersions ?? null;
|
|
172
|
+
if (typeof range !== 'string' || range.trim() === '') return 'uncertain';
|
|
173
|
+
const verdict = satisfiesRange(version, range);
|
|
174
|
+
if (verdict === true) return 'yes';
|
|
175
|
+
if (verdict === false) return 'no';
|
|
176
|
+
return 'uncertain';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Extract the patched/fixed version range from an advisory, when the registry
|
|
181
|
+
* provides it. The npm/GitHub advisory shape carries this as `patched_versions`
|
|
182
|
+
* (e.g. ">=4.17.21"); the sentinel "<0.0.0" means "no fix is available yet".
|
|
183
|
+
* Returns null when absent — we never fabricate a fix that the data doesn't claim.
|
|
184
|
+
*/
|
|
185
|
+
function fixedVersionOf(advisory) {
|
|
186
|
+
const raw = advisory.patched_versions ?? advisory.patchedVersions ?? advisory.fixedVersion ?? null;
|
|
187
|
+
if (typeof raw !== 'string') return null;
|
|
188
|
+
const trimmed = raw.trim();
|
|
189
|
+
if (!trimmed || trimmed === '<0.0.0') return null; // no fix published
|
|
190
|
+
return trimmed;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Extract the CVE identifier from an advisory, when present. The npm/GitHub
|
|
195
|
+
* advisory shape carries it as `cves` (array), `cve`, or `cve_id`. Returns null
|
|
196
|
+
* when absent — we never fabricate one.
|
|
197
|
+
*/
|
|
198
|
+
function cveOf(advisory) {
|
|
199
|
+
const raw = (Array.isArray(advisory.cves) ? advisory.cves[0] : null)
|
|
200
|
+
?? advisory.cve ?? advisory.cve_id ?? null;
|
|
201
|
+
return typeof raw === 'string' && raw.trim() ? raw.trim() : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Collect reference URLs from an advisory (its `references` list plus its own
|
|
206
|
+
* `url`), de-duplicated. References may be plain strings or `{ url }` objects.
|
|
207
|
+
*/
|
|
208
|
+
function referencesOf(advisory) {
|
|
209
|
+
const refs = [];
|
|
210
|
+
if (Array.isArray(advisory.references)) {
|
|
211
|
+
for (const r of advisory.references) {
|
|
212
|
+
const url = typeof r === 'string' ? r : (r && r.url);
|
|
213
|
+
if (url) refs.push(url);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (advisory.url) refs.push(advisory.url);
|
|
217
|
+
return [...new Set(refs)];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Walk the lockfile and collect the entries we can check via the bulk endpoint,
|
|
222
|
+
* mirroring checker.js's skip logic. Mutates results.skipped for the rest.
|
|
223
|
+
* Returns the candidate list ({ key, name, version, registryBase }).
|
|
224
|
+
*/
|
|
225
|
+
function collectCandidates(lockfileData, results, defaultRegistry) {
|
|
226
|
+
const candidates = [];
|
|
227
|
+
forEachPackageEntry(lockfileData, (info) => {
|
|
228
|
+
const { key, entry, name, isRoot, isWorkspaceSource, isLink, isBundled, isGitDep, isFileDep } = info;
|
|
229
|
+
if (isRoot) return results.skipped++;
|
|
230
|
+
if (isWorkspaceSource) return results.skipped++;
|
|
231
|
+
if (isLink) return results.skipped++;
|
|
232
|
+
if (isBundled || isGitDep || isFileDep) return results.skipped++; // no registry advisory to check
|
|
233
|
+
if (!entry.version) return results.skipped++;
|
|
234
|
+
const registryBase = info.registryBase || defaultRegistry;
|
|
235
|
+
candidates.push({ key, name, version: entry.version, registryBase });
|
|
236
|
+
});
|
|
237
|
+
return candidates;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Group candidates by registry, then by name (the bulk endpoint keys by name),
|
|
242
|
+
* and slice each registry's name list into POST units of at most `batchSize` names.
|
|
243
|
+
* Each unit is { registryBase, names: [ [candidate, ...], ... ] }.
|
|
244
|
+
*/
|
|
245
|
+
function buildUnits(candidates, batchSize) {
|
|
246
|
+
const byRegistry = new Map();
|
|
247
|
+
for (const c of candidates) {
|
|
248
|
+
if (!byRegistry.has(c.registryBase)) byRegistry.set(c.registryBase, new Map());
|
|
249
|
+
const names = byRegistry.get(c.registryBase);
|
|
250
|
+
if (!names.has(c.name)) names.set(c.name, []);
|
|
251
|
+
names.get(c.name).push(c);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const units = [];
|
|
255
|
+
for (const [registryBase, names] of byRegistry) {
|
|
256
|
+
const nameList = [...names.keys()];
|
|
257
|
+
for (let i = 0; i < nameList.length; i += batchSize) {
|
|
258
|
+
const chunk = nameList.slice(i, i + batchSize);
|
|
259
|
+
units.push({ registryBase, names: chunk.map((n) => names.get(n)) });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return units;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Record every candidate in a unit as unresolved (registry unreachable, or the
|
|
267
|
+
* endpoint isn't supported) — i.e. advisory data could not be obtained, so the
|
|
268
|
+
* scan did not complete for these packages. Fails the run when failOnUnresolved
|
|
269
|
+
* (the default), so a registry outage can never be mistaken for "no vulnerabilities".
|
|
270
|
+
*/
|
|
271
|
+
function recordUnresolvedUnit(unitCandidates, reason, results, failOnUnresolved) {
|
|
272
|
+
for (const cand of unitCandidates) {
|
|
273
|
+
const item = { package: cand.name, version: cand.version, packagePath: cand.key, reason };
|
|
274
|
+
results.unresolved++;
|
|
275
|
+
results.unresolvedItems.push(item);
|
|
276
|
+
results.details.push({ unresolved: true, ...item });
|
|
277
|
+
if (failOnUnresolved) {
|
|
278
|
+
results.valid = false;
|
|
279
|
+
results.errors.push(item);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Attribute the registry's advisory response to each submitted candidate.
|
|
286
|
+
*
|
|
287
|
+
* The endpoint keys advisories by NAME and filters server-side to the versions we
|
|
288
|
+
* submitted, but doesn't say which submitted version each advisory covers. When a
|
|
289
|
+
* name has a SINGLE locked version we trust that per-name attribution; when it has
|
|
290
|
+
* MULTIPLE versions we match each version against the advisory's vulnerable range
|
|
291
|
+
* so a patched sibling version isn't falsely flagged (issue #14). A per-name value
|
|
292
|
+
* that isn't an array (a malformed 200) is treated as "no advisories" rather than
|
|
293
|
+
* crashing the whole scan (issue #24).
|
|
294
|
+
*/
|
|
295
|
+
function recordResolvedUnit(unitCandidates, advisoriesByName, results, threshold) {
|
|
296
|
+
// Count distinct submitted versions per name to know when to version-match.
|
|
297
|
+
const versionsByName = new Map();
|
|
298
|
+
for (const c of unitCandidates) {
|
|
299
|
+
if (!versionsByName.has(c.name)) versionsByName.set(c.name, new Set());
|
|
300
|
+
versionsByName.get(c.name).add(c.version);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
for (const cand of unitCandidates) {
|
|
304
|
+
const raw = advisoriesByName[cand.name];
|
|
305
|
+
const advisories = Array.isArray(raw) ? raw : []; // malformed per-name value → no advisories
|
|
306
|
+
const multiVersion = versionsByName.get(cand.name).size > 1;
|
|
307
|
+
|
|
308
|
+
// Keep only advisories that actually apply to THIS version.
|
|
309
|
+
const applicable = [];
|
|
310
|
+
for (const advisory of advisories) {
|
|
311
|
+
const verdict = advisoryAppliesTo(cand.version, advisory, multiVersion);
|
|
312
|
+
if (verdict === 'no') continue;
|
|
313
|
+
applicable.push({ advisory, uncertain: verdict === 'uncertain' });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (applicable.length === 0) {
|
|
317
|
+
results.clean++;
|
|
318
|
+
results.details.push({ vulnerable: false, package: cand.name, version: cand.version, packagePath: cand.key });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
results.vulnerable++;
|
|
322
|
+
for (const { advisory, uncertain } of applicable) recordVuln(cand, advisory, results, threshold, uncertain);
|
|
323
|
+
results.details.push({
|
|
324
|
+
vulnerable: true,
|
|
325
|
+
package: cand.name,
|
|
326
|
+
version: cand.version,
|
|
327
|
+
packagePath: cand.key,
|
|
328
|
+
advisories: applicable.map(({ advisory: a }) => ({
|
|
329
|
+
id: a.id, title: a.title, severity: normalizeSeverity(a.severity),
|
|
330
|
+
vulnerable_versions: a.vulnerable_versions, fixedVersion: fixedVersionOf(a), url: a.url
|
|
331
|
+
}))
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Classify one advisory: at/above the threshold it's an error (fails the run),
|
|
338
|
+
* below it a warning. A missing/unknown severity ranks above every threshold, so
|
|
339
|
+
* it fails closed rather than being silently downgraded. `forceWarning` records
|
|
340
|
+
* the finding as a warning regardless of severity — used when a multi-version
|
|
341
|
+
* group can't be matched to a specific version, so an unmatchable advisory never
|
|
342
|
+
* produces a run-failing false positive on a possibly-patched version (issue #14).
|
|
343
|
+
*/
|
|
344
|
+
function recordVuln(cand, advisory, results, threshold, forceWarning = false) {
|
|
345
|
+
const finding = {
|
|
346
|
+
package: cand.name,
|
|
347
|
+
version: cand.version,
|
|
348
|
+
packagePath: cand.key,
|
|
349
|
+
advisoryId: advisory.id,
|
|
350
|
+
title: advisory.title,
|
|
351
|
+
severity: normalizeSeverity(advisory.severity), // raw value surfaced; 'unknown' when absent
|
|
352
|
+
fixedVersion: fixedVersionOf(advisory), // null when the advisory publishes no fix
|
|
353
|
+
cve: cveOf(advisory), // null when the advisory carries no CVE
|
|
354
|
+
vulnerableRange: advisory.vulnerable_versions ?? null,
|
|
355
|
+
references: referencesOf(advisory),
|
|
356
|
+
url: advisory.url
|
|
357
|
+
};
|
|
358
|
+
if (!forceWarning && severityRank(finding.severity) >= threshold) {
|
|
359
|
+
results.errors.push(finding);
|
|
360
|
+
results.valid = false;
|
|
361
|
+
} else {
|
|
362
|
+
results.warnings.push(finding);
|
|
363
|
+
}
|
|
364
|
+
return finding;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Build the bulk request body for a unit: { name: [unique versions] }.
|
|
369
|
+
*/
|
|
370
|
+
function buildUnitBody(unitNames) {
|
|
371
|
+
const body = {};
|
|
372
|
+
for (const group of unitNames) {
|
|
373
|
+
const name = group[0].name;
|
|
374
|
+
body[name] = [...new Set(group.map((c) => c.version))];
|
|
375
|
+
}
|
|
376
|
+
return body;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Scan a single POST unit: fetch the bulk advisories for its registry and route
|
|
381
|
+
* the result to the unresolved or resolved recorder. Updates results.scanned.
|
|
382
|
+
*/
|
|
383
|
+
async function scanUnit(unit, fetcher, timeoutMs, results, threshold, failOnUnresolved) {
|
|
384
|
+
const unitCandidates = unit.names.flat();
|
|
385
|
+
const body = buildUnitBody(unit.names);
|
|
386
|
+
|
|
387
|
+
let advisoriesByName = null;
|
|
388
|
+
let networkError = null;
|
|
389
|
+
try {
|
|
390
|
+
advisoriesByName = await fetcher(unit.registryBase, body, timeoutMs);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
networkError = e;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const malformed = advisoriesByName !== null
|
|
396
|
+
&& (typeof advisoriesByName !== 'object' || Array.isArray(advisoriesByName));
|
|
397
|
+
if (networkError || advisoriesByName === null || malformed) {
|
|
398
|
+
let reason;
|
|
399
|
+
if (networkError) {
|
|
400
|
+
reason = `registry unreachable (${networkError.message})`;
|
|
401
|
+
} else if (malformed) {
|
|
402
|
+
reason = 'registry returned a malformed advisory response';
|
|
403
|
+
} else {
|
|
404
|
+
reason = 'registry does not support the bulk advisory endpoint';
|
|
405
|
+
}
|
|
406
|
+
recordUnresolvedUnit(unitCandidates, reason, results, failOnUnresolved);
|
|
407
|
+
} else {
|
|
408
|
+
recordResolvedUnit(unitCandidates, advisoriesByName, results, threshold);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
results.scanned += unitCandidates.length;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Map items through an async fn with a concurrency cap, preserving input order.
|
|
416
|
+
* (Mirrors the private helper in checker.js — kept local to keep the modules decoupled.)
|
|
417
|
+
*/
|
|
418
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
419
|
+
const results = new Array(items.length);
|
|
420
|
+
let next = 0;
|
|
421
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length || 1)) }, async () => {
|
|
422
|
+
while (next < items.length) {
|
|
423
|
+
const index = next++;
|
|
424
|
+
results[index] = await fn(items[index], index);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
await Promise.all(workers);
|
|
428
|
+
return results;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Default transport: POST the bulk advisory request for one registry.
|
|
433
|
+
* Resolves the advisories-by-name object ({} when nothing is vulnerable),
|
|
434
|
+
* null when the endpoint 404s (registry doesn't support it), and rejects on
|
|
435
|
+
* network errors / timeouts.
|
|
436
|
+
*/
|
|
437
|
+
function fetchBulkAdvisories(registryBase, bodyObject, timeoutMs) {
|
|
438
|
+
// Strip trailing slashes without a regex (linear scan, no backtracking).
|
|
439
|
+
let base = registryBase;
|
|
440
|
+
while (base.endsWith('/')) base = base.slice(0, -1);
|
|
441
|
+
const url = `${base}/-/npm/v1/security/advisories/bulk`;
|
|
442
|
+
return postJson(url, bodyObject, timeoutMs);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Scan locked packages for known vulnerabilities via the registry bulk advisory endpoint.
|
|
447
|
+
*
|
|
448
|
+
* Outcomes per entry:
|
|
449
|
+
* - vulnerable: registry returned ≥1 advisory for that name@version
|
|
450
|
+
* - clean: submitted, no advisories (obtained data, nothing found)
|
|
451
|
+
* - unresolved: registry unreachable or endpoint not supported — advisory data
|
|
452
|
+
* could not be obtained (FAILS the run by default; fail-closed)
|
|
453
|
+
* - skipped: not checkable this way (root/workspace/link/git/file/bundled, missing version)
|
|
454
|
+
*
|
|
455
|
+
* Each advisory at or above `minSeverity` is an error (fails the run); below it, a warning.
|
|
456
|
+
*
|
|
457
|
+
* @param {object} lockfileData - Parsed lockfile data (v2/v3)
|
|
458
|
+
* @param {object} options
|
|
459
|
+
* @param {number} options.concurrency - Parallel registry POSTs (default: 8)
|
|
460
|
+
* @param {number} options.timeoutMs - Per-request timeout (default: 10000)
|
|
461
|
+
* @param {string} options.defaultRegistry - Registry for entries without a derivable base
|
|
462
|
+
* @param {string} options.minSeverity - Threshold at/above which a finding fails the run (default: 'high')
|
|
463
|
+
* @param {number} options.batchSize - Max package names per bulk POST (default: 250)
|
|
464
|
+
* @param {boolean} options.offline - Skip all network; report everything as skipped
|
|
465
|
+
* @param {boolean} options.failOnUnresolved - Fail the run when advisory data can't be
|
|
466
|
+
* obtained (registry unreachable / endpoint unsupported). Default true (fail closed);
|
|
467
|
+
* set false to tolerate an incomplete scan.
|
|
468
|
+
* @param {Function} options.fetchAdvisories - Injectable (registryBase, body, timeoutMs) => Promise<object|null>
|
|
469
|
+
* @param {Function} options.onProgress - Progress callback
|
|
470
|
+
* @returns {Promise<object>} Results object with summary and details
|
|
471
|
+
*/
|
|
472
|
+
export async function checkVulnerabilities(lockfileData, options = {}) {
|
|
473
|
+
const {
|
|
474
|
+
concurrency = 8,
|
|
475
|
+
timeoutMs = 10000,
|
|
476
|
+
defaultRegistry = DEFAULT_REGISTRY,
|
|
477
|
+
minSeverity = 'high',
|
|
478
|
+
batchSize = 250,
|
|
479
|
+
offline = false,
|
|
480
|
+
failOnUnresolved = true, // fail closed: a scan that couldn't complete must not pass as "clean"
|
|
481
|
+
fetchAdvisories = null,
|
|
482
|
+
onProgress = null
|
|
483
|
+
} = options;
|
|
484
|
+
|
|
485
|
+
if (!(minSeverity.toLowerCase() in SEVERITY_RANK)) {
|
|
486
|
+
throw new VulnError(
|
|
487
|
+
`Invalid minSeverity "${minSeverity}"; use one of: ${Object.keys(SEVERITY_RANK).join(', ')}`,
|
|
488
|
+
'INVALID_SEVERITY'
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
const threshold = severityRank(minSeverity);
|
|
492
|
+
|
|
493
|
+
// A non-positive / non-integer batchSize makes the name-slicing loop never
|
|
494
|
+
// advance (infinite loop). Reject it up front, mirroring the minSeverity guard.
|
|
495
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) {
|
|
496
|
+
throw new VulnError(
|
|
497
|
+
`Invalid batchSize "${batchSize}"; must be a positive integer`,
|
|
498
|
+
'INVALID_BATCH_SIZE'
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (lockfileData && lockfileData.lockfileVersion === 1) {
|
|
503
|
+
throw new VulnError(
|
|
504
|
+
'v1 lockfiles are not supported; run `npm-check migrate 3` first',
|
|
505
|
+
'UNSUPPORTED_VERSION'
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const results = {
|
|
510
|
+
valid: true,
|
|
511
|
+
scanned: 0,
|
|
512
|
+
vulnerable: 0,
|
|
513
|
+
clean: 0,
|
|
514
|
+
unresolved: 0,
|
|
515
|
+
skipped: 0,
|
|
516
|
+
errors: [],
|
|
517
|
+
warnings: [],
|
|
518
|
+
unresolvedItems: [],
|
|
519
|
+
details: []
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// Collect verifiable candidates (mirror checker.js skip logic).
|
|
523
|
+
const candidates = collectCandidates(lockfileData, results, defaultRegistry);
|
|
524
|
+
|
|
525
|
+
// Offline: nothing left to do — count remaining candidates as skipped.
|
|
526
|
+
if (offline) {
|
|
527
|
+
results.skipped += candidates.length;
|
|
528
|
+
return results;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const fetcher = fetchAdvisories || fetchBulkAdvisories;
|
|
532
|
+
|
|
533
|
+
// Group candidates by registry/name into POST units of at most `batchSize` names.
|
|
534
|
+
const units = buildUnits(candidates, batchSize);
|
|
535
|
+
|
|
536
|
+
const reporter = onProgress ? createProgressReporter(units.length, {
|
|
537
|
+
onProgress,
|
|
538
|
+
stage: 'Scanning for known vulnerabilities'
|
|
539
|
+
}) : null;
|
|
540
|
+
let completed = 0;
|
|
541
|
+
|
|
542
|
+
await mapWithConcurrency(units, concurrency, async (unit) => {
|
|
543
|
+
await scanUnit(unit, fetcher, timeoutMs, results, threshold, failOnUnresolved);
|
|
544
|
+
completed++;
|
|
545
|
+
if (reporter) reporter.update(completed);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
if (reporter) reporter.finish();
|
|
549
|
+
|
|
550
|
+
return results;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Map one advisory finding (from results.errors/warnings) into the suite's shared
|
|
555
|
+
* Finding shape. The advisory's TRUE severity is already the ladder vocabulary
|
|
556
|
+
* (info|low|moderate|high|critical), so it becomes the top-level `severity`
|
|
557
|
+
* verbatim; the advisory payload rides under `extra` per the vuln-tool contract.
|
|
558
|
+
*/
|
|
559
|
+
function toSchemaFinding(f) {
|
|
560
|
+
return {
|
|
561
|
+
severity: (f.severity || 'low').toLowerCase(),
|
|
562
|
+
ruleId: f.advisoryId != null ? String(f.advisoryId) : 'NPM-ADVISORY',
|
|
563
|
+
category: 'vulnerability',
|
|
564
|
+
message: f.title,
|
|
565
|
+
location: null, // a package advisory is not file-scoped
|
|
566
|
+
remediation: f.fixedVersion ? `upgrade to ${f.fixedVersion}` : null,
|
|
567
|
+
extra: {
|
|
568
|
+
package: f.package,
|
|
569
|
+
installedVersion: f.version,
|
|
570
|
+
fixedVersion: f.fixedVersion ?? null,
|
|
571
|
+
advisoryId: f.advisoryId ?? null,
|
|
572
|
+
cve: f.cve ?? null,
|
|
573
|
+
vulnerableRange: f.vulnerableRange ?? null,
|
|
574
|
+
references: referencesOf(f)
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Wrap a checkVulnerabilities() result in the shared finding-schema envelope.
|
|
581
|
+
* `findings` is the COMPLETE list of advisory findings (errors with an advisoryId
|
|
582
|
+
* plus warnings); scan-completeness state (unresolved/skipped/clean, which drives
|
|
583
|
+
* the fail-closed gate) is preserved under `extra.scan` so nothing is lost.
|
|
584
|
+
*
|
|
585
|
+
* @param {object} result - a checkVulnerabilities() result
|
|
586
|
+
* @param {object} meta
|
|
587
|
+
* @param {string} meta.target - the lockfile path scanned, as given
|
|
588
|
+
* @param {number} meta.exitCode - the real process exit code (0/1/2)
|
|
589
|
+
* @returns {object} the shared envelope
|
|
590
|
+
*/
|
|
591
|
+
export function vulnEnvelope(result, { target, exitCode }) {
|
|
592
|
+
// errors holds BOTH advisory findings and unresolved items; the latter carry a
|
|
593
|
+
// `reason` (and no advisory payload). Discriminate on `reason` — not on
|
|
594
|
+
// `advisoryId` — so an advisory that merely lacks an `id` still reaches the
|
|
595
|
+
// envelope (it falls back to the 'NPM-ADVISORY' ruleId) instead of vanishing
|
|
596
|
+
// while the run still exits non-zero (issue #24). Mirrors deprecationEnvelope.
|
|
597
|
+
const advisories = [
|
|
598
|
+
...result.errors.filter((e) => !e.reason),
|
|
599
|
+
...result.warnings
|
|
600
|
+
];
|
|
601
|
+
const findings = advisories.map(toSchemaFinding);
|
|
602
|
+
return buildEnvelope({
|
|
603
|
+
target,
|
|
604
|
+
scanned: result.scanned,
|
|
605
|
+
findings,
|
|
606
|
+
exitCode,
|
|
607
|
+
extra: {
|
|
608
|
+
scan: {
|
|
609
|
+
vulnerable: result.vulnerable,
|
|
610
|
+
clean: result.clean,
|
|
611
|
+
unresolved: result.unresolved,
|
|
612
|
+
skipped: result.skipped,
|
|
613
|
+
valid: result.valid,
|
|
614
|
+
unresolvedItems: result.unresolvedItems
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker thread for deduplicating packages
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parentPort } from 'worker_threads';
|
|
6
|
+
import { deduplicatePackages } from '../updater.js';
|
|
7
|
+
|
|
8
|
+
parentPort.on('message', async (task) => {
|
|
9
|
+
try {
|
|
10
|
+
const { chunk, options } = task;
|
|
11
|
+
const result = deduplicatePackages(chunk, options);
|
|
12
|
+
parentPort.postMessage({ success: true, result, chunkIndex: task.chunkIndex });
|
|
13
|
+
} catch (error) {
|
|
14
|
+
parentPort.postMessage({
|
|
15
|
+
success: false,
|
|
16
|
+
error: error.message,
|
|
17
|
+
chunkIndex: task.chunkIndex
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker thread for upgrading integrity hashes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parentPort } from 'worker_threads';
|
|
6
|
+
import { upgradeIntegrityHashes } from '../updater.js';
|
|
7
|
+
|
|
8
|
+
parentPort.on('message', async (task) => {
|
|
9
|
+
try {
|
|
10
|
+
const { chunk, options } = task;
|
|
11
|
+
const result = upgradeIntegrityHashes(chunk, options);
|
|
12
|
+
parentPort.postMessage({ success: true, result, chunkIndex: task.chunkIndex });
|
|
13
|
+
} catch (error) {
|
|
14
|
+
parentPort.postMessage({
|
|
15
|
+
success: false,
|
|
16
|
+
error: error.message,
|
|
17
|
+
chunkIndex: task.chunkIndex
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker thread for migrating lockfile versions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parentPort } from 'worker_threads';
|
|
6
|
+
import { migrateToVersion } from '../migrator.js';
|
|
7
|
+
|
|
8
|
+
parentPort.on('message', async (task) => {
|
|
9
|
+
try {
|
|
10
|
+
const { chunk, options } = task;
|
|
11
|
+
const { targetVersion } = options;
|
|
12
|
+
const result = migrateToVersion(chunk, targetVersion);
|
|
13
|
+
parentPort.postMessage({ success: true, result, chunkIndex: task.chunkIndex });
|
|
14
|
+
} catch (error) {
|
|
15
|
+
parentPort.postMessage({
|
|
16
|
+
success: false,
|
|
17
|
+
error: error.message,
|
|
18
|
+
chunkIndex: task.chunkIndex
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|