@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/checker.js ADDED
@@ -0,0 +1,771 @@
1
+ /**
2
+ * Checker module for verifying package integrity hashes and licenses
3
+ * Provides comprehensive validation of installed packages against lockfile
4
+ */
5
+
6
+ import crypto from 'crypto';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import { createProgressReporter } from './progress-reporter.js';
10
+ import { forEachPackageEntry, detectLockfileFlavor } from './format-library.js';
11
+ import { fetchPackumentIntegrity, DEFAULT_REGISTRY } from './integrity.js';
12
+
13
+ /**
14
+ * Custom error class for checker operations
15
+ */
16
+ export class CheckError extends Error {
17
+ constructor(message, code, context = {}) {
18
+ super(message);
19
+ this.name = 'CheckError';
20
+ this.code = code;
21
+ this.context = context;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Hash a package directory to verify integrity
27
+ * Matches npm's tarball hashing approach
28
+ * @param {string} pkgDir - Path to package directory
29
+ * @returns {Promise<string>} Integrity hash in sha512-<base64> format
30
+ */
31
+ export async function hashPackageDirectory(pkgDir) {
32
+ try {
33
+ // Collect all files in the package (excluding node_modules, etc)
34
+ const filesToHash = await collectPackageFiles(pkgDir);
35
+
36
+ // Sort files for consistent hashing
37
+ filesToHash.sort();
38
+
39
+ // Create hash
40
+ const hash = crypto.createHash('sha512');
41
+
42
+ for (const file of filesToHash) {
43
+ const fullPath = path.join(pkgDir, file);
44
+ try {
45
+ const content = fs.readFileSync(fullPath);
46
+ // Include filename and content in hash for consistency
47
+ hash.update(file);
48
+ hash.update(content);
49
+ } catch {
50
+ // Skip files that can't be read
51
+ continue;
52
+ }
53
+ }
54
+
55
+ const digest = hash.digest('base64');
56
+ return `sha512-${digest}`;
57
+ } catch (e) {
58
+ throw new CheckError(
59
+ `Failed to hash package directory: ${e.message}`,
60
+ 'HASH_FAILURE',
61
+ { pkgDir }
62
+ );
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Collect all files in a package directory for hashing
68
+ * Excludes node_modules, tests, and build artifacts
69
+ * @param {string} pkgDir - Package directory path
70
+ * @returns {Promise<string[]>} Array of relative file paths
71
+ */
72
+ export async function collectPackageFiles(pkgDir) {
73
+ const files = [];
74
+ const excludeDirs = new Set(['node_modules', '.git', 'test', 'tests', '__tests__', '.github', '.nyc_output', 'coverage', 'dist', 'build']);
75
+ const excludeFiles = new Set(['.DS_Store', '.gitignore', '.npmignore', 'thumbs.db']);
76
+
77
+ function walkDir(dir, baseDir = '') {
78
+ try {
79
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
80
+
81
+ for (const entry of entries) {
82
+ const relativePath = baseDir ? path.join(baseDir, entry.name) : entry.name;
83
+
84
+ if (entry.isDirectory()) {
85
+ if (!excludeDirs.has(entry.name)) {
86
+ walkDir(path.join(dir, entry.name), relativePath);
87
+ }
88
+ } else if (entry.isFile()) {
89
+ if (!excludeFiles.has(entry.name)) {
90
+ files.push(relativePath);
91
+ }
92
+ }
93
+ }
94
+ } catch {
95
+ // Skip directories we can't read
96
+ }
97
+ }
98
+
99
+ walkDir(pkgDir);
100
+ return files;
101
+ }
102
+
103
+ /**
104
+ * Map items through an async fn with a concurrency cap, preserving input order.
105
+ */
106
+ async function mapWithConcurrency(items, limit, fn) {
107
+ const results = new Array(items.length);
108
+ let next = 0;
109
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length || 1)) }, async () => {
110
+ while (next < items.length) {
111
+ const index = next++;
112
+ results[index] = await fn(items[index], index);
113
+ }
114
+ });
115
+ await Promise.all(workers);
116
+ return results;
117
+ }
118
+
119
+ /**
120
+ * Evaluate an SPDX license expression against an approved set.
121
+ * Implements proper SPDX operator precedence: AND binds tighter than OR.
122
+ * Handles nested parentheses via recursive descent.
123
+ *
124
+ * Grammar:
125
+ * expr := andExpr (' OR ' andExpr)*
126
+ * andExpr := atom (' AND ' atom)*
127
+ * atom := '(' expr ')' | licenseId
128
+ *
129
+ * Fails closed (returns false) on empty identifiers, parse errors, or any
130
+ * trailing unconsumed input.
131
+ *
132
+ * @param {string} licenseExpr - SPDX license expression
133
+ * @param {Set<string>} approvedSet - Set of approved license identifiers
134
+ * @returns {boolean} True if the expression is approved
135
+ */
136
+ // Recursive-descent SPDX evaluator. The three functions share a mutable cursor
137
+ // `state = { src, pos }` and are module-level (not nested closures) to keep each
138
+ // one's cognitive complexity low. Grammar: expr := andExpr (' OR ' andExpr)*;
139
+ // andExpr := atom (' AND ' atom)*; atom := '(' expr ')' | licenseId.
140
+ function spdxAtom(state, approvedSet) {
141
+ const { src } = state;
142
+ if (state.pos < src.length && src[state.pos] === '(') {
143
+ state.pos++; // consume '('
144
+ const result = spdxExpr(state, approvedSet);
145
+ if (state.pos < src.length && src[state.pos] === ')') state.pos++; // consume ')'
146
+ return result;
147
+ }
148
+ // Consume a license identifier; terminates at ' OR ', ' AND ', ')', or end.
149
+ const start = state.pos;
150
+ while (
151
+ state.pos < src.length &&
152
+ src[state.pos] !== ')' &&
153
+ !src.startsWith(' OR ', state.pos) &&
154
+ !src.startsWith(' AND ', state.pos)
155
+ ) {
156
+ state.pos++;
157
+ }
158
+ const id = src.slice(start, state.pos).trim();
159
+ return id.length > 0 && approvedSet.has(id);
160
+ }
161
+
162
+ function spdxAndExpr(state, approvedSet) {
163
+ let result = spdxAtom(state, approvedSet);
164
+ while (state.pos < state.src.length && state.src.startsWith(' AND ', state.pos)) {
165
+ state.pos += 5; // consume ' AND '
166
+ const right = spdxAtom(state, approvedSet); // always consume to advance pos
167
+ result = result && right;
168
+ }
169
+ return result;
170
+ }
171
+
172
+ function spdxExpr(state, approvedSet) {
173
+ let result = spdxAndExpr(state, approvedSet);
174
+ while (state.pos < state.src.length && state.src.startsWith(' OR ', state.pos)) {
175
+ state.pos += 4; // consume ' OR '
176
+ const right = spdxAndExpr(state, approvedSet); // always consume to advance pos
177
+ result = result || right;
178
+ }
179
+ return result;
180
+ }
181
+
182
+ function isLicenseApproved(licenseExpr, approvedSet) {
183
+ if (typeof licenseExpr !== 'string' || !licenseExpr.trim()) return false;
184
+ const state = { src: licenseExpr.trim(), pos: 0 };
185
+ try {
186
+ const result = spdxExpr(state, approvedSet);
187
+ // Fail closed unless the ENTIRE expression was consumed. Trailing tokens
188
+ // (e.g. 'MIT ) AND GPL-3.0-only') mean a malformed expression whose
189
+ // unevaluated remainder might contain a rejected license — a compliance
190
+ // gate must not approve it.
191
+ return state.pos === state.src.length ? result : false;
192
+ } catch {
193
+ return false; // fail closed on any parse error
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Normalize a license field value from a package.json to a plain string.
199
+ * Handles the legacy object form ({ type: "MIT" }) and the legacy "licenses"
200
+ * array ([{ type: "MIT" }, { type: "ISC" }]) that older packages used before
201
+ * the SPDX string form became the standard.
202
+ *
203
+ * @param {*} licenseField - Value of the "license" field (may be string, object, or absent)
204
+ * @param {*} licensesArray - Value of the legacy "licenses" array field (may be array or absent)
205
+ * @returns {string|null} SPDX string, or null if unresolvable
206
+ */
207
+ function normalizeLicenseField(licenseField, licensesArray) {
208
+ // Normal case: already a string
209
+ if (typeof licenseField === 'string') return licenseField;
210
+
211
+ // Legacy object form: { type: "MIT", url: "..." }
212
+ if (licenseField !== null && typeof licenseField === 'object' && !Array.isArray(licenseField)) {
213
+ return typeof licenseField.type === 'string' ? licenseField.type : null;
214
+ }
215
+
216
+ // Legacy "licenses" array: [{ type: "MIT" }, { type: "ISC" }]
217
+ if (Array.isArray(licensesArray) && licensesArray.length > 0) {
218
+ const types = licensesArray
219
+ .map(l => (l !== null && typeof l === 'object' ? l.type : l))
220
+ .filter(t => typeof t === 'string');
221
+ if (types.length > 0) return types.join(' OR ');
222
+ }
223
+
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * Verify license for a single package
229
+ * @param {string} packagePath - Package path from lockfile
230
+ * @param {Set<string>} approvedLicenses - Set of approved license identifiers
231
+ * @param {string} nodeModulesPath - Path to node_modules directory
232
+ * @param {boolean} strict - Treat unknown licenses as errors
233
+ * @param {object} pkgData - Package data from lockfile (optional)
234
+ * @returns {Promise<object>} Verification result
235
+ */
236
+ async function verifyPackageLicense(packagePath, approvedLicenses, nodeModulesPath, strict, pkgData) {
237
+ // Skip root package
238
+ if (packagePath === '') {
239
+ return { valid: true, skipped: true, package: 'root' };
240
+ }
241
+
242
+ // Skip workspace packages (those not in node_modules or with link: true)
243
+ if (pkgData && (pkgData.link === true || (!packagePath.startsWith('node_modules/')))) {
244
+ return { valid: true, skipped: true, package: packagePath, reason: 'workspace-link' };
245
+ }
246
+
247
+ const pkgName = packagePath.replace(/^node_modules\//, '');
248
+ const pkgJsonPath = path.join(nodeModulesPath, pkgName, 'package.json');
249
+
250
+ // Resolve the license preferring the installed package.json, but falling back
251
+ // to the lockfile's own `license` field when the package isn't on disk (a
252
+ // partial node_modules) or omits it. This keeps the license check lockfile-
253
+ // first — consistent with the integrity/vuln/deprecated checks, which all
254
+ // work without a full install — instead of reporting UNKNOWN for every
255
+ // uninstalled entry whose license the lockfile already records.
256
+ let license;
257
+ const pkgJsonExists = fs.existsSync(pkgJsonPath);
258
+ if (pkgJsonExists) {
259
+ try {
260
+ // Normalize handles the string, object ({ type }), and legacy "licenses"
261
+ // array forms without crashing on non-string values.
262
+ const parsed = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
263
+ license = normalizeLicenseField(parsed.license, parsed.licenses);
264
+ } catch (e) {
265
+ return { valid: false, error: e.message, package: pkgName };
266
+ }
267
+ }
268
+ if (!license && pkgData && (pkgData.license || pkgData.licenses)) {
269
+ license = normalizeLicenseField(pkgData.license, pkgData.licenses);
270
+ }
271
+
272
+ if (!license) {
273
+ return {
274
+ valid: !strict,
275
+ package: pkgName,
276
+ license: 'UNKNOWN',
277
+ approved: false,
278
+ reason: pkgJsonExists ? 'no-license' : 'package-json-not-found'
279
+ };
280
+ }
281
+
282
+ const isApproved = isLicenseApproved(license, approvedLicenses);
283
+ return {
284
+ valid: isApproved,
285
+ package: pkgName,
286
+ license,
287
+ approved: isApproved
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Parse approved licenses CSV file
293
+ * Format: license,category,notes
294
+ * @param {string} csvPath - Path to CSV file
295
+ * @returns {Promise<Set<string>>} Set of approved license identifiers
296
+ */
297
+ export async function parseLicensesCsv(csvPath) {
298
+ if (!fs.existsSync(csvPath)) {
299
+ throw new CheckError(
300
+ `Approved licenses file not found: ${csvPath}`,
301
+ 'LICENSES_CSV_NOT_FOUND',
302
+ { csvPath }
303
+ );
304
+ }
305
+
306
+ try {
307
+ const content = fs.readFileSync(csvPath, 'utf8');
308
+ const lines = content.split('\n')
309
+ .map(line => line.trim())
310
+ .filter(line => line && !line.startsWith('#'));
311
+
312
+ // Detect if first line is a header by checking for common header names or pattern
313
+ const HEADER_PATTERN = /^(license|spdx|identifier|name)/i;
314
+ let dataLines = lines;
315
+ if (lines.length > 0) {
316
+ const firstLine = lines[0];
317
+ const firstToken = firstLine.split(',')[0].trim();
318
+ // It's a header if the first token matches common header names, or if line has multiple commas and contains "license"
319
+ const isHeader = HEADER_PATTERN.test(firstToken) || (firstLine.includes(',') && firstLine.includes('license'));
320
+ if (isHeader) {
321
+ dataLines = lines.slice(1);
322
+ }
323
+ }
324
+
325
+ const approvedSet = new Set();
326
+
327
+ for (const line of dataLines) {
328
+ const parts = line.split(',');
329
+ const license = parts[0].trim();
330
+ if (license) {
331
+ approvedSet.add(license);
332
+ }
333
+ }
334
+
335
+ return approvedSet;
336
+ } catch (e) {
337
+ throw new CheckError(
338
+ `Failed to parse licenses CSV: ${e.message}`,
339
+ 'CSV_PARSE_ERROR',
340
+ { csvPath }
341
+ );
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Extract the sha512 component from an SSRI integrity string.
347
+ * An SSRI string may carry multiple space-separated hashes (multi-hash), e.g.
348
+ * 'sha512-ABC== sha256-DEF='. The registry always publishes a single sha512 token,
349
+ * so this is the canonical basis for comparison.
350
+ *
351
+ * NOTE: this helper is local to checker.js. A validator.js agent (#21) owns the
352
+ * authoritative SSRI accept/reject logic in integrity.js; this is a deliberately
353
+ * small duplicate kept in-module to avoid touching files out of scope for this fix.
354
+ *
355
+ * @param {*} integrity - Integrity field value (may be non-string)
356
+ * @returns {string|null} The sha512-prefixed token, or null if absent
357
+ */
358
+ function extractSha512Component(integrity) {
359
+ if (typeof integrity !== 'string') return null;
360
+ for (const token of integrity.split(/\s+/)) {
361
+ if (token.startsWith('sha512-')) return token;
362
+ }
363
+ return null;
364
+ }
365
+
366
+ /**
367
+ * Extract ALL sha512 tokens from an SSRI integrity string. npm/ssri accepts a
368
+ * tarball matching ANY digest of the strongest algorithm present, so a lockfile
369
+ * carrying more than one sha512 token ('sha512-GOOD sha512-EVIL') would let npm
370
+ * accept a tarball hashing to EITHER. Tamper detection must therefore verify
371
+ * every sha512 token against the single hash the registry publishes.
372
+ * @param {*} integrity - Integrity field value (may be non-string)
373
+ * @returns {string[]} All sha512-prefixed tokens (possibly empty)
374
+ */
375
+ function extractAllSha512Components(integrity) {
376
+ if (typeof integrity !== 'string') return [];
377
+ return integrity.split(/\s+/).filter(token => token.startsWith('sha512-'));
378
+ }
379
+
380
+ /**
381
+ * Decide whether a package entry can be verified against the registry.
382
+ * Returns true for verifiable entries; non-verifiable entries are counted as
383
+ * skipped (root/workspace/link/git/file/bundled, missing integrity/version, or
384
+ * no sha512 component to compare to the registry's sha512).
385
+ * @param {object} info - Entry classification from forEachPackageEntry
386
+ * @returns {boolean} True if the entry should be verified
387
+ */
388
+ function isVerifiableEntry(info) {
389
+ const { entry, isRoot, isWorkspaceSource, isLink, isBundled, isGitDep, isFileDep } = info;
390
+ if (isRoot || isWorkspaceSource || isLink) return false;
391
+ if (!entry.integrity) return false; // nothing locked to verify (integrity-hygiene flags this)
392
+ if (isBundled || isGitDep || isFileDep) return false; // no registry tarball integrity
393
+ // Skip when there is no sha512 component: sha1-only and sha256-only hashes cannot be
394
+ // compared to the registry's sha512 (would be a guaranteed false 'tampered' alarm).
395
+ // Multi-hash strings that include sha512 ('sha512-X sha256-Y') pass through and are
396
+ // verified by extracting their sha512 component in recordIntegrityResult.
397
+ if (!extractSha512Component(entry.integrity)) return false;
398
+ // a version is required to query the registry
399
+ return Boolean(entry.version);
400
+ }
401
+
402
+ /**
403
+ * Collect the entries that are verifiable against the registry, tallying every
404
+ * non-verifiable entry into results.skipped.
405
+ * @param {object} lockfileData - Parsed lockfile data
406
+ * @param {object} results - Results accumulator (skipped is incremented)
407
+ * @returns {Array<object>} Candidate entries ({ key, entry, name })
408
+ */
409
+ function collectIntegrityCandidates(lockfileData, results) {
410
+ const candidates = [];
411
+ forEachPackageEntry(lockfileData, (info) => {
412
+ if (!isVerifiableEntry(info)) {
413
+ results.skipped++;
414
+ return;
415
+ }
416
+ candidates.push({ key: info.key, entry: info.entry, name: info.name, registryBase: info.registryBase });
417
+ });
418
+ return candidates;
419
+ }
420
+
421
+ /**
422
+ * Resolve the host from a registry base for allowlist enforcement.
423
+ * @param {string} registryBase - Registry base URL
424
+ * @returns {string|null} Host, or null if unparseable
425
+ */
426
+ function resolveRegistryHost(registryBase) {
427
+ try {
428
+ return new URL(registryBase).host;
429
+ } catch {
430
+ return null; // unparseable → treat as untrusted
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Record an integrity failure (mismatch or untrusted host) into the results.
436
+ * @param {object} results - Results accumulator
437
+ * @param {object} item - Failure detail (already includes valid: false)
438
+ */
439
+ function recordIntegrityFailure(results, item) {
440
+ results.failed++;
441
+ results.valid = false;
442
+ results.errors.push(item);
443
+ results.details.push(item);
444
+ }
445
+
446
+ /**
447
+ * Record an unresolved entry (registry unreachable or no sha512 published) — i.e.
448
+ * the authoritative hash could not be obtained, so integrity could not be verified.
449
+ * Fails the run when failOnUnresolved (the default), so a registry outage can never
450
+ * be mistaken for "integrity verified".
451
+ * @param {object} results - Results accumulator
452
+ * @param {object} item - Unresolved detail ({ package, version, packagePath, reason })
453
+ * @param {boolean} failOnUnresolved - Promote unresolved entries to failures
454
+ */
455
+ function recordIntegrityUnresolved(results, item, failOnUnresolved) {
456
+ results.unresolved++;
457
+ results.unresolvedItems.push(item);
458
+ results.details.push({ valid: !failOnUnresolved, unresolved: true, ...item });
459
+ if (failOnUnresolved) {
460
+ results.failed++;
461
+ results.valid = false;
462
+ results.errors.push(item);
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Classify a fetched registry hash for a candidate and record the outcome.
468
+ * @param {object} results - Results accumulator
469
+ * @param {object} candidate - { key, entry, name }
470
+ * @param {string|null} registryHash - Hash fetched from the registry
471
+ * @param {Error|null} networkError - Error thrown by the fetcher, if any
472
+ * @param {boolean} failOnUnresolved - Promote unresolved entries to failures
473
+ */
474
+ function recordIntegrityResult(results, candidate, registryHash, networkError, failOnUnresolved) {
475
+ const { key, entry, name } = candidate;
476
+ const base = { package: name, version: entry.version, packagePath: key };
477
+
478
+ if (networkError) {
479
+ recordIntegrityUnresolved(results, { ...base, reason: `registry unreachable (${networkError.message})` }, failOnUnresolved);
480
+ } else if (!registryHash) {
481
+ recordIntegrityUnresolved(results, { ...base, reason: `registry has no sha512 integrity for ${name}@${entry.version}` }, failOnUnresolved);
482
+ } else {
483
+ // SSRI-aware compare: the lockfile may carry a multi-hash string
484
+ // ('sha512-A sha256-B') but the registry always publishes a single sha512
485
+ // token, so a sha256/sha1 sibling must not trigger a false 'tampered'.
486
+ // Crucially, EVERY sha512 token must equal the registry's: npm accepts a
487
+ // tarball matching any sha512 present, so a second, non-registry sha512
488
+ // ('sha512-GOOD sha512-EVIL') is a tamper vector and must fail.
489
+ const lockedSha512s = extractAllSha512Components(entry.integrity);
490
+ const registrySha512 = extractSha512Component(registryHash) ?? registryHash;
491
+ const allMatch = lockedSha512s.length > 0 && lockedSha512s.every(t => t === registrySha512);
492
+ if (allMatch) {
493
+ results.passed++;
494
+ results.details.push({ valid: true, package: name, packagePath: key, expected: registrySha512, actual: lockedSha512s.join(' ') });
495
+ } else {
496
+ recordIntegrityFailure(results, { valid: false, package: name, packagePath: key, expected: registrySha512, actual: lockedSha512s.join(' ') || entry.integrity });
497
+ }
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Verify a single candidate entry against the registry and record the outcome.
503
+ * Enforces the host allowlist (an untrusted resolved host is failed outright),
504
+ * then fetches the registry hash and classifies the result.
505
+ * @param {object} candidate - { key, entry, name }
506
+ * @param {object} ctx - Shared context (results, fetcher, defaultRegistry, hostAllowlist, failOnUnresolved)
507
+ * @returns {Promise<void>}
508
+ */
509
+ async function verifyIntegrityCandidate(candidate, ctx) {
510
+ const { key, entry, name } = candidate;
511
+ const { results, fetcher, defaultRegistry, hostAllowlist, failOnUnresolved } = ctx;
512
+ const registryBase = candidate.registryBase || defaultRegistry;
513
+
514
+ // Trust-anchor enforcement: never verify a hash against a host the operator
515
+ // hasn't trusted — a tampered lockfile would just point `resolved` at its own server.
516
+ if (hostAllowlist) {
517
+ const host = resolveRegistryHost(registryBase);
518
+ if (!host || !hostAllowlist.has(host)) {
519
+ recordIntegrityFailure(results, { valid: false, package: name, version: entry.version, packagePath: key, reason: `untrusted registry host "${host || registryBase}" (not in allowedHosts) — refusing to trust its integrity hash` });
520
+ return;
521
+ }
522
+ }
523
+
524
+ let registryHash = null;
525
+ let networkError = null;
526
+ try {
527
+ registryHash = await fetcher(name, entry.version, registryBase);
528
+ } catch (e) {
529
+ networkError = e;
530
+ }
531
+
532
+ recordIntegrityResult(results, candidate, registryHash, networkError, failOnUnresolved);
533
+ }
534
+
535
+ /**
536
+ * Verify lockfile integrity hashes against the authoritative registry.
537
+ *
538
+ * For each registry-resolved package entry, the locked `integrity` is compared
539
+ * to the `dist.integrity` published by the registry (the registry base is
540
+ * derived per-package from the entry's `resolved` URL, so private registries
541
+ * work). This detects a tampered or drifted lockfile WITHOUT needing
542
+ * node_modules — and, unlike a directory hash, it actually matches npm's
543
+ * tarball integrity.
544
+ *
545
+ * Outcomes per entry:
546
+ * - passed: locked hash matches the registry hash
547
+ * - failed: locked hash differs from the registry hash (the real tamper signal)
548
+ * - skipped: not verifiable this way (root/workspace/link/git/file/bundled,
549
+ * missing integrity, or a legacy sha1 hash)
550
+ * - unresolved: registry unreachable or has no sha512 for that version
551
+ *
552
+ * `valid` is false on mismatches (failed > 0) AND, by default, on unresolved
553
+ * entries — verification that could not complete must not pass as "verified"
554
+ * (fail closed). Pass `failOnUnresolved: false` to tolerate a flaky registry and
555
+ * keep unresolved entries non-fatal.
556
+ *
557
+ * @param {object} lockfileData - Parsed lockfile data (v2/v3)
558
+ * @param {object} options
559
+ * @param {number} options.concurrency - Parallel registry requests (default: 8)
560
+ * @param {number} options.timeoutMs - Per-request timeout (default: 10000)
561
+ * @param {string} options.defaultRegistry - Registry for entries without a derivable base
562
+ * @param {boolean} options.failOnUnresolved - Fail the run when the registry hash can't
563
+ * be obtained. Default true (fail closed); set false to keep unresolved non-fatal.
564
+ * @param {Function} options.fetchIntegrity - Injectable (name, version, registryBase) => Promise<string|null>
565
+ * @param {Function} options.onProgress - Progress callback
566
+ * @returns {Promise<object>} Results object with summary and details
567
+ */
568
+ export async function checkIntegrity(lockfileData, options = {}) {
569
+ const {
570
+ concurrency = 8,
571
+ timeoutMs = 10000,
572
+ defaultRegistry = DEFAULT_REGISTRY,
573
+ failOnUnresolved = true, // fail closed: verification that couldn't complete must not pass as "verified"
574
+ // Operator-pinned trusted registry hosts. The authoritative hash is fetched
575
+ // from the host named in the lockfile's own `resolved` URL — so a tampered
576
+ // lockfile could point at an attacker host that returns a matching hash. When
577
+ // this allowlist is set, an entry resolving from a non-listed host is FAILED
578
+ // (not verified against it), closing that self-referential-trust gap.
579
+ allowedHosts = null,
580
+ fetchIntegrity = null,
581
+ onProgress = null
582
+ } = options;
583
+ const hostAllowlist = Array.isArray(allowedHosts) && allowedHosts.length ? new Set(allowedHosts) : null;
584
+
585
+ if (lockfileData && lockfileData.lockfileVersion === 1) {
586
+ throw new CheckError(
587
+ 'v1 lockfiles have no integrity to verify; run `npm-check migrate 3` first',
588
+ 'UNSUPPORTED_VERSION'
589
+ );
590
+ }
591
+
592
+ const fetcher = fetchIntegrity ||
593
+ ((name, ver, registryBase) => fetchPackumentIntegrity(name, ver, { registryBase, timeoutMs }));
594
+
595
+ const results = {
596
+ valid: true,
597
+ checked: 0,
598
+ passed: 0,
599
+ failed: 0,
600
+ skipped: 0,
601
+ unresolved: 0,
602
+ errors: [],
603
+ unresolvedItems: [],
604
+ details: []
605
+ };
606
+
607
+ const candidates = collectIntegrityCandidates(lockfileData, results);
608
+
609
+ const total = candidates.length;
610
+ const reporter = onProgress ? createProgressReporter(total, {
611
+ onProgress,
612
+ stage: 'Verifying integrity against registry'
613
+ }) : null;
614
+
615
+ let completed = 0;
616
+ const markCompleted = () => {
617
+ completed++;
618
+ results.checked = completed;
619
+ if (reporter) reporter.update(completed);
620
+ };
621
+
622
+ const ctx = { results, fetcher, defaultRegistry, hostAllowlist, failOnUnresolved };
623
+ await mapWithConcurrency(candidates, concurrency, async (candidate) => {
624
+ await verifyIntegrityCandidate(candidate, ctx);
625
+ markCompleted();
626
+ });
627
+
628
+ if (reporter) reporter.finish();
629
+
630
+ return results;
631
+ }
632
+
633
+ /**
634
+ * Classify a single package's license verification result into the running
635
+ * tallies (approved/rejected/unknown) and the error/warning lists.
636
+ * @param {object} results - Results accumulator
637
+ * @param {object} result - Per-package result from verifyPackageLicense
638
+ * @param {boolean} strict - Treat unknown licenses as errors
639
+ */
640
+ function classifyLicenseResult(results, result, strict) {
641
+ results.details.push(result);
642
+ results.checked++;
643
+
644
+ if (result.skipped) {
645
+ return; // root/workspace-link packages aren't counted
646
+ }
647
+
648
+ if (result.license === 'UNKNOWN' || result.reason === 'no-license') {
649
+ // Handle unknown/missing licenses
650
+ results.unknown++;
651
+ if (strict) {
652
+ results.valid = false;
653
+ results.errors.push(result);
654
+ } else {
655
+ results.warnings.push(result);
656
+ }
657
+ return;
658
+ }
659
+
660
+ if (result.valid) {
661
+ results.approved++;
662
+ return;
663
+ }
664
+
665
+ results.rejected++;
666
+ results.valid = false;
667
+ results.errors.push(result);
668
+ }
669
+
670
+ /**
671
+ * Check licenses for all packages in lockfile against approved list
672
+ * @param {object} lockfileData - Parsed lockfile data
673
+ * @param {object} options - Options
674
+ * @param {string} options.csvPath - Path to approved licenses CSV
675
+ * @param {string} options.nodeModulesPath - Path to node_modules
676
+ * @param {boolean} options.strict - Treat unknown licenses as errors
677
+ * @param {Function} options.onProgress - Progress callback
678
+ * @returns {Promise<object>} Results object with summary and details
679
+ */
680
+ export async function checkLicenses(lockfileData, options = {}) {
681
+ const {
682
+ csvPath = './approved-licenses.csv',
683
+ nodeModulesPath = './node_modules',
684
+ strict = false,
685
+ onProgress = null
686
+ } = options;
687
+
688
+ // pnpm's flat `.pnpm` virtual store means license-by-node_modules path walking
689
+ // doesn't map directly — not supported yet (planned as a store-aware walk).
690
+ if (detectLockfileFlavor(lockfileData) === 'pnpm') {
691
+ throw new CheckError(
692
+ 'license verification is not supported for pnpm-lock.yaml yet',
693
+ 'PNPM_UNSUPPORTED'
694
+ );
695
+ }
696
+
697
+ // v1 lockfiles have no `packages` map — iterating `{}` would silently verify
698
+ // nothing and return valid:true (a false-clean pass). Mirror checkIntegrity.
699
+ if (lockfileData && lockfileData.lockfileVersion === 1) {
700
+ throw new CheckError(
701
+ 'v1 lockfiles have no packages map to check licenses against; run `npm-check migrate 3` first',
702
+ 'UNSUPPORTED_VERSION'
703
+ );
704
+ }
705
+
706
+ // Check if node_modules exists
707
+ if (!fs.existsSync(nodeModulesPath)) {
708
+ throw new CheckError(
709
+ `node_modules directory not found: ${nodeModulesPath}`,
710
+ 'NO_NODE_MODULES',
711
+ { nodeModulesPath }
712
+ );
713
+ }
714
+
715
+ // Parse approved licenses CSV
716
+ const approvedLicenses = await parseLicensesCsv(csvPath);
717
+
718
+ const packages = lockfileData.packages || {};
719
+ const entries = Object.entries(packages);
720
+ const total = entries.length;
721
+
722
+ const results = {
723
+ valid: true,
724
+ checked: 0,
725
+ approved: 0,
726
+ rejected: 0,
727
+ unknown: 0,
728
+ errors: [],
729
+ warnings: [],
730
+ details: []
731
+ };
732
+
733
+ // Create progress reporter
734
+ const reporter = onProgress ? createProgressReporter(total, {
735
+ onProgress,
736
+ stage: 'Checking licenses'
737
+ }) : null;
738
+
739
+ for (const [pkgPath, pkgData] of entries) {
740
+ const result = await verifyPackageLicense(pkgPath, approvedLicenses, nodeModulesPath, strict, pkgData);
741
+
742
+ classifyLicenseResult(results, result, strict);
743
+
744
+ if (reporter) {
745
+ reporter.update(results.checked);
746
+ }
747
+ }
748
+
749
+ if (reporter) {
750
+ reporter.finish();
751
+ }
752
+
753
+ return results;
754
+ }
755
+
756
+ /**
757
+ * Run both integrity and license checks
758
+ * @param {object} lockfileData - Parsed lockfile data
759
+ * @param {object} options - Options (merged for both checks)
760
+ * @returns {Promise<object>} Combined results
761
+ */
762
+ export async function checkAll(lockfileData, options = {}) {
763
+ const hashResults = await checkIntegrity(lockfileData, options);
764
+ const licenseResults = await checkLicenses(lockfileData, options);
765
+
766
+ return {
767
+ valid: hashResults.valid && licenseResults.valid,
768
+ integrity: hashResults,
769
+ licenses: licenseResults
770
+ };
771
+ }