@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/audit.js ADDED
@@ -0,0 +1,805 @@
1
+ // src/audit.js
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { forEachPackageEntry, detectLockfileFlavor } from './format-library.js';
5
+ import { validatePackageLock } from './validator.js';
6
+ import { validatePackageJson } from './package-json-validator.js';
7
+ import { validateNpmrc, NPMRC_SECURITY_CODES } from './npmrc-validator.js';
8
+ import { validatePnpmWorkspace } from './pnpm-workspace-validator.js';
9
+ import { isPlaceholder } from './integrity.js';
10
+ import { classifyRange } from './pinner.js';
11
+ import { walkOverrides } from './overrides.js';
12
+ import { findOrphanedPackages } from './pruner.js';
13
+ import { findUnusedDependencies } from './usage-scanner.js';
14
+ import { mergeConfig } from './audit-config.js';
15
+
16
+ export class AuditError extends Error {
17
+ constructor(message, code, context = {}) {
18
+ super(message);
19
+ this.name = 'AuditError';
20
+ this.code = code;
21
+ this.context = context;
22
+ }
23
+ }
24
+
25
+ // Rule contract: { id, description, defaultSeverity, flavors?, check(context) => findings[] }
26
+ // context = { lockfile, packageJson|null, options, filePath, flavor }
27
+ // findings = [{ packagePath, message, data? }] — the engine stamps ruleId + severity.
28
+ // `flavors` lists which lockfile flavors a rule applies to (default ['npm']); a
29
+ // rule whose flavors don't include the current flavor is skipped (e.g. the npm
30
+ // lockfile-shape rules no-op on a pnpm-lock.yaml, and vice versa).
31
+ const DEFAULT_FLAVORS = ['npm'];
32
+
33
+ const lockfileVersionRule = {
34
+ id: 'lockfile-version',
35
+ description: 'Require a modern lockfile format version',
36
+ defaultSeverity: 'error',
37
+ check({ lockfile, options }) {
38
+ const minVersion = options.minVersion || 3;
39
+ const actual = lockfile.lockfileVersion;
40
+ if (typeof actual !== 'number' || actual < minVersion) {
41
+ return [{
42
+ packagePath: '',
43
+ message: `lockfileVersion is ${actual === undefined ? 'missing' : actual}, minimum required is ${minVersion} (run \`npm-check migrate ${minVersion}\`)`
44
+ }];
45
+ }
46
+ return [];
47
+ }
48
+ };
49
+
50
+ const validStructureRule = {
51
+ id: 'valid-structure',
52
+ description: 'Lockfile must pass structural validation',
53
+ defaultSeverity: 'error',
54
+ check({ lockfile }) {
55
+ const result = validatePackageLock(lockfile);
56
+ const findings = result.errors.map((err) => ({
57
+ packagePath: '',
58
+ message: err.message,
59
+ data: { code: err.code }
60
+ }));
61
+ const warnFindings = result.warnings.map((warn) => ({
62
+ packagePath: '',
63
+ message: typeof warn === 'string' ? warn : warn.message,
64
+ data: { forcedSeverity: 'warn' }
65
+ }));
66
+ return [...findings, ...warnFindings];
67
+ }
68
+ };
69
+
70
+ const validPackageJsonRule = {
71
+ id: 'valid-package-json',
72
+ description: 'package.json must pass schema/field validation',
73
+ defaultSeverity: 'error',
74
+ flavors: ['npm', 'pnpm'],
75
+ check({ packageJson, options }) {
76
+ if (!packageJson) {
77
+ return [{
78
+ packagePath: 'package.json',
79
+ message: 'package.json not found next to lockfile; valid-package-json rule skipped',
80
+ data: { forcedSeverity: 'warn' }
81
+ }];
82
+ }
83
+ const result = validatePackageJson(packageJson, options);
84
+ const findings = result.errors.map((err) => ({
85
+ packagePath: 'package.json',
86
+ message: err.message,
87
+ data: { code: err.code }
88
+ }));
89
+ const warnFindings = result.warnings.map((warn) => ({
90
+ packagePath: 'package.json',
91
+ message: typeof warn === 'string' ? warn : warn.message,
92
+ data: { forcedSeverity: 'warn', code: typeof warn === 'string' ? undefined : warn.code }
93
+ }));
94
+ return [...findings, ...warnFindings];
95
+ }
96
+ };
97
+
98
+ const integrityHygieneRule = {
99
+ id: 'integrity-hygiene',
100
+ description: 'Integrity hashes must be present, real, and strong (sha512)',
101
+ defaultSeverity: 'error',
102
+ check({ lockfile, options }) {
103
+ const allowSha1 = Boolean(options.allowSha1);
104
+ const findings = [];
105
+ if (!lockfile.packages) return findings;
106
+
107
+ forEachPackageEntry(lockfile, ({ key, entry, isRoot, isWorkspaceSource, isLink, isBundled, isGitDep, isFileDep }) => {
108
+ if (isRoot || isWorkspaceSource || isLink || isBundled) return;
109
+
110
+ const integrity = entry.integrity;
111
+ if (!integrity) {
112
+ if (isGitDep || isFileDep) return; // legitimately absent
113
+ findings.push({ packagePath: key, message: 'missing integrity hash (run `npm-check fix-checksums`)' });
114
+ return;
115
+ }
116
+ if (isPlaceholder(integrity)) {
117
+ findings.push({ packagePath: key, message: 'placeholder integrity hash (run `npm-check fix-checksums`)' });
118
+ return;
119
+ }
120
+ if (!allowSha1 && integrity.startsWith('sha1-')) {
121
+ findings.push({ packagePath: key, message: 'integrity uses deprecated sha1 (run `npm-check fix-checksums`)' });
122
+ }
123
+ });
124
+ return findings;
125
+ }
126
+ };
127
+
128
+ // Validate a single non-registry resolved URL (git/file), returning a finding
129
+ // message when the dependency type is disallowed, otherwise null.
130
+ function checkSpecialResolved(resolved, { isGitDep, isFileDep, allowGit, allowFile }) {
131
+ if (isGitDep) {
132
+ return allowGit ? null : `git dependency not allowed: ${resolved}`;
133
+ }
134
+ if (isFileDep) {
135
+ return allowFile ? null : `file dependency not allowed: ${resolved}`;
136
+ }
137
+ return undefined; // not a special dep — caller handles registry URL
138
+ }
139
+
140
+ // Validate a registry/tarball resolved URL for TLS and trusted host, returning
141
+ // a finding message when it fails, otherwise null.
142
+ function checkRegistryResolved(resolved, { allowHttp, allowedHosts }) {
143
+ let url;
144
+ try {
145
+ url = new URL(resolved);
146
+ } catch {
147
+ return `unparseable resolved URL: ${resolved}`;
148
+ }
149
+ if (url.protocol === 'http:' && !allowHttp) {
150
+ return `insecure (non-TLS) resolved URL: ${resolved}`;
151
+ }
152
+ const isHttp = url.protocol === 'https:' || url.protocol === 'http:';
153
+ if (isHttp && !allowedHosts.includes(url.hostname)) {
154
+ return `resolved from untrusted registry host "${url.hostname}" (allowed: ${allowedHosts.join(', ')})`;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ const secureResolvedRule = {
160
+ id: 'secure-resolved',
161
+ description: 'Resolved URLs must use TLS and trusted registries',
162
+ defaultSeverity: 'error',
163
+ check({ lockfile, options }) {
164
+ const {
165
+ allowedHosts = ['registry.npmjs.org'],
166
+ allowHttp = false,
167
+ allowGit = true,
168
+ allowFile = true
169
+ } = options;
170
+ const findings = [];
171
+ if (!lockfile.packages) return findings;
172
+
173
+ forEachPackageEntry(lockfile, ({ key, entry, isRoot, isWorkspaceSource, isLink, isGitDep, isFileDep }) => {
174
+ if (isRoot || isWorkspaceSource || isLink) return;
175
+ const resolved = entry.resolved;
176
+ if (!resolved) return;
177
+
178
+ const special = checkSpecialResolved(resolved, { isGitDep, isFileDep, allowGit, allowFile });
179
+ const message = special === undefined
180
+ ? checkRegistryResolved(resolved, { allowHttp, allowedHosts })
181
+ : special;
182
+ if (message) findings.push({ packagePath: key, message });
183
+ });
184
+ return findings;
185
+ }
186
+ };
187
+
188
+ /**
189
+ * Classify every package that declares a lifecycle install script as allowed
190
+ * or blocked, reconciling against both this rule's `allow` list and npm v12's
191
+ * native package.json `allowScripts`. Two shapes are accepted: the map form
192
+ * (keys are `name@version` pinned or bare `name`; values true=approved /
193
+ * false=denied) and the array form (`["name", "name@version"]`, listing alone
194
+ * = approved, no deny semantics). Under npm v12 a script only runs when
195
+ * explicitly approved, so anything not approved is "blocked".
196
+ *
197
+ * @returns {{ total, allowed: object[], blocked: object[], v12Aware: boolean }}
198
+ */
199
+ // Resolve a package's npm v12 `allowScripts` approval state — pinned
200
+ // `name@version` takes precedence over a bare `name` key.
201
+ function resolveScriptApproval(allowScripts, name, version) {
202
+ if (!allowScripts || !name) return 'pending';
203
+ const pinned = `${name}@${version}`;
204
+ if (Array.isArray(allowScripts)) {
205
+ // Array form: an entry approves; there is no way to express a denial.
206
+ return allowScripts.includes(pinned) || allowScripts.includes(name) ? 'allowed' : 'pending';
207
+ }
208
+ if (pinned in allowScripts) return allowScripts[pinned] ? 'allowed' : 'denied';
209
+ if (name in allowScripts) return allowScripts[name] ? 'allowed' : 'denied';
210
+ return 'pending'; // pending | allowed | denied
211
+ }
212
+
213
+ // Build the allowed/blocked record for a single install-script package, or null
214
+ // when the entry is a root/workspace/link/script-less node that we skip.
215
+ function classifyScriptEntry({ key, entry, name }, { allowScripts, v12Aware, allow }) {
216
+ if (!entry || entry.hasInstallScript !== true) return null;
217
+
218
+ const approval = v12Aware ? resolveScriptApproval(allowScripts, name, entry.version) : 'pending';
219
+ const viaRuleAllow = Boolean(name && allow.includes(name));
220
+ return { key, name, version: entry.version, approval, viaRuleAllow };
221
+ }
222
+
223
+ export function classifyInstallScripts(lockfile, packageJson, options = {}) {
224
+ const { allow = [] } = options;
225
+ const allowScripts = packageJson && packageJson.allowScripts;
226
+ const v12Aware = Boolean(allowScripts && typeof allowScripts === 'object');
227
+ const allowed = [];
228
+ const blocked = [];
229
+ if (!lockfile.packages) return { total: 0, allowed, blocked, v12Aware };
230
+
231
+ forEachPackageEntry(lockfile, ({ key, entry, name, isRoot, isWorkspaceSource, isLink }) => {
232
+ if (isRoot || isWorkspaceSource || isLink) return;
233
+ const rec = classifyScriptEntry({ key, entry, name }, { allowScripts, v12Aware, allow });
234
+ if (!rec) return;
235
+ if (rec.viaRuleAllow || rec.approval === 'allowed') allowed.push(rec);
236
+ else blocked.push(rec);
237
+ });
238
+
239
+ return { total: allowed.length + blocked.length, allowed, blocked, v12Aware };
240
+ }
241
+
242
+ // Compose the finding message for a blocked install-script package, varying by
243
+ // whether it is explicitly denied, pending under an allowScripts-aware project,
244
+ // or simply unreviewed in a pre-v12 project.
245
+ function installScriptMessage(label, approval, v12Aware) {
246
+ if (approval === 'denied') {
247
+ return `${label} runs an install script but is denied in package.json "allowScripts" — npm v12 will not run it`;
248
+ }
249
+ if (v12Aware) {
250
+ return `${label} runs an install script not yet approved in package.json "allowScripts" — npm v12 will not run it (\`npm approve-scripts\`)`;
251
+ }
252
+ return `${label} runs a lifecycle install script — npm v12 blocks install scripts by default; approve it in package.json "allowScripts" (\`npm approve-scripts\`) if trusted, or it will not run`;
253
+ }
254
+
255
+ const installScriptsRule = {
256
+ id: 'install-scripts',
257
+ description: 'Packages with lifecycle install scripts must be reviewed and allowlisted',
258
+ defaultSeverity: 'warn',
259
+ check({ lockfile, packageJson, options }) {
260
+ const { blocked, v12Aware } = classifyInstallScripts(lockfile, packageJson, options);
261
+ return blocked.map(({ key, name, approval }) => ({
262
+ packagePath: key,
263
+ message: installScriptMessage(name || key, approval, v12Aware)
264
+ }));
265
+ }
266
+ };
267
+
268
+ const noGitDepsRule = {
269
+ id: 'no-git-deps',
270
+ description: 'Git dependencies require --allow-git under npm v12',
271
+ defaultSeverity: 'warn',
272
+ check({ lockfile }) {
273
+ const findings = [];
274
+ if (!lockfile.packages) return findings;
275
+ forEachPackageEntry(lockfile, ({ key, name, isRoot, isWorkspaceSource, isLink, isGitDep }) => {
276
+ if (isRoot || isWorkspaceSource || isLink || !isGitDep) return;
277
+ findings.push({
278
+ packagePath: key,
279
+ message: `${name || key} is a git dependency — npm v12 will not install it without \`--allow-git\``
280
+ });
281
+ });
282
+ return findings;
283
+ }
284
+ };
285
+
286
+ const noRemoteDepsRule = {
287
+ id: 'no-remote-deps',
288
+ description: 'Remote-URL (non-registry) dependencies require --allow-remote under npm v12',
289
+ defaultSeverity: 'warn',
290
+ check({ lockfile, options }) {
291
+ const { allowedHosts = ['registry.npmjs.org', 'npm.pkg.github.com'] } = options;
292
+ const findings = [];
293
+ if (!lockfile.packages) return findings;
294
+ forEachPackageEntry(lockfile, ({ key, entry, name, isRoot, isWorkspaceSource, isLink, isGitDep, isFileDep }) => {
295
+ if (isRoot || isWorkspaceSource || isLink || isGitDep || isFileDep) return;
296
+ const resolved = entry && entry.resolved;
297
+ if (!resolved || !/^https?:/i.test(resolved)) return;
298
+ // Treat a URL as a registry tarball when its hostname is in the configured
299
+ // allowedHosts list. This correctly handles GitHub Packages
300
+ // (npm.pkg.github.com/download/...) and private registries without relying
301
+ // on the brittle `/-/` path marker, which is absent from several registry
302
+ // URL shapes and present in some genuine remote tarball URLs.
303
+ let hostname;
304
+ try {
305
+ hostname = new URL(resolved).hostname;
306
+ } catch {
307
+ // Unparseable URL — secure-resolved will flag it; skip here.
308
+ return;
309
+ }
310
+ if (allowedHosts.includes(hostname)) return;
311
+ findings.push({
312
+ packagePath: key,
313
+ message: `${name || key} resolves from a remote URL (${resolved}) — npm v12 will not install it without \`--allow-remote\``
314
+ });
315
+ });
316
+ return findings;
317
+ }
318
+ };
319
+
320
+ // Collect unpinned (caret/tilde) ranges from one package.json section.
321
+ function collectUnpinnedRanges(lockfile, deps, section, ignore) {
322
+ if (!deps || typeof deps !== 'object') return [];
323
+ const findings = [];
324
+ for (const [name, range] of Object.entries(deps)) {
325
+ if (ignore.includes(name)) continue;
326
+ const kind = classifyRange(range);
327
+ if (kind !== 'caret' && kind !== 'tilde') continue;
328
+
329
+ const entry = lockfile.packages && lockfile.packages[`node_modules/${name}`];
330
+ const resolvedNote = entry && entry.version ? ` (resolved: ${entry.version})` : '';
331
+ findings.push({
332
+ packagePath: `package.json#${section}/${name}`,
333
+ message: `range "${range}" is not pinned${resolvedNote} (run \`npm-check pin\`)`
334
+ });
335
+ }
336
+ return findings;
337
+ }
338
+
339
+ // Flag caret/tilde ranges inside an overrides object (npm `overrides` or the
340
+ // pnpm `pnpm.overrides` field). Walks the nested/flat structure via walkOverrides
341
+ // (which skips `$`-references) and reports each unpinned range by its full path.
342
+ function collectUnpinnedOverrides(overrides, lockfile, section, ignore, pinHint) {
343
+ if (!overrides || typeof overrides !== 'object') return [];
344
+ const findings = [];
345
+ for (const { path, name, range } of walkOverrides(overrides)) {
346
+ if (ignore.includes(name) || ignore.includes(path)) continue;
347
+ const kind = classifyRange(range);
348
+ if (kind !== 'caret' && kind !== 'tilde') continue;
349
+
350
+ const entry = lockfile.packages && lockfile.packages[`node_modules/${name}`];
351
+ const resolvedNote = entry && entry.version ? ` (resolved: ${entry.version})` : '';
352
+ findings.push({
353
+ packagePath: `package.json#${section}/${path}`,
354
+ message: `override range "${range}" is not pinned${resolvedNote}${pinHint}`
355
+ });
356
+ }
357
+ return findings;
358
+ }
359
+
360
+ const pinnedVersionsRule = {
361
+ id: 'pinned-versions',
362
+ description: 'package.json dependency ranges must be exact versions',
363
+ defaultSeverity: 'warn',
364
+ // package.json pinning is manifest-level and flavor-agnostic — pnpm projects
365
+ // should pin too, and this is what makes the pnpm.overrides flagging below
366
+ // actually reachable on a pnpm-lock.yaml (the `lockfile.packages` lookups are
367
+ // guarded and degrade to an empty resolved-note for pnpm).
368
+ flavors: ['npm', 'pnpm'],
369
+ check({ lockfile, packageJson, options }) {
370
+ if (!packageJson) {
371
+ return [{
372
+ packagePath: 'package.json',
373
+ message: 'package.json not found next to lockfile; pinned-versions rule skipped',
374
+ data: { forcedSeverity: 'warn' }
375
+ }];
376
+ }
377
+
378
+ const {
379
+ sections = ['dependencies', 'devDependencies', 'optionalDependencies'],
380
+ ignore = []
381
+ } = options;
382
+ const findings = [];
383
+
384
+ for (const section of sections) {
385
+ findings.push(...collectUnpinnedRanges(lockfile, packageJson[section], section, ignore));
386
+ }
387
+
388
+ // Overrides force transitive versions and can carry floating ranges too — a
389
+ // caret here silently defeats an otherwise fully-pinned manifest. npm
390
+ // `overrides` are pinnable (`npm-check pin`); pnpm.overrides are flagged for
391
+ // manual attention (pin refuses pnpm lockfiles).
392
+ findings.push(...collectUnpinnedOverrides(packageJson.overrides, lockfile, 'overrides', ignore, ' (run `npm-check pin`)'));
393
+ const pnpmOverrides = packageJson.pnpm && packageJson.pnpm.overrides;
394
+ findings.push(...collectUnpinnedOverrides(pnpmOverrides, lockfile, 'pnpm.overrides', ignore, ' (pin manually or regenerate with pnpm)'));
395
+
396
+ return findings;
397
+ }
398
+ };
399
+
400
+ const SYNC_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
401
+
402
+ // Compare the top-level name/version of package.json against the lockfile.
403
+ function checkRootMetadataSync(lockfile, packageJson) {
404
+ const findings = [];
405
+ if (packageJson.name && lockfile.name && packageJson.name !== lockfile.name) {
406
+ findings.push({ packagePath: '', message: `name mismatch: package.json says "${packageJson.name}", lockfile says "${lockfile.name}"` });
407
+ }
408
+ if (packageJson.version && lockfile.version && packageJson.version !== lockfile.version) {
409
+ findings.push({ packagePath: '', message: `version mismatch: package.json says "${packageJson.version}", lockfile says "${lockfile.version}" (run \`npm install\`)` });
410
+ }
411
+ return findings;
412
+ }
413
+
414
+ // Reconcile one dependency section between package.json and the lockfile root
415
+ // entry (and the packages map), both directions.
416
+ function checkSectionSync(lockfile, section, declared, locked) {
417
+ const findings = [];
418
+
419
+ for (const [name, range] of Object.entries(declared)) {
420
+ if (locked[name] === undefined) {
421
+ findings.push({
422
+ packagePath: `package.json#${section}/${name}`,
423
+ message: `declared in package.json but missing from the lockfile root entry (run \`npm install\`)`
424
+ });
425
+ } else if (locked[name] !== range) {
426
+ findings.push({
427
+ packagePath: `package.json#${section}/${name}`,
428
+ message: `range mismatch: package.json has "${range}", lockfile root has "${locked[name]}" (run \`npm install\`)`
429
+ });
430
+ }
431
+ // peers aren't necessarily installed as their own entries
432
+ if (section !== 'peerDependencies' && lockfile.packages[`node_modules/${name}`] === undefined) {
433
+ findings.push({
434
+ packagePath: `package.json#${section}/${name}`,
435
+ message: `declared in package.json but not installed in the lockfile packages map (run \`npm install\`)`
436
+ });
437
+ }
438
+ }
439
+
440
+ for (const name of Object.keys(locked)) {
441
+ if (declared[name] === undefined) {
442
+ findings.push({
443
+ packagePath: `package-lock.json#${section}/${name}`,
444
+ message: `present in the lockfile root entry but not declared in package.json (run \`npm install\`)`
445
+ });
446
+ }
447
+ }
448
+
449
+ return findings;
450
+ }
451
+
452
+ const lockfileSyncRule = {
453
+ id: 'lockfile-sync',
454
+ description: 'package.json and the lockfile must agree',
455
+ defaultSeverity: 'error',
456
+ check({ lockfile, packageJson }) {
457
+ if (!packageJson) {
458
+ return [{
459
+ packagePath: 'package.json',
460
+ message: 'package.json not found next to lockfile; lockfile-sync rule skipped',
461
+ data: { forcedSeverity: 'warn' }
462
+ }];
463
+ }
464
+
465
+ const findings = checkRootMetadataSync(lockfile, packageJson);
466
+
467
+ const root = lockfile.packages && lockfile.packages[''];
468
+ if (!root) return findings;
469
+
470
+ for (const section of SYNC_SECTIONS) {
471
+ const declared = packageJson[section] || {};
472
+ const locked = root[section] || {};
473
+ findings.push(...checkSectionSync(lockfile, section, declared, locked));
474
+ }
475
+
476
+ return findings;
477
+ }
478
+ };
479
+
480
+ const noOrphanPackagesRule = {
481
+ id: 'no-orphan-packages',
482
+ description: 'Lockfile must not contain packages unreachable from the dependency graph',
483
+ defaultSeverity: 'warn',
484
+ check({ lockfile }) {
485
+ if (!lockfile.packages) return [];
486
+ let orphans;
487
+ try {
488
+ orphans = findOrphanedPackages(lockfile).orphans;
489
+ } catch {
490
+ // v1 lockfiles: lockfile-version rule already covers this
491
+ return [];
492
+ }
493
+ return orphans.map((orphan) => {
494
+ const detail = orphan.version ? ` (${orphan.name}@${orphan.version})` : '';
495
+ return {
496
+ packagePath: orphan.key,
497
+ message: `orphaned package${detail} unreachable from the dependency graph (run \`npm-check prune\`)`
498
+ };
499
+ });
500
+ }
501
+ };
502
+
503
+ const unusedDependenciesRule = {
504
+ id: 'unused-dependencies',
505
+ description: 'Dependencies declared in package.json should be imported by the application',
506
+ defaultSeverity: 'warn',
507
+ check({ packageJson, options, filePath }) {
508
+ if (!packageJson) {
509
+ return [{
510
+ packagePath: 'package.json',
511
+ message: 'package.json not found next to lockfile; unused-dependencies rule skipped',
512
+ data: { forcedSeverity: 'warn' }
513
+ }];
514
+ }
515
+
516
+ const dir = path.dirname(path.resolve(filePath));
517
+ let result;
518
+ try {
519
+ result = findUnusedDependencies(packageJson, dir, {
520
+ includeDev: Boolean(options.includeDev),
521
+ ignore: options.ignore || []
522
+ });
523
+ } catch (e) {
524
+ return [{
525
+ packagePath: 'package.json',
526
+ message: `unused-dependencies rule skipped: ${e.message}`,
527
+ data: { forcedSeverity: 'warn' }
528
+ }];
529
+ }
530
+
531
+ return result.unused.map((dep) => ({
532
+ packagePath: `package.json#${dep.section}/${dep.name}`,
533
+ message: `"${dep.name}" is never imported by the application — flagged for removal (heuristic; add to the rule's ignore list if loaded indirectly)`
534
+ }));
535
+ }
536
+ };
537
+
538
+ /**
539
+ * Does a project `.npmrc` already suppress npm's funding solicitations?
540
+ * npm prints "N packages are looking for funding" on install unless `fund`
541
+ * is set false. We only consult the project-level `.npmrc` (the committed,
542
+ * reproducible artifact a CI audit can rely on) — not the machine's `~/.npmrc`,
543
+ * which would make results differ between local and CI.
544
+ */
545
+ function npmrcDisablesFund(dir, options = {}) {
546
+ const npmrcPath = options.npmrcPath ? path.resolve(options.npmrcPath) : path.join(dir, '.npmrc');
547
+ let content;
548
+ try {
549
+ content = fs.readFileSync(npmrcPath, 'utf8');
550
+ } catch {
551
+ return false; // no .npmrc → funding messages are on by default
552
+ }
553
+ // ini-style `fund=false` / `fund = false`, ignoring case, surrounding ws, and inline comments
554
+ return content.split(/\r?\n/).some((line) => {
555
+ const m = line.match(/^\s*fund\s*=\s*([^\s;#]+)/i);
556
+ return Boolean(m) && m[1].toLowerCase() === 'false';
557
+ });
558
+ }
559
+
560
+ const noFundRule = {
561
+ id: 'no-fund',
562
+ description: 'npm funding solicitations should be suppressed (fund=false in .npmrc)',
563
+ defaultSeverity: 'warn',
564
+ check({ lockfile, filePath, options }) {
565
+ if (!lockfile.packages) return [];
566
+
567
+ // Count installed packages that declare funding metadata — these are
568
+ // exactly what npm's "N packages are looking for funding" notice tallies.
569
+ let funded = 0;
570
+ forEachPackageEntry(lockfile, ({ entry, isRoot, isWorkspaceSource, isLink }) => {
571
+ if (isRoot || isWorkspaceSource || isLink) return;
572
+ if (entry && entry.funding) funded++;
573
+ });
574
+ if (funded === 0) return [];
575
+
576
+ // Already silenced by a project .npmrc → nothing to flag.
577
+ if (npmrcDisablesFund(path.dirname(path.resolve(filePath)), options)) return [];
578
+
579
+ return [{
580
+ packagePath: '.npmrc',
581
+ message: `${funded} package${funded === 1 ? '' : 's'} emit npm funding solicitations on install — disable with \`npm config set fund false\` (adds \`fund=false\` to .npmrc)`
582
+ }];
583
+ }
584
+ };
585
+
586
+ const validNpmrcRule = {
587
+ id: 'valid-npmrc',
588
+ description: '.npmrc must be well-formed and free of insecure settings',
589
+ defaultSeverity: 'warn',
590
+ flavors: ['npm', 'pnpm'],
591
+ check({ filePath, options, flavor }) {
592
+ const dir = path.dirname(path.resolve(filePath));
593
+ const npmrcPath = options.npmrcPath ? path.resolve(options.npmrcPath) : path.join(dir, '.npmrc');
594
+ let content;
595
+ try {
596
+ content = fs.readFileSync(npmrcPath, 'utf8');
597
+ } catch {
598
+ return []; // no project .npmrc → nothing to validate (legitimate)
599
+ }
600
+ // For pnpm projects, flag non-auth settings that pnpm silently ignores in .npmrc.
601
+ const result = validateNpmrc(content, { ...options, flavor });
602
+ const findings = result.errors.map((err) => ({
603
+ packagePath: '.npmrc',
604
+ message: err.message,
605
+ // Security-critical findings always fail, regardless of configured severity.
606
+ data: NPMRC_SECURITY_CODES.has(err.code)
607
+ ? { forcedSeverity: 'error', code: err.code }
608
+ : { code: err.code }
609
+ }));
610
+ const warnFindings = result.warnings.map((warn) => ({
611
+ packagePath: '.npmrc',
612
+ message: typeof warn === 'string' ? warn : warn.message,
613
+ data: { forcedSeverity: 'warn', code: typeof warn === 'string' ? undefined : warn.code }
614
+ }));
615
+ return [...findings, ...warnFindings];
616
+ }
617
+ };
618
+
619
+ const validPnpmWorkspaceRule = {
620
+ id: 'valid-pnpm-workspace',
621
+ description: 'pnpm-workspace.yaml must be well-formed (packages globs + valid settings)',
622
+ defaultSeverity: 'error',
623
+ flavors: ['pnpm'],
624
+ check({ filePath }) {
625
+ const dir = path.dirname(path.resolve(filePath));
626
+ const wsPath = path.join(dir, 'pnpm-workspace.yaml');
627
+ let content;
628
+ try {
629
+ content = fs.readFileSync(wsPath, 'utf8');
630
+ } catch {
631
+ return []; // no pnpm-workspace.yaml → nothing to validate (single-package repo)
632
+ }
633
+ const result = validatePnpmWorkspace(content);
634
+ const findings = result.errors.map((err) => ({
635
+ packagePath: 'pnpm-workspace.yaml',
636
+ message: err.message,
637
+ data: { code: err.code }
638
+ }));
639
+ const warnFindings = result.warnings.map((warn) => ({
640
+ packagePath: 'pnpm-workspace.yaml',
641
+ message: typeof warn === 'string' ? warn : warn.message,
642
+ data: { forcedSeverity: 'warn', code: typeof warn === 'string' ? undefined : warn.code }
643
+ }));
644
+ return [...findings, ...warnFindings];
645
+ }
646
+ };
647
+
648
+ const validPnpmFieldRule = {
649
+ id: 'valid-pnpm-field',
650
+ description: 'package.json "pnpm" field (overrides, build allowlists, …) must be well-typed',
651
+ defaultSeverity: 'error',
652
+ flavors: ['pnpm'],
653
+ check({ packageJson }) {
654
+ if (!packageJson || packageJson.pnpm === undefined) return [];
655
+ // validatePackageJson already type-checks the pnpm field; surface only those.
656
+ const result = validatePackageJson(packageJson);
657
+ const isPnpm = (msg) => typeof msg === 'string' && msg.includes('"pnpm');
658
+ const findings = result.errors
659
+ .filter((err) => err.code === 'PJ_INVALID_PNPM' || err.code === 'PJ_INVALID_PNPM_FIELD')
660
+ .map((err) => ({ packagePath: 'package.json', message: err.message, data: { code: err.code } }));
661
+ const warnFindings = result.warnings
662
+ .filter((warn) => warn.code === 'PJ_UNKNOWN_PNPM_KEY' || isPnpm(warn.message))
663
+ .map((warn) => ({ packagePath: 'package.json', message: warn.message, data: { forcedSeverity: 'warn', code: warn.code } }));
664
+ return [...findings, ...warnFindings];
665
+ }
666
+ };
667
+
668
+ export const rules = [
669
+ lockfileVersionRule,
670
+ validStructureRule,
671
+ validPackageJsonRule,
672
+ integrityHygieneRule,
673
+ secureResolvedRule,
674
+ installScriptsRule,
675
+ noGitDepsRule,
676
+ noRemoteDepsRule,
677
+ pinnedVersionsRule,
678
+ lockfileSyncRule,
679
+ noOrphanPackagesRule,
680
+ unusedDependenciesRule,
681
+ noFundRule,
682
+ validNpmrcRule,
683
+ validPnpmWorkspaceRule,
684
+ validPnpmFieldRule
685
+ ];
686
+
687
+ /**
688
+ * Run all configured audit rules against a lockfile (and optional package.json).
689
+ *
690
+ * @param {object} target - { lockfile, packageJson|null, filePath }
691
+ * @param {object} config - Resolved config from loadAuditConfig/mergeConfig,
692
+ * or a raw user config object (will be merged over defaults)
693
+ * @returns {{findings, summary, pass}}
694
+ */
695
+ // Resolve the audit config, accepting either an already-normalized config or a
696
+ // raw user config that needs merging over the defaults.
697
+ function resolveAuditConfig(config) {
698
+ const alreadyNormalized = config.rules
699
+ && config.rules[rules[0].id]
700
+ && config.rules[rules[0].id].severity;
701
+ return alreadyNormalized ? config : mergeConfig(config);
702
+ }
703
+
704
+ // Run a single rule and map its raw findings into stamped findings (ruleId +
705
+ // resolved severity, honoring a finding's forcedSeverity).
706
+ function collectRuleFindings(rule, ruleConfig, context) {
707
+ const raw = rule.check(context);
708
+ return raw.map((finding) => ({
709
+ ruleId: rule.id,
710
+ severity: (finding.data && finding.data.forcedSeverity) || ruleConfig.severity,
711
+ packagePath: finding.packagePath,
712
+ message: finding.message
713
+ }));
714
+ }
715
+
716
+ // Tally findings into the summary's per-rule error/warning breakdown.
717
+ function summarizeByRule(findings) {
718
+ const byRule = {};
719
+ for (const finding of findings) {
720
+ byRule[finding.ruleId] = byRule[finding.ruleId] || { errors: 0, warnings: 0 };
721
+ byRule[finding.ruleId][finding.severity === 'error' ? 'errors' : 'warnings']++;
722
+ }
723
+ return byRule;
724
+ }
725
+
726
+ export function runAudit(target, config = {}) {
727
+ const { lockfile, packageJson = null, filePath = 'package-lock.json' } = target;
728
+ if (!lockfile || typeof lockfile !== 'object') {
729
+ throw new AuditError('lockfile data is required', 'MISSING_LOCKFILE');
730
+ }
731
+
732
+ const resolved = resolveAuditConfig(config);
733
+ const flavor = detectLockfileFlavor(lockfile);
734
+
735
+ const findings = [];
736
+ for (const rule of rules) {
737
+ // Flavor gating: a rule only runs against the lockfile flavors it supports
738
+ // (npm-shape rules no-op on pnpm-lock.yaml; pnpm rules no-op on npm).
739
+ if (!(rule.flavors || DEFAULT_FLAVORS).includes(flavor)) continue;
740
+
741
+ const ruleConfig = resolved.rules[rule.id];
742
+ if (!ruleConfig || ruleConfig.severity === 'off') continue;
743
+
744
+ const context = { lockfile, packageJson, options: ruleConfig.options || {}, filePath, flavor };
745
+ findings.push(...collectRuleFindings(rule, ruleConfig, context));
746
+ }
747
+
748
+ const errors = findings.filter((f) => f.severity === 'error').length;
749
+ const warnings = findings.filter((f) => f.severity === 'warn').length;
750
+ const byRule = summarizeByRule(findings);
751
+
752
+ const maxWarnings = resolved.maxWarnings !== undefined ? resolved.maxWarnings : -1;
753
+ const pass = errors === 0 && (maxWarnings < 0 || warnings <= maxWarnings);
754
+
755
+ return {
756
+ findings,
757
+ summary: { errors, warnings, total: findings.length, byRule },
758
+ pass,
759
+ filePath
760
+ };
761
+ }
762
+
763
+ /**
764
+ * Format an audit report for display.
765
+ * @param {object} report - Result of runAudit()
766
+ * @param {object} options - { format: 'stylish' | 'json' }
767
+ * @returns {string}
768
+ */
769
+ export function formatAuditReport(report, options = {}) {
770
+ const { format = 'stylish' } = options;
771
+
772
+ if (format === 'json') {
773
+ return JSON.stringify({
774
+ filePath: report.filePath,
775
+ pass: report.pass,
776
+ summary: report.summary,
777
+ findings: report.findings
778
+ }, null, 2);
779
+ }
780
+
781
+ if (format !== 'stylish') {
782
+ throw new AuditError(`Unknown report format: ${format}`, 'UNKNOWN_FORMAT');
783
+ }
784
+
785
+ const lines = [];
786
+ lines.push(report.filePath);
787
+
788
+ if (report.findings.length === 0) {
789
+ lines.push(' no problems found');
790
+ return lines.join('\n');
791
+ }
792
+
793
+ const ruleWidth = Math.max(...report.findings.map((f) => f.ruleId.length));
794
+ for (const finding of report.findings) {
795
+ const sev = finding.severity === 'error' ? 'error' : 'warn ';
796
+ const loc = finding.packagePath ? `${finding.packagePath} ` : '';
797
+ lines.push(` ${sev} ${finding.ruleId.padEnd(ruleWidth)} ${loc}${finding.message}`);
798
+ }
799
+
800
+ const { errors, warnings, total } = report.summary;
801
+ const problemWord = total === 1 ? 'problem' : 'problems';
802
+ lines.push('');
803
+ lines.push(`${total} ${problemWord} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'})`);
804
+ return lines.join('\n');
805
+ }