@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/report.js
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
// src/report.js
|
|
2
|
+
// Unified report: runs every check npm-check offers (the audit rules covering
|
|
3
|
+
// lockfile + package.json + .npmrc validation, plus registry integrity
|
|
4
|
+
// verification and license validation) and renders one clean, sectioned report.
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { runAudit, classifyInstallScripts } from './audit.js';
|
|
8
|
+
import { mergeConfig } from './audit-config.js';
|
|
9
|
+
import { checkIntegrity, checkLicenses } from './checker.js';
|
|
10
|
+
import { checkVulnerabilities } from './vuln.js';
|
|
11
|
+
import { checkDeprecations } from './deprecation.js';
|
|
12
|
+
import { detectLockfileFlavor } from './format-library.js';
|
|
13
|
+
import { buildEnvelope } from './schema.js';
|
|
14
|
+
|
|
15
|
+
// Sections that apply to a pnpm lockfile: the registry-backed scans, the config
|
|
16
|
+
// validators (package.json, .npmrc, pnpm-workspace.yaml + pnpm field), and
|
|
17
|
+
// pinned-versions (a manifest-level check — pnpm dep ranges and pnpm.overrides
|
|
18
|
+
// pin just like npm's, and the pinned-versions rule is npm+pnpm flavored). The
|
|
19
|
+
// npm-lockfile-shape sections (and license, pending a `.pnpm` store walk) are
|
|
20
|
+
// marked N/A rather than rendered as a misleading pass.
|
|
21
|
+
const PNPM_LIVE_SECTIONS = new Set(['integrity', 'vuln', 'deprecated', 'package-json', 'npmrc', 'pnpm-config', 'pinned']);
|
|
22
|
+
// The pnpm-config section has no meaning for an npm lockfile.
|
|
23
|
+
const NPM_NA_SECTIONS = new Set(['pnpm-config']);
|
|
24
|
+
|
|
25
|
+
export class ReportError extends Error {
|
|
26
|
+
constructor(message, code, context = {}) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'ReportError';
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.context = context;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Which report section each audit rule feeds into.
|
|
35
|
+
const RULE_SECTION = {
|
|
36
|
+
'lockfile-version': 'structure',
|
|
37
|
+
'valid-structure': 'structure',
|
|
38
|
+
'lockfile-sync': 'structure',
|
|
39
|
+
'valid-package-json': 'package-json',
|
|
40
|
+
'valid-npmrc': 'npmrc',
|
|
41
|
+
'integrity-hygiene': 'integrity',
|
|
42
|
+
'secure-resolved': 'resolved',
|
|
43
|
+
'install-scripts': 'install-scripts',
|
|
44
|
+
'no-git-deps': 'git',
|
|
45
|
+
'no-remote-deps': 'remote',
|
|
46
|
+
'pinned-versions': 'pinned',
|
|
47
|
+
'no-orphan-packages': 'orphans',
|
|
48
|
+
'unused-dependencies': 'unused',
|
|
49
|
+
'no-fund': 'fund',
|
|
50
|
+
'valid-pnpm-workspace': 'pnpm-config',
|
|
51
|
+
'valid-pnpm-field': 'pnpm-config'
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Display order and titles for the sections.
|
|
55
|
+
const SECTIONS = [
|
|
56
|
+
{ id: 'structure', title: 'Structure & format' },
|
|
57
|
+
{ id: 'package-json', title: 'package.json' },
|
|
58
|
+
{ id: 'npmrc', title: '.npmrc (config)' },
|
|
59
|
+
{ id: 'pnpm-config', title: 'pnpm (workspace + manifest)' },
|
|
60
|
+
{ id: 'integrity', title: 'Integrity (registry)' },
|
|
61
|
+
{ id: 'vuln', title: 'Known vulnerabilities' },
|
|
62
|
+
{ id: 'deprecated', title: 'Deprecated packages' },
|
|
63
|
+
{ id: 'resolved', title: 'Resolved URLs' },
|
|
64
|
+
{ id: 'licenses', title: 'Licenses' },
|
|
65
|
+
{ id: 'install-scripts', title: 'Install scripts' },
|
|
66
|
+
{ id: 'git', title: 'Git dependencies' },
|
|
67
|
+
{ id: 'remote', title: 'Remote-URL deps' },
|
|
68
|
+
{ id: 'pinned', title: 'Pinned versions' },
|
|
69
|
+
{ id: 'orphans', title: 'Orphaned packages' },
|
|
70
|
+
{ id: 'unused', title: 'Unused dependencies' },
|
|
71
|
+
{ id: 'fund', title: 'Funding solicitations' }
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const MAX_DETAIL = 50; // cap per-section detail lines so the report stays readable
|
|
75
|
+
|
|
76
|
+
function worstSeverity(findings) {
|
|
77
|
+
if (findings.some((f) => f.severity === 'error')) return 'error';
|
|
78
|
+
if (findings.some((f) => f.severity === 'warn')) return 'warn';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Append a normalized finding to its section bucket, creating it on first use.
|
|
83
|
+
function pushFinding(buckets, id, finding) {
|
|
84
|
+
const list = buckets[id] || (buckets[id] = []);
|
|
85
|
+
list.push(finding);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Bucket integrity findings: real hash mismatches are errors. Unresolved entries
|
|
89
|
+
// (could-not-verify) are errors when failing closed (the default), else warnings.
|
|
90
|
+
function collectIntegrityFindings(buckets, integrityResult, failOnUnresolved) {
|
|
91
|
+
for (const err of integrityResult.errors) {
|
|
92
|
+
if (err.expected && err.actual) {
|
|
93
|
+
pushFinding(buckets, 'integrity', { severity: 'error', location: err.packagePath, message: `lockfile hash differs from registry for ${err.package}` });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const unresolvedSeverity = failOnUnresolved ? 'error' : 'warn';
|
|
97
|
+
for (const item of integrityResult.unresolvedItems) {
|
|
98
|
+
pushFinding(buckets, 'integrity', { severity: unresolvedSeverity, location: item.packagePath, message: `${item.package}@${item.version}: ${item.reason}` });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Coerce an advisory's references into an array: an explicit `references` array
|
|
103
|
+
// wins, else the single `url` (if any) becomes a one-element list, else empty.
|
|
104
|
+
function referencesOf(f) {
|
|
105
|
+
if (Array.isArray(f.references)) return f.references;
|
|
106
|
+
return f.url ? [f.url] : [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Normalize one advisory finding (from vuln.js's errors/warnings) into a report
|
|
110
|
+
// finding that PRESERVES the full advisory data instead of collapsing it. `level`
|
|
111
|
+
// is the report-tier severity (error|warn) that drives the icons, the section
|
|
112
|
+
// status and the pass/fail rollup; `advisorySeverity` carries the TRUE 5-level
|
|
113
|
+
// advisory severity (info|low|moderate|high|critical) so JSON consumers no longer
|
|
114
|
+
// have to scrape it out of the message string. Mirrors `vuln --format json`.
|
|
115
|
+
function advisoryFinding(level, f) {
|
|
116
|
+
return {
|
|
117
|
+
severity: level,
|
|
118
|
+
location: f.packagePath,
|
|
119
|
+
message: `${f.package}@${f.version}: ${f.title} (${f.severity})`,
|
|
120
|
+
package: f.package,
|
|
121
|
+
version: f.version,
|
|
122
|
+
advisoryId: f.advisoryId,
|
|
123
|
+
title: f.title,
|
|
124
|
+
advisorySeverity: f.severity,
|
|
125
|
+
fixedVersion: f.fixedVersion ?? null,
|
|
126
|
+
cve: f.cve ?? null,
|
|
127
|
+
vulnerableRange: f.vulnerableRange ?? null,
|
|
128
|
+
references: referencesOf(f),
|
|
129
|
+
url: f.url ?? null
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Bucket vulnerability findings. Advisory findings are errors. Unresolved entries —
|
|
134
|
+
// packages the scan could not check at all — are errors when failing closed (the
|
|
135
|
+
// default), else warnings; rendered once here (not from `errors`, where they have no advisoryId).
|
|
136
|
+
function collectVulnFindings(buckets, vulnResult, failOnUnresolved) {
|
|
137
|
+
for (const err of vulnResult.errors) {
|
|
138
|
+
// Discriminate on `reason` (like vulnEnvelope), NOT on `advisoryId`: an
|
|
139
|
+
// unresolved entry carries a `reason` and is rendered from unresolvedItems
|
|
140
|
+
// below, while a genuine advisory has none — including one that merely lacks
|
|
141
|
+
// an `id`, which must still fail the run rather than silently vanish.
|
|
142
|
+
if (err.reason) continue;
|
|
143
|
+
pushFinding(buckets, 'vuln', advisoryFinding('error', err));
|
|
144
|
+
}
|
|
145
|
+
for (const warn of vulnResult.warnings) {
|
|
146
|
+
pushFinding(buckets, 'vuln', advisoryFinding('warn', warn));
|
|
147
|
+
}
|
|
148
|
+
const unresolvedSeverity = failOnUnresolved ? 'error' : 'warn';
|
|
149
|
+
for (const item of vulnResult.unresolvedItems) {
|
|
150
|
+
pushFinding(buckets, 'vuln', { severity: unresolvedSeverity, location: item.packagePath, message: `could not scan ${item.package}@${item.version}: ${item.reason}` });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Bucket deprecation findings. A *found* deprecation is an error only under
|
|
155
|
+
// failOnDeprecated (it lands in `errors` with a message), else a warning. Unresolved
|
|
156
|
+
// entries — the scan couldn't complete — are errors when failing closed (the default),
|
|
157
|
+
// else warnings; rendered once here (those in `errors` carry no `message`).
|
|
158
|
+
function collectDeprecationFindings(buckets, deprecationResult, failOnUnresolved) {
|
|
159
|
+
for (const err of deprecationResult.errors) {
|
|
160
|
+
if (!err.message) continue;
|
|
161
|
+
pushFinding(buckets, 'deprecated', { severity: 'error', location: err.packagePath, message: `${err.package}@${err.version}: ${err.message}` });
|
|
162
|
+
}
|
|
163
|
+
for (const warn of deprecationResult.warnings) {
|
|
164
|
+
pushFinding(buckets, 'deprecated', { severity: 'warn', location: warn.packagePath, message: `${warn.package}@${warn.version}: ${warn.message}` });
|
|
165
|
+
}
|
|
166
|
+
const unresolvedSeverity = failOnUnresolved ? 'error' : 'warn';
|
|
167
|
+
for (const item of deprecationResult.unresolvedItems) {
|
|
168
|
+
pushFinding(buckets, 'deprecated', { severity: unresolvedSeverity, location: item.packagePath, message: `could not scan ${item.package}@${item.version}: ${item.reason}` });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Bucket license findings: rejected licenses are errors, unknown licenses warn.
|
|
173
|
+
function collectLicenseFindings(buckets, licenseResult) {
|
|
174
|
+
for (const err of licenseResult.errors) {
|
|
175
|
+
pushFinding(buckets, 'licenses', { severity: 'error', location: err.package, message: `license "${err.license || 'UNKNOWN'}" not approved` });
|
|
176
|
+
}
|
|
177
|
+
for (const warn of licenseResult.warnings) {
|
|
178
|
+
pushFinding(buckets, 'licenses', { severity: 'warn', location: warn.package, message: `license "${warn.license || 'UNKNOWN'}" unknown` });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// One-line summary for the integrity section's count bits.
|
|
183
|
+
function integritySummary(r) {
|
|
184
|
+
const bits = [`${r.passed} verified`];
|
|
185
|
+
if (r.failed) bits.push(`${r.failed} mismatched`);
|
|
186
|
+
if (r.unresolved) bits.push(`${r.unresolved} unresolved`);
|
|
187
|
+
if (r.skipped) bits.push(`${r.skipped} skipped`);
|
|
188
|
+
return bits.join(' · ');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// One-line summary shared by the vuln and deprecation sections (scanned/flagged/…).
|
|
192
|
+
function scanSummary(r, flaggedKey, flaggedLabel) {
|
|
193
|
+
const bits = [`${r.scanned} scanned`];
|
|
194
|
+
if (r[flaggedKey]) bits.push(`${r[flaggedKey]} ${flaggedLabel}`);
|
|
195
|
+
if (r.unresolved) bits.push(`${r.unresolved} unresolved`);
|
|
196
|
+
if (r.skipped) bits.push(`${r.skipped} skipped`);
|
|
197
|
+
return bits.join(' · ');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// One-line summary for the license section's count bits.
|
|
201
|
+
function licenseSummary(r) {
|
|
202
|
+
const bits = [`${r.approved} ok`];
|
|
203
|
+
if (r.rejected) bits.push(`${r.rejected} rejected`);
|
|
204
|
+
if (r.unknown) bits.push(`${r.unknown} unknown`);
|
|
205
|
+
return bits.join(' · ');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// One-line summary for the install-scripts section, reconciled against allowScripts.
|
|
209
|
+
function installScriptsSummary(tally) {
|
|
210
|
+
if (tally.total === 0) return 'none';
|
|
211
|
+
if (tally.v12Aware) {
|
|
212
|
+
return `${tally.total} ${tally.total === 1 ? 'script' : 'scripts'} · ${tally.allowed.length} allowed · ${tally.blocked.length} blocked`;
|
|
213
|
+
}
|
|
214
|
+
// No allowScripts map: npm v12 blocks every install script by default.
|
|
215
|
+
return `${tally.total} ${tally.total === 1 ? 'script' : 'scripts'} · ${tally.blocked.length} blocked by npm v12 (no allowScripts)`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Default pass-state summary for a generic section: count its findings or fall back.
|
|
219
|
+
function genericSummary(id, findings) {
|
|
220
|
+
const sev = worstSeverity(findings);
|
|
221
|
+
if (sev) return `${findings.length} ${findings.length === 1 ? 'finding' : 'findings'}`;
|
|
222
|
+
return DEFAULT_PASS_SUMMARY[id] || 'pass';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Build a passing/severity result whose summary comes from the given producer.
|
|
226
|
+
function liveSection(findings, summary) {
|
|
227
|
+
return { status: worstSeverity(findings) || 'pass', summary };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Per-section describers keyed by section id. Each returns { status, summary },
|
|
231
|
+
// short-circuiting to a 'skip' when the underlying check didn't run.
|
|
232
|
+
const SECTION_DESCRIBERS = {
|
|
233
|
+
integrity(findings, state) {
|
|
234
|
+
// The registry check may be off (--offline / --no-integrity) while the offline
|
|
235
|
+
// `integrity-hygiene` audit rule still bucketed findings here. A skipped section
|
|
236
|
+
// must not silently carry (and count) findings, so only report `skip` when the
|
|
237
|
+
// bucket is genuinely empty; otherwise surface the offline findings and let their
|
|
238
|
+
// severity drive the status and the rollup. (The `integrity: false` boolean can't
|
|
239
|
+
// distinguish --offline from --no-integrity, so the label stays flag-neutral.)
|
|
240
|
+
if (!state.integrity) {
|
|
241
|
+
if (findings.length === 0) return { status: 'skip', summary: 'registry check skipped' };
|
|
242
|
+
const n = findings.length;
|
|
243
|
+
return liveSection(findings, `registry check skipped · ${n} offline finding${n === 1 ? '' : 's'}`);
|
|
244
|
+
}
|
|
245
|
+
return liveSection(findings, integritySummary(state.integrityResult));
|
|
246
|
+
},
|
|
247
|
+
vuln(findings, state) {
|
|
248
|
+
if (!state.vuln) return { status: 'skip', summary: 'skipped (--offline)' };
|
|
249
|
+
return liveSection(findings, scanSummary(state.vulnResult, 'vulnerable', 'vulnerable'));
|
|
250
|
+
},
|
|
251
|
+
deprecated(findings, state) {
|
|
252
|
+
if (!state.deprecated) return { status: 'skip', summary: 'skipped (--offline)' };
|
|
253
|
+
return liveSection(findings, scanSummary(state.deprecationResult, 'deprecated', 'deprecated'));
|
|
254
|
+
},
|
|
255
|
+
licenses(findings, state) {
|
|
256
|
+
// An unexpected checkLicenses failure (malformed CSV, fs permission error,
|
|
257
|
+
// internal bug) is recorded as an error-severity finding upstream — NOT swallowed
|
|
258
|
+
// into a passing skip — so the license policy gate trips when the check breaks.
|
|
259
|
+
if (state.licenseError) return liveSection(findings, `check failed (${state.licenseError})`);
|
|
260
|
+
if (state.licenseSkip) return { status: 'skip', summary: `skipped (${state.licenseSkip})` };
|
|
261
|
+
return liveSection(findings, licenseSummary(state.licenseResult));
|
|
262
|
+
},
|
|
263
|
+
'install-scripts'(findings, state) {
|
|
264
|
+
return liveSection(findings, installScriptsSummary(state.scriptTally));
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Resolve a section's { status, summary } from its findings and the run's results.
|
|
269
|
+
function describeSection(id, findings, state) {
|
|
270
|
+
if (state.flavor === 'pnpm' && !PNPM_LIVE_SECTIONS.has(id)) {
|
|
271
|
+
return { status: 'skip', summary: 'N/A (pnpm)' };
|
|
272
|
+
}
|
|
273
|
+
if (state.flavor !== 'pnpm' && NPM_NA_SECTIONS.has(id)) {
|
|
274
|
+
return { status: 'skip', summary: 'N/A (npm)' };
|
|
275
|
+
}
|
|
276
|
+
const describer = SECTION_DESCRIBERS[id];
|
|
277
|
+
if (describer) return describer(findings, state);
|
|
278
|
+
return liveSection(findings, genericSummary(id, findings));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Run every available check and build a structured report.
|
|
283
|
+
*
|
|
284
|
+
* @param {object} target - { lockfile, packageJson|null, filePath, dir }
|
|
285
|
+
* @param {object} options
|
|
286
|
+
* @param {object} options.auditConfig - Resolved/raw audit config (passed to runAudit)
|
|
287
|
+
* @param {boolean} options.integrity - Run registry integrity verification (default true)
|
|
288
|
+
* @param {boolean} options.license - Run license validation (default true)
|
|
289
|
+
* @param {string} options.licensesCsv - Path to approved-licenses CSV
|
|
290
|
+
* @param {string} options.nodeModulesPath - node_modules path for license reads
|
|
291
|
+
* @param {boolean} options.strict - Treat warnings as failures
|
|
292
|
+
* @param {number} options.maxWarnings - Warning budget (-1 = unlimited)
|
|
293
|
+
* @param {number} options.concurrency / options.timeoutMs / options.defaultRegistry / options.failOnUnresolved
|
|
294
|
+
* @param {Function} options.fetchIntegrity - Injectable registry transport (tests)
|
|
295
|
+
* @param {Function} options.onProgress - Progress callback for the integrity stage
|
|
296
|
+
* @returns {Promise<object>} { filePath, sections, summary }
|
|
297
|
+
*/
|
|
298
|
+
// Merge caller options over the report defaults, resolving CSV / node_modules
|
|
299
|
+
// paths relative to the target dir. Returns the fully-defaulted option set.
|
|
300
|
+
function resolveRunOptions(options, dir) {
|
|
301
|
+
return {
|
|
302
|
+
auditConfig: {},
|
|
303
|
+
integrity: true,
|
|
304
|
+
license: true,
|
|
305
|
+
vuln: true,
|
|
306
|
+
deprecated: true,
|
|
307
|
+
failOnDeprecated: false,
|
|
308
|
+
minSeverity: 'high',
|
|
309
|
+
licensesCsv: path.join(dir, 'approved-licenses.csv'),
|
|
310
|
+
nodeModulesPath: path.join(dir, 'node_modules'),
|
|
311
|
+
strict: false,
|
|
312
|
+
maxWarnings: -1,
|
|
313
|
+
concurrency: 8,
|
|
314
|
+
timeoutMs: 10000,
|
|
315
|
+
defaultRegistry: undefined,
|
|
316
|
+
// Fail closed by default: a registry-backed scan that couldn't complete must not
|
|
317
|
+
// pass the report as clean. Set false (CLI `--allow-unresolved`) to tolerate.
|
|
318
|
+
failOnUnresolved: true,
|
|
319
|
+
fetchIntegrity: null,
|
|
320
|
+
fetchAdvisories: null,
|
|
321
|
+
fetchManifest: null,
|
|
322
|
+
onProgress: null,
|
|
323
|
+
...options
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Install-script tally (allowed vs blocked), reconciled against npm v12's
|
|
328
|
+
// package.json `allowScripts` — used for the section's summary line.
|
|
329
|
+
function tallyInstallScripts(lockfile, packageJson, auditConfig) {
|
|
330
|
+
const sampleRule = auditConfig.rules && auditConfig.rules['install-scripts'];
|
|
331
|
+
const isResolved = sampleRule && typeof sampleRule === 'object' && !Array.isArray(sampleRule) && typeof sampleRule.severity === 'string';
|
|
332
|
+
const resolvedConfig = isResolved ? auditConfig : mergeConfig(auditConfig);
|
|
333
|
+
const scriptOptions = (resolvedConfig.rules['install-scripts'] || {}).options || {};
|
|
334
|
+
return classifyInstallScripts(lockfile, packageJson, scriptOptions);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Bucket audit findings into report sections (normalized shape).
|
|
338
|
+
function bucketAuditFindings(buckets, audit) {
|
|
339
|
+
for (const f of audit.findings) {
|
|
340
|
+
const id = RULE_SECTION[f.ruleId] || 'structure';
|
|
341
|
+
pushFinding(buckets, id, { severity: f.severity, location: f.packagePath, message: f.message, ruleId: f.ruleId });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Optional defaultRegistry spread, shared by every network stage.
|
|
346
|
+
function registryOption(defaultRegistry) {
|
|
347
|
+
return defaultRegistry ? { defaultRegistry } : {};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Registry integrity verification (network). Returns the result or null when off.
|
|
351
|
+
async function runIntegrityStage(buckets, lockfile, opts) {
|
|
352
|
+
if (!opts.integrity) return null;
|
|
353
|
+
const result = await checkIntegrity(lockfile, {
|
|
354
|
+
concurrency: opts.concurrency, timeoutMs: opts.timeoutMs, failOnUnresolved: opts.failOnUnresolved,
|
|
355
|
+
fetchIntegrity: opts.fetchIntegrity, onProgress: opts.onProgress, ...registryOption(opts.defaultRegistry)
|
|
356
|
+
});
|
|
357
|
+
collectIntegrityFindings(buckets, result, opts.failOnUnresolved);
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Known-vulnerability scan (network; registry bulk advisory endpoint).
|
|
362
|
+
async function runVulnStage(buckets, lockfile, opts) {
|
|
363
|
+
if (!opts.vuln) return null;
|
|
364
|
+
const result = await checkVulnerabilities(lockfile, {
|
|
365
|
+
concurrency: opts.concurrency, timeoutMs: opts.timeoutMs, minSeverity: opts.minSeverity,
|
|
366
|
+
failOnUnresolved: opts.failOnUnresolved, fetchAdvisories: opts.fetchAdvisories, onProgress: opts.onProgress,
|
|
367
|
+
...registryOption(opts.defaultRegistry)
|
|
368
|
+
});
|
|
369
|
+
collectVulnFindings(buckets, result, opts.failOnUnresolved);
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Deprecation scan (network; registry version manifest `deprecated` field).
|
|
374
|
+
async function runDeprecationStage(buckets, lockfile, opts) {
|
|
375
|
+
if (!opts.deprecated) return null;
|
|
376
|
+
const result = await checkDeprecations(lockfile, {
|
|
377
|
+
concurrency: opts.concurrency, timeoutMs: opts.timeoutMs, failOnDeprecated: opts.failOnDeprecated,
|
|
378
|
+
failOnUnresolved: opts.failOnUnresolved, fetchManifest: opts.fetchManifest, onProgress: opts.onProgress,
|
|
379
|
+
...registryOption(opts.defaultRegistry)
|
|
380
|
+
});
|
|
381
|
+
collectDeprecationFindings(buckets, result, opts.failOnUnresolved);
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// License validation (filesystem; needs node_modules + an approved list).
|
|
386
|
+
// Returns { licenseResult, licenseSkip, licenseError }. The two benign degrade cases
|
|
387
|
+
// (no node_modules, no CSV) are explicit `existsSync` skips above. An UNEXPECTED
|
|
388
|
+
// checkLicenses failure (malformed CSV, fs permission error, internal bug) must NOT be
|
|
389
|
+
// swallowed into a passing skip — it's recorded as an error-severity finding so the
|
|
390
|
+
// gate trips (fail-closed), and surfaced via `licenseError`.
|
|
391
|
+
async function runLicenseStage(buckets, lockfile, opts) {
|
|
392
|
+
if (!opts.license) return { licenseResult: null, licenseSkip: 'disabled' };
|
|
393
|
+
if (!fs.existsSync(opts.nodeModulesPath)) return { licenseResult: null, licenseSkip: 'no node_modules' };
|
|
394
|
+
if (!fs.existsSync(opts.licensesCsv)) return { licenseResult: null, licenseSkip: 'no approved-licenses.csv' };
|
|
395
|
+
try {
|
|
396
|
+
const licenseResult = await checkLicenses(lockfile, {
|
|
397
|
+
csvPath: opts.licensesCsv, nodeModulesPath: opts.nodeModulesPath, strict: opts.strict
|
|
398
|
+
});
|
|
399
|
+
collectLicenseFindings(buckets, licenseResult);
|
|
400
|
+
return { licenseResult, licenseSkip: null };
|
|
401
|
+
} catch (e) {
|
|
402
|
+
pushFinding(buckets, 'licenses', { severity: 'error', location: opts.licensesCsv, message: `license check failed: ${e.message}` });
|
|
403
|
+
return { licenseResult: null, licenseSkip: null, licenseError: e.message };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// The 5-level ladder used by the suite-wide `--fail-on severity=` gate.
|
|
408
|
+
const SEVERITY_RANK = { info: 0, low: 1, moderate: 2, high: 3, critical: 4 };
|
|
409
|
+
|
|
410
|
+
// Does any finding meet or exceed the severity gate? This is the suite-wide CI gate:
|
|
411
|
+
// each finding's ladder severity comes from `ladderSeverity()` (advisory severity, else
|
|
412
|
+
// error→high / warn→low), so the gate applies across EVERY section that carries
|
|
413
|
+
// severities — not just the vuln stage, whose `minSeverity` only shaped its own
|
|
414
|
+
// error/warn split. An unknown/absent gate never trips.
|
|
415
|
+
function severityGateTripped(findings, gate) {
|
|
416
|
+
const threshold = SEVERITY_RANK[gate];
|
|
417
|
+
if (threshold === undefined) return false;
|
|
418
|
+
return findings.some((f) => (SEVERITY_RANK[ladderSeverity(f)] ?? 0) >= threshold);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Assemble the ordered sections (status + one-line summary) and roll up totals.
|
|
422
|
+
function assembleSections(buckets, sectionState, maxWarnings, severityGate) {
|
|
423
|
+
const sections = SECTIONS.map(({ id, title }) => {
|
|
424
|
+
const findings = buckets[id] || [];
|
|
425
|
+
const { status, summary } = describeSection(id, findings, sectionState);
|
|
426
|
+
return { id, title, status, summary, findings };
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const allFindings = sections.flatMap((s) => s.findings);
|
|
430
|
+
const errors = allFindings.filter((f) => f.severity === 'error').length;
|
|
431
|
+
const warnings = allFindings.filter((f) => f.severity === 'warn').length;
|
|
432
|
+
// Fail-closed rollup: any error, over the warning budget, OR any finding at/above the
|
|
433
|
+
// severity gate (default `high`, so errors→high already trip and the default behavior
|
|
434
|
+
// is unchanged; lowering the gate to e.g. `low` now correctly fails on warn-tier
|
|
435
|
+
// findings from deprecation / install-scripts / pinned-versions / etc.).
|
|
436
|
+
const gateTripped = severityGateTripped(allFindings, severityGate);
|
|
437
|
+
const pass = errors === 0 && (maxWarnings < 0 || warnings <= maxWarnings) && !gateTripped;
|
|
438
|
+
return { sections, summary: { errors, warnings, total: errors + warnings, pass } };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export async function runReport(target, options = {}) {
|
|
442
|
+
const { lockfile, packageJson = null, filePath = 'package-lock.json', dir = process.cwd() } = target;
|
|
443
|
+
if (!lockfile || typeof lockfile !== 'object') {
|
|
444
|
+
throw new ReportError('lockfile data is required', 'MISSING_LOCKFILE');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const opts = resolveRunOptions(options, dir);
|
|
448
|
+
const flavor = detectLockfileFlavor(lockfile);
|
|
449
|
+
const isPnpm = flavor === 'pnpm';
|
|
450
|
+
|
|
451
|
+
const buckets = {};
|
|
452
|
+
|
|
453
|
+
// 1. Offline audit rules + install-script tally, bucketed into report sections.
|
|
454
|
+
// runAudit self-gates by flavor: on pnpm only the config rules run
|
|
455
|
+
// (package.json / .npmrc / pnpm-workspace.yaml + pnpm field); the npm
|
|
456
|
+
// lockfile-shape rules no-op and their sections render N/A. The install-script
|
|
457
|
+
// tally is npm-only (pnpm gates builds via onlyBuiltDependencies).
|
|
458
|
+
const audit = runAudit({ lockfile, packageJson, filePath }, opts.auditConfig);
|
|
459
|
+
bucketAuditFindings(buckets, audit);
|
|
460
|
+
let scriptTally = { total: 0, allowed: [], blocked: [], v12Aware: false };
|
|
461
|
+
if (!isPnpm) {
|
|
462
|
+
scriptTally = tallyInstallScripts(lockfile, packageJson, opts.auditConfig);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 2–4. Network + filesystem stages (each no-ops to null when disabled). The
|
|
466
|
+
// registry-backed scans work for both flavors; license is npm-only for now.
|
|
467
|
+
const integrityResult = await runIntegrityStage(buckets, lockfile, opts);
|
|
468
|
+
const vulnResult = await runVulnStage(buckets, lockfile, opts);
|
|
469
|
+
const deprecationResult = await runDeprecationStage(buckets, lockfile, opts);
|
|
470
|
+
const { licenseResult, licenseSkip, licenseError = null } = isPnpm
|
|
471
|
+
? { licenseResult: null, licenseSkip: 'N/A (pnpm)' }
|
|
472
|
+
: await runLicenseStage(buckets, lockfile, opts);
|
|
473
|
+
|
|
474
|
+
// Assemble ordered sections with status + one-line summary, then roll up totals.
|
|
475
|
+
const sectionState = {
|
|
476
|
+
flavor,
|
|
477
|
+
integrity: opts.integrity, integrityResult, vuln: opts.vuln, vulnResult,
|
|
478
|
+
deprecated: opts.deprecated, deprecationResult, licenseSkip, licenseError, licenseResult, scriptTally
|
|
479
|
+
};
|
|
480
|
+
const { sections, summary } = assembleSections(buckets, sectionState, opts.maxWarnings, opts.minSeverity);
|
|
481
|
+
|
|
482
|
+
return { filePath, scanned: countExaminedPackages(lockfile), sections, summary };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Count the package entries the report examined (the non-root lockfile entries):
|
|
486
|
+
// v2/v3 keep them under `packages` (the "" root is excluded), v1 under `dependencies`.
|
|
487
|
+
function countExaminedPackages(lockfile) {
|
|
488
|
+
if (lockfile && lockfile.packages && typeof lockfile.packages === 'object') {
|
|
489
|
+
return Object.keys(lockfile.packages).filter((k) => k !== '').length;
|
|
490
|
+
}
|
|
491
|
+
if (lockfile && lockfile.dependencies && typeof lockfile.dependencies === 'object') {
|
|
492
|
+
return Object.keys(lockfile.dependencies).length;
|
|
493
|
+
}
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const DEFAULT_PASS_SUMMARY = {
|
|
498
|
+
structure: 'valid',
|
|
499
|
+
'package-json': 'valid',
|
|
500
|
+
npmrc: 'valid',
|
|
501
|
+
'pnpm-config': 'valid',
|
|
502
|
+
resolved: 'all TLS / trusted',
|
|
503
|
+
'install-scripts': 'none',
|
|
504
|
+
git: 'none',
|
|
505
|
+
remote: 'none',
|
|
506
|
+
pinned: 'all pinned',
|
|
507
|
+
orphans: 'none',
|
|
508
|
+
unused: 'none',
|
|
509
|
+
fund: 'suppressed'
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const ICON = { pass: ' ', warn: ' ', error: ' ', skip: '·' };
|
|
513
|
+
|
|
514
|
+
// Map each report section to a shared-schema `category`. The lockfile-hygiene
|
|
515
|
+
// audit sections fold into `lint`; policy-ish sections into `policy`; the scan
|
|
516
|
+
// sections keep their first-class categories.
|
|
517
|
+
const SECTION_CATEGORY = {
|
|
518
|
+
structure: 'lint',
|
|
519
|
+
'package-json': 'lint',
|
|
520
|
+
npmrc: 'lint',
|
|
521
|
+
'pnpm-config': 'lint',
|
|
522
|
+
integrity: 'integrity',
|
|
523
|
+
vuln: 'vulnerability',
|
|
524
|
+
deprecated: 'deprecated',
|
|
525
|
+
resolved: 'lint',
|
|
526
|
+
licenses: 'policy',
|
|
527
|
+
'install-scripts': 'policy',
|
|
528
|
+
git: 'policy',
|
|
529
|
+
remote: 'policy',
|
|
530
|
+
pinned: 'policy',
|
|
531
|
+
orphans: 'lint',
|
|
532
|
+
unused: 'unused',
|
|
533
|
+
fund: 'lint'
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// The report tier is error|warn; the shared ladder needs one of five strings.
|
|
537
|
+
// Advisory findings carry their TRUE ladder severity (advisorySeverity); for every
|
|
538
|
+
// other finding map error→high, warn→low (the suite's error/warn→ladder rule).
|
|
539
|
+
function ladderSeverity(f) {
|
|
540
|
+
if (f.advisorySeverity) return f.advisorySeverity;
|
|
541
|
+
return f.severity === 'error' ? 'high' : 'low';
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Map one report finding (any section) into the shared Finding shape. The report
|
|
545
|
+
// tier (error/warn) that drives the gate is preserved under `extra.reportSeverity`;
|
|
546
|
+
// advisory findings additionally carry the vuln-tool `extra` payload.
|
|
547
|
+
// The advisory-specific slice of a finding's `extra` payload (only populated for
|
|
548
|
+
// vuln/advisory findings); kept separate so reportFindingToSchema stays flat.
|
|
549
|
+
function advisoryExtra(f) {
|
|
550
|
+
return {
|
|
551
|
+
package: f.package ?? null,
|
|
552
|
+
installedVersion: f.version ?? null,
|
|
553
|
+
fixedVersion: f.fixedVersion ?? null,
|
|
554
|
+
advisoryId: f.advisoryId ?? null,
|
|
555
|
+
cve: f.cve ?? null,
|
|
556
|
+
vulnerableRange: f.vulnerableRange ?? null,
|
|
557
|
+
references: referencesOf(f)
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function reportFindingToSchema(sectionId, f) {
|
|
562
|
+
const isAdvisory = f.advisoryId != null || f.advisorySeverity != null;
|
|
563
|
+
const extra = { section: sectionId, reportSeverity: f.severity };
|
|
564
|
+
if (isAdvisory) Object.assign(extra, advisoryExtra(f));
|
|
565
|
+
return {
|
|
566
|
+
severity: ladderSeverity(f),
|
|
567
|
+
ruleId: f.advisoryId != null ? String(f.advisoryId) : (f.ruleId || sectionId),
|
|
568
|
+
category: SECTION_CATEGORY[sectionId] || 'lint',
|
|
569
|
+
message: f.message,
|
|
570
|
+
location: f.location ? { file: f.location, line: null, column: null } : null,
|
|
571
|
+
remediation: f.fixedVersion ? `upgrade to ${f.fixedVersion}` : null,
|
|
572
|
+
extra
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Wrap a runReport() result in the shared finding-schema envelope. The COMPLETE
|
|
578
|
+
* set of findings (every section, in section order) becomes the top-level
|
|
579
|
+
* `findings`; the section statuses/summaries and the report-tier rollup
|
|
580
|
+
* (errors/warnings/total/pass — the gate signal) are preserved under `extra`.
|
|
581
|
+
*
|
|
582
|
+
* @param {object} report - a runReport() result
|
|
583
|
+
* @param {object} [meta]
|
|
584
|
+
* @param {string} [meta.target] - path scanned, as given (defaults to report.filePath)
|
|
585
|
+
* @param {number} [meta.exitCode] - the real exit code (defaults to pass ? 0 : 1)
|
|
586
|
+
* @returns {object} the shared envelope
|
|
587
|
+
*/
|
|
588
|
+
export function reportEnvelope(report, meta = {}) {
|
|
589
|
+
const target = meta.target ?? report.filePath;
|
|
590
|
+
const exitCode = meta.exitCode ?? (report.summary.pass ? 0 : 1);
|
|
591
|
+
const findings = [];
|
|
592
|
+
for (const section of report.sections) {
|
|
593
|
+
for (const f of section.findings) findings.push(reportFindingToSchema(section.id, f));
|
|
594
|
+
}
|
|
595
|
+
return buildEnvelope({
|
|
596
|
+
target,
|
|
597
|
+
scanned: report.scanned ?? 0,
|
|
598
|
+
findings,
|
|
599
|
+
exitCode,
|
|
600
|
+
extra: {
|
|
601
|
+
sections: report.sections.map((s) => ({ id: s.id, title: s.title, status: s.status, summary: s.summary })),
|
|
602
|
+
summary: report.summary
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Render a report produced by runReport().
|
|
609
|
+
* @param {object} report
|
|
610
|
+
* @param {object} options - { format: 'human' | 'json', target?, exitCode? }
|
|
611
|
+
* In 'json' mode the output is the shared finding-schema envelope (see schema.js).
|
|
612
|
+
* @returns {string}
|
|
613
|
+
*/
|
|
614
|
+
export function formatReport(report, options = {}) {
|
|
615
|
+
const { format = 'human' } = options;
|
|
616
|
+
|
|
617
|
+
if (format === 'json') {
|
|
618
|
+
// The shared finding-schema envelope is the ONLY thing printed in json mode.
|
|
619
|
+
return JSON.stringify(reportEnvelope(report, { target: options.target, exitCode: options.exitCode }), null, 2);
|
|
620
|
+
}
|
|
621
|
+
if (format !== 'human') {
|
|
622
|
+
throw new ReportError(`Unknown report format: ${format}`, 'UNKNOWN_FORMAT');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const lines = [];
|
|
626
|
+
lines.push(`npm-check report — ${report.filePath}`);
|
|
627
|
+
lines.push('');
|
|
628
|
+
lines.push(...renderSummaryTable(report.sections));
|
|
629
|
+
for (const s of report.sections) {
|
|
630
|
+
lines.push(...renderSectionDetail(s));
|
|
631
|
+
}
|
|
632
|
+
lines.push('');
|
|
633
|
+
lines.push(renderFooter(report.summary));
|
|
634
|
+
return lines.join('\n');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Section summary table: one aligned status line per section.
|
|
638
|
+
function renderSummaryTable(sections) {
|
|
639
|
+
const titleWidth = Math.max(...sections.map((s) => s.title.length));
|
|
640
|
+
return sections.map((s) => ` ${ICON[s.status]} ${s.title.padEnd(titleWidth)} ${s.summary}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Detail block for a single section — empty unless it has findings.
|
|
644
|
+
function renderSectionDetail(s) {
|
|
645
|
+
if (s.findings.length === 0) return [];
|
|
646
|
+
const lines = ['', `${s.title} (${s.findings.length})`];
|
|
647
|
+
const shown = s.findings.slice(0, MAX_DETAIL);
|
|
648
|
+
for (const f of shown) {
|
|
649
|
+
const loc = f.location ? `${f.location} ` : '';
|
|
650
|
+
lines.push(` ${ICON[f.severity] || ' '} ${loc}${f.message}`);
|
|
651
|
+
}
|
|
652
|
+
if (s.findings.length > shown.length) {
|
|
653
|
+
lines.push(` …and ${s.findings.length - shown.length} more`);
|
|
654
|
+
}
|
|
655
|
+
return lines;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Closing totals line: an all-clear, or an error/warning count.
|
|
659
|
+
function renderFooter({ errors, warnings, total }) {
|
|
660
|
+
if (total === 0) return 'all checks passed';
|
|
661
|
+
const word = total === 1 ? 'problem' : 'problems';
|
|
662
|
+
return `${total} ${word} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'})`;
|
|
663
|
+
}
|