@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/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
+ });