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