@dependably/npm-check 1.7.0 → 1.7.1
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/README.md +1 -5
- package/bin/cli.js +52 -16
- package/package.json +1 -1
- package/src/audit-config.js +215 -62
- package/src/audit.js +73 -8
- package/src/exceptions.js +281 -0
- package/src/progress-reporter.js +33 -1
- package/src/report.js +261 -48
package/src/report.js
CHANGED
|
@@ -18,7 +18,7 @@ import { buildEnvelope } from './schema.js';
|
|
|
18
18
|
// pin just like npm's, and the pinned-versions rule is npm+pnpm flavored). The
|
|
19
19
|
// npm-lockfile-shape sections (and license, pending a `.pnpm` store walk) are
|
|
20
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']);
|
|
21
|
+
const PNPM_LIVE_SECTIONS = new Set(['integrity', 'vuln', 'deprecated', 'package-json', 'npmrc', 'pnpm-config', 'pinned', 'unresolved']);
|
|
22
22
|
// The pnpm-config section has no meaning for an npm lockfile.
|
|
23
23
|
const NPM_NA_SECTIONS = new Set(['pnpm-config']);
|
|
24
24
|
|
|
@@ -60,6 +60,13 @@ const SECTIONS = [
|
|
|
60
60
|
{ id: 'integrity', title: 'Integrity (registry)' },
|
|
61
61
|
{ id: 'vuln', title: 'Known vulnerabilities' },
|
|
62
62
|
{ id: 'deprecated', title: 'Deprecated packages' },
|
|
63
|
+
// moonlitlabs/npm-check#35: entries the integrity/vuln/deprecation scans could
|
|
64
|
+
// not check at all (registry unreachable, endpoint unsupported, …) are a
|
|
65
|
+
// distinct signal from what each scan actually FOUND — filing them under
|
|
66
|
+
// whichever scan happened to run last (previously "Deprecated packages") made
|
|
67
|
+
// an unrelated check look like it had findings. They're collected here instead,
|
|
68
|
+
// one shared section, tagged with the check that couldn't complete.
|
|
69
|
+
{ id: 'unresolved', title: 'Unresolved (could not check)' },
|
|
63
70
|
{ id: 'resolved', title: 'Resolved URLs' },
|
|
64
71
|
{ id: 'licenses', title: 'Licenses' },
|
|
65
72
|
{ id: 'install-scripts', title: 'Install scripts' },
|
|
@@ -85,17 +92,52 @@ function pushFinding(buckets, id, finding) {
|
|
|
85
92
|
list.push(finding);
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
//
|
|
89
|
-
// (
|
|
95
|
+
// `checkIntegrity()` fails closed by default (failOnUnresolved): an unresolved
|
|
96
|
+
// entry (registry unreachable / no published sha512) is folded into BOTH
|
|
97
|
+
// `unresolvedItems` AND `errors`/`failed`, so the same package would otherwise
|
|
98
|
+
// be counted — and rendered — as "mismatched" as well as "unresolved". Since
|
|
99
|
+
// `errors` pushes the very same object reference for that case, a Set keyed on
|
|
100
|
+
// `unresolvedItems` reliably tells a GENUINE failure (a real hash mismatch, or
|
|
101
|
+
// an untrusted-host rejection) apart from an unresolved entry just riding along
|
|
102
|
+
// in `errors` for the fail-closed gate.
|
|
103
|
+
function integrityMismatches(integrityResult) {
|
|
104
|
+
const unresolvedSet = new Set(integrityResult.unresolvedItems);
|
|
105
|
+
return integrityResult.errors.filter((err) => !unresolvedSet.has(err));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Bucket integrity findings: one detail line per genuinely-mismatched package.
|
|
109
|
+
// The detail count here always equals the summary's "mismatched" bit (see
|
|
110
|
+
// integritySummary()) — both are derived from integrityMismatches(). Entries
|
|
111
|
+
// that couldn't be checked at all go to the shared "Unresolved" section instead
|
|
112
|
+
// (collectUnresolvedFindings) so this section only ever reports what integrity
|
|
113
|
+
// verification actually FOUND.
|
|
90
114
|
function collectIntegrityFindings(buckets, integrityResult, failOnUnresolved) {
|
|
91
|
-
for (const err of integrityResult
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
115
|
+
for (const err of integrityMismatches(integrityResult)) {
|
|
116
|
+
const who = err.version ? `${err.package}@${err.version}` : err.package;
|
|
117
|
+
const detail = err.reason || 'lockfile hash differs from registry';
|
|
118
|
+
pushFinding(buckets, 'integrity', { severity: 'error', location: err.packagePath, message: `mismatched: ${who}: ${detail}` });
|
|
95
119
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
collectUnresolvedFindings(buckets, 'integrity', 'integrity', integrityResult.unresolvedItems, failOnUnresolved);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// moonlitlabs/npm-check#35: shared collector for the "Unresolved (could not
|
|
124
|
+
// check)" section — every registry-backed scan (integrity/vuln/deprecated) uses
|
|
125
|
+
// the same `{ package, version, packagePath, reason }` unresolved-item shape, so
|
|
126
|
+
// one function renders them identically instead of each scan inventing its own
|
|
127
|
+
// "unresolved:" / "could not scan" phrasing. `check` tags which scan couldn't
|
|
128
|
+
// complete (surfaced in the message and carried structurally for JSON/grouping);
|
|
129
|
+
// `category` is the schema category that finding would have carried had it
|
|
130
|
+
// stayed in its own section (see reportFindingToSchema).
|
|
131
|
+
function collectUnresolvedFindings(buckets, check, category, unresolvedItems, failOnUnresolved) {
|
|
132
|
+
const severity = failOnUnresolved ? 'error' : 'warn';
|
|
133
|
+
for (const item of unresolvedItems) {
|
|
134
|
+
pushFinding(buckets, 'unresolved', {
|
|
135
|
+
severity,
|
|
136
|
+
location: item.packagePath,
|
|
137
|
+
message: `[${check}] ${item.package}@${item.version}: ${item.reason}`,
|
|
138
|
+
check,
|
|
139
|
+
category
|
|
140
|
+
});
|
|
99
141
|
}
|
|
100
142
|
}
|
|
101
143
|
|
|
@@ -130,31 +172,30 @@ function advisoryFinding(level, f) {
|
|
|
130
172
|
};
|
|
131
173
|
}
|
|
132
174
|
|
|
133
|
-
// Bucket vulnerability findings. Advisory findings
|
|
134
|
-
//
|
|
135
|
-
//
|
|
175
|
+
// Bucket vulnerability findings. Advisory findings (the section's own unit — see
|
|
176
|
+
// SECTION_HEADER_LABEL.vuln) are errors/warnings. Entries the scan could not
|
|
177
|
+
// check at all go to the shared "Unresolved" section (not from `errors`, where
|
|
178
|
+
// they have no advisoryId).
|
|
136
179
|
function collectVulnFindings(buckets, vulnResult, failOnUnresolved) {
|
|
137
180
|
for (const err of vulnResult.errors) {
|
|
138
181
|
// Discriminate on `reason` (like vulnEnvelope), NOT on `advisoryId`: an
|
|
139
|
-
// unresolved entry carries a `reason` and is rendered
|
|
140
|
-
// below, while a genuine advisory has none — including
|
|
141
|
-
// an `id`, which must still fail the run rather than
|
|
182
|
+
// unresolved entry carries a `reason` and is rendered via the shared
|
|
183
|
+
// unresolved collector below, while a genuine advisory has none — including
|
|
184
|
+
// one that merely lacks an `id`, which must still fail the run rather than
|
|
185
|
+
// silently vanish.
|
|
142
186
|
if (err.reason) continue;
|
|
143
187
|
pushFinding(buckets, 'vuln', advisoryFinding('error', err));
|
|
144
188
|
}
|
|
145
189
|
for (const warn of vulnResult.warnings) {
|
|
146
190
|
pushFinding(buckets, 'vuln', advisoryFinding('warn', warn));
|
|
147
191
|
}
|
|
148
|
-
|
|
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
|
-
}
|
|
192
|
+
collectUnresolvedFindings(buckets, 'vuln', 'vulnerability', vulnResult.unresolvedItems, failOnUnresolved);
|
|
152
193
|
}
|
|
153
194
|
|
|
154
195
|
// Bucket deprecation findings. A *found* deprecation is an error only under
|
|
155
|
-
// failOnDeprecated (it lands in `errors` with a message), else a warning.
|
|
156
|
-
//
|
|
157
|
-
//
|
|
196
|
+
// failOnDeprecated (it lands in `errors` with a message), else a warning. Entries
|
|
197
|
+
// the scan could not check at all go to the shared "Unresolved" section (those in
|
|
198
|
+
// `errors` for the fail-closed gate carry no `message`).
|
|
158
199
|
function collectDeprecationFindings(buckets, deprecationResult, failOnUnresolved) {
|
|
159
200
|
for (const err of deprecationResult.errors) {
|
|
160
201
|
if (!err.message) continue;
|
|
@@ -163,10 +204,7 @@ function collectDeprecationFindings(buckets, deprecationResult, failOnUnresolved
|
|
|
163
204
|
for (const warn of deprecationResult.warnings) {
|
|
164
205
|
pushFinding(buckets, 'deprecated', { severity: 'warn', location: warn.packagePath, message: `${warn.package}@${warn.version}: ${warn.message}` });
|
|
165
206
|
}
|
|
166
|
-
|
|
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
|
-
}
|
|
207
|
+
collectUnresolvedFindings(buckets, 'deprecated', 'deprecated', deprecationResult.unresolvedItems, failOnUnresolved);
|
|
170
208
|
}
|
|
171
209
|
|
|
172
210
|
// Bucket license findings: rejected licenses are errors, unknown licenses warn.
|
|
@@ -179,24 +217,49 @@ function collectLicenseFindings(buckets, licenseResult) {
|
|
|
179
217
|
}
|
|
180
218
|
}
|
|
181
219
|
|
|
182
|
-
// One-line summary for the integrity section's count bits.
|
|
220
|
+
// One-line summary for the integrity section's count bits. "mismatched" counts
|
|
221
|
+
// only GENUINE failures (integrityMismatches) — not `r.failed`, which also folds
|
|
222
|
+
// in unresolved entries when failing closed and would otherwise double-count the
|
|
223
|
+
// same package as both "mismatched" and "unresolved". Unresolved entries are no
|
|
224
|
+
// longer summarized here — they're counted (and detailed) in the shared
|
|
225
|
+
// "Unresolved (could not check)" section instead (moonlitlabs/npm-check#35).
|
|
183
226
|
function integritySummary(r) {
|
|
227
|
+
const mismatched = integrityMismatches(r).length;
|
|
184
228
|
const bits = [`${r.passed} verified`];
|
|
185
|
-
if (
|
|
186
|
-
if (r.unresolved) bits.push(`${r.unresolved} unresolved`);
|
|
229
|
+
if (mismatched) bits.push(`${mismatched} mismatched`);
|
|
187
230
|
if (r.skipped) bits.push(`${r.skipped} skipped`);
|
|
188
231
|
return bits.join(' · ');
|
|
189
232
|
}
|
|
190
233
|
|
|
191
234
|
// One-line summary shared by the vuln and deprecation sections (scanned/flagged/…).
|
|
192
|
-
|
|
235
|
+
// `flaggedAdjective` + "package(s)" always names the UNIT being counted
|
|
236
|
+
// ("vulnerable packages", "deprecated packages"), pluralized to match the count,
|
|
237
|
+
// so it reads the same as the section header's count (moonlitlabs/npm-check#35)
|
|
238
|
+
// — never a bare adjective a reader has to guess the unit of. `detail`, when
|
|
239
|
+
// given, appends a labeled sub-count in a trailing parenthetical for units that
|
|
240
|
+
// don't map 1:1 to packages (a vulnerable package can carry more than one
|
|
241
|
+
// advisory). Unresolved entries are summarized in the shared "Unresolved (could
|
|
242
|
+
// not check)" section instead.
|
|
243
|
+
function scanSummary(r, flaggedKey, flaggedAdjective, detail = null) {
|
|
193
244
|
const bits = [`${r.scanned} scanned`];
|
|
194
|
-
|
|
195
|
-
if (
|
|
245
|
+
const n = r[flaggedKey];
|
|
246
|
+
if (n) {
|
|
247
|
+
const unit = `${flaggedAdjective} package${n === 1 ? '' : 's'}`;
|
|
248
|
+
bits.push(`${n} ${unit}${detail ? ` (${detail})` : ''}`);
|
|
249
|
+
}
|
|
196
250
|
if (r.skipped) bits.push(`${r.skipped} skipped`);
|
|
197
251
|
return bits.join(' · ');
|
|
198
252
|
}
|
|
199
253
|
|
|
254
|
+
// One-line summary for the shared "Unresolved (could not check)" section: a
|
|
255
|
+
// breakdown by originating check (integrity/vuln/deprecated) so a reader knows
|
|
256
|
+
// which scan(s) couldn't complete without opening the detail block below.
|
|
257
|
+
function unresolvedSummary(findings) {
|
|
258
|
+
const byCheck = new Map();
|
|
259
|
+
for (const f of findings) byCheck.set(f.check, (byCheck.get(f.check) || 0) + 1);
|
|
260
|
+
return [...byCheck.entries()].map(([check, n]) => `${n} ${check}`).join(' · ');
|
|
261
|
+
}
|
|
262
|
+
|
|
200
263
|
// One-line summary for the license section's count bits.
|
|
201
264
|
function licenseSummary(r) {
|
|
202
265
|
const bits = [`${r.approved} ok`];
|
|
@@ -237,27 +300,45 @@ const SECTION_DESCRIBERS = {
|
|
|
237
300
|
// bucket is genuinely empty; otherwise surface the offline findings and let their
|
|
238
301
|
// severity drive the status and the rollup. (The `integrity: false` boolean can't
|
|
239
302
|
// distinguish --offline from --no-integrity, so the label stays flag-neutral.)
|
|
303
|
+
// moonlitlabs/npm-check#35: the bare detail text no longer says "skipped" —
|
|
304
|
+
// the fixed status column already says that (statusLabel()); the detail is
|
|
305
|
+
// just the reason, so the rendered row reads "skipped (--offline / …)"
|
|
306
|
+
// instead of the old "skipped (registry check skipped)".
|
|
240
307
|
if (!state.integrity) {
|
|
241
|
-
if (findings.length === 0) return { status: 'skip', summary: '
|
|
308
|
+
if (findings.length === 0) return { status: 'skip', summary: '--offline / --no-integrity' };
|
|
242
309
|
const n = findings.length;
|
|
243
310
|
return liveSection(findings, `registry check skipped · ${n} offline finding${n === 1 ? '' : 's'}`);
|
|
244
311
|
}
|
|
245
312
|
return liveSection(findings, integritySummary(state.integrityResult));
|
|
246
313
|
},
|
|
247
314
|
vuln(findings, state) {
|
|
248
|
-
if (!state.vuln) return { status: 'skip', summary: '
|
|
249
|
-
|
|
315
|
+
if (!state.vuln) return { status: 'skip', summary: '--offline' };
|
|
316
|
+
// moonlitlabs/npm-check#35: `findings` here is ONLY advisory findings (the
|
|
317
|
+
// unresolved entries that used to ride along have moved to the shared
|
|
318
|
+
// "Unresolved" section — see collectVulnFindings), so its length IS the
|
|
319
|
+
// advisory count, matching the section header 1:1 (SECTION_HEADER_LABEL.vuln).
|
|
320
|
+
const n = findings.length;
|
|
321
|
+
const detail = n ? `${n} advisor${n === 1 ? 'y' : 'ies'}` : null;
|
|
322
|
+
return liveSection(findings, scanSummary(state.vulnResult, 'vulnerable', 'vulnerable', detail));
|
|
250
323
|
},
|
|
251
324
|
deprecated(findings, state) {
|
|
252
|
-
if (!state.deprecated) return { status: 'skip', summary: '
|
|
325
|
+
if (!state.deprecated) return { status: 'skip', summary: '--offline' };
|
|
253
326
|
return liveSection(findings, scanSummary(state.deprecationResult, 'deprecated', 'deprecated'));
|
|
254
327
|
},
|
|
328
|
+
// moonlitlabs/npm-check#35: the shared "Unresolved (could not check)" section.
|
|
329
|
+
// Each finding carries `check` (integrity/vuln/deprecated); the summary breaks
|
|
330
|
+
// the total down by check so a reader can tell WHICH scan(s) couldn't complete
|
|
331
|
+
// without opening the detail block.
|
|
332
|
+
unresolved(findings) {
|
|
333
|
+
if (findings.length === 0) return { status: 'pass', summary: 'none' };
|
|
334
|
+
return liveSection(findings, unresolvedSummary(findings));
|
|
335
|
+
},
|
|
255
336
|
licenses(findings, state) {
|
|
256
337
|
// An unexpected checkLicenses failure (malformed CSV, fs permission error,
|
|
257
338
|
// internal bug) is recorded as an error-severity finding upstream — NOT swallowed
|
|
258
339
|
// into a passing skip — so the license policy gate trips when the check breaks.
|
|
259
340
|
if (state.licenseError) return liveSection(findings, `check failed (${state.licenseError})`);
|
|
260
|
-
if (state.licenseSkip) return { status: 'skip', summary:
|
|
341
|
+
if (state.licenseSkip) return { status: 'skip', summary: state.licenseSkip };
|
|
261
342
|
return liveSection(findings, licenseSummary(state.licenseResult));
|
|
262
343
|
},
|
|
263
344
|
'install-scripts'(findings, state) {
|
|
@@ -320,10 +401,70 @@ function resolveRunOptions(options, dir) {
|
|
|
320
401
|
fetchAdvisories: null,
|
|
321
402
|
fetchManifest: null,
|
|
322
403
|
onProgress: null,
|
|
404
|
+
// The `secure-resolved` and `no-remote-deps` audit rules independently flag
|
|
405
|
+
// a package resolved from an untrusted/unrecognized host — for a private
|
|
406
|
+
// registry mirror EVERY package resolved from it trips both rules, so
|
|
407
|
+
// "Resolved URLs" and "Remote-URL deps" end up reporting the same packages
|
|
408
|
+
// twice (one root cause, two lines). By default the report cross-references
|
|
409
|
+
// and collapses those duplicates (dedupeRemoteFindings); `verbose: true`
|
|
410
|
+
// (CLI `--verbose`) opts back into the full per-package listing.
|
|
411
|
+
verbose: false,
|
|
323
412
|
...options
|
|
324
413
|
};
|
|
325
414
|
}
|
|
326
415
|
|
|
416
|
+
// Pull a hostname out of a "no-remote-deps" finding's message, which always
|
|
417
|
+
// embeds the offending URL in parentheses (`${name} resolves from a remote
|
|
418
|
+
// URL (${resolved}) — …`). Returns null when the URL can't be parsed.
|
|
419
|
+
function extractRemoteHost(message) {
|
|
420
|
+
const match = message.match(/\(([^()]*:\/\/[^()]+)\)/);
|
|
421
|
+
if (!match) return null;
|
|
422
|
+
try {
|
|
423
|
+
return new URL(match[1]).hostname;
|
|
424
|
+
} catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Cross-reference "Remote-URL deps" (remote) against "Resolved URLs" (resolved):
|
|
430
|
+
// a `remote` finding whose package is ALREADY flagged in `resolved` is the same
|
|
431
|
+
// root cause (an untrusted/unrecognized host) reported twice, not two distinct
|
|
432
|
+
// problems. Collapse those into one grouped-by-host finding per host instead of
|
|
433
|
+
// one line per duplicated package — counting root causes, not duplicated lines.
|
|
434
|
+
// Findings for packages `remote` flags that `resolved` did NOT (e.g. a host
|
|
435
|
+
// trusted by one rule's config but not the other's) are left untouched.
|
|
436
|
+
function dedupeRemoteFindings(buckets) {
|
|
437
|
+
const resolved = buckets.resolved;
|
|
438
|
+
const remote = buckets.remote;
|
|
439
|
+
if (!resolved || !remote || resolved.length === 0 || remote.length === 0) return;
|
|
440
|
+
|
|
441
|
+
const resolvedPaths = new Set(resolved.map((f) => f.location));
|
|
442
|
+
const distinct = [];
|
|
443
|
+
const duplicatesByHost = new Map(); // host -> { count, severity }
|
|
444
|
+
|
|
445
|
+
for (const f of remote) {
|
|
446
|
+
if (!resolvedPaths.has(f.location)) {
|
|
447
|
+
distinct.push(f);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const host = extractRemoteHost(f.message) || 'an unrecognized host';
|
|
451
|
+
const group = duplicatesByHost.get(host) || { count: 0, severity: f.severity };
|
|
452
|
+
group.count++;
|
|
453
|
+
if (f.severity === 'error') group.severity = 'error'; // keep the worst severity seen
|
|
454
|
+
duplicatesByHost.set(host, group);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (const [host, { count, severity }] of duplicatesByHost) {
|
|
458
|
+
distinct.push({
|
|
459
|
+
severity,
|
|
460
|
+
location: null,
|
|
461
|
+
message: `${count} package${count === 1 ? '' : 's'} resolved from "${host}" — already reported under Resolved URLs (npm v12 needs --allow-remote; pass --verbose to list each package)`
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
buckets.remote = distinct;
|
|
466
|
+
}
|
|
467
|
+
|
|
327
468
|
// Install-script tally (allowed vs blocked), reconciled against npm v12's
|
|
328
469
|
// package.json `allowScripts` — used for the section's summary line.
|
|
329
470
|
function tallyInstallScripts(lockfile, packageJson, auditConfig) {
|
|
@@ -457,6 +598,10 @@ export async function runReport(target, options = {}) {
|
|
|
457
598
|
// tally is npm-only (pnpm gates builds via onlyBuiltDependencies).
|
|
458
599
|
const audit = runAudit({ lockfile, packageJson, filePath }, opts.auditConfig);
|
|
459
600
|
bucketAuditFindings(buckets, audit);
|
|
601
|
+
// Cross-reference "Remote-URL deps" against "Resolved URLs" so one untrusted
|
|
602
|
+
// host doesn't get reported once per package in each section (opt out with
|
|
603
|
+
// `verbose: true` / CLI `--verbose` for the full per-package listing).
|
|
604
|
+
if (!opts.verbose) dedupeRemoteFindings(buckets);
|
|
460
605
|
let scriptTally = { total: 0, allowed: [], blocked: [], v12Aware: false };
|
|
461
606
|
if (!isPnpm) {
|
|
462
607
|
scriptTally = tallyInstallScripts(lockfile, packageJson, opts.auditConfig);
|
|
@@ -509,11 +654,18 @@ const DEFAULT_PASS_SUMMARY = {
|
|
|
509
654
|
fund: 'suppressed'
|
|
510
655
|
};
|
|
511
656
|
|
|
512
|
-
|
|
657
|
+
// moonlitlabs/npm-check#35: one glyph per fixed status (see statusLabel()
|
|
658
|
+
// below) — consistently applied so a reader can scan the icon column alone
|
|
659
|
+
// and know the state, instead of the icon-and-vocabulary pair drifting per
|
|
660
|
+
// section.
|
|
661
|
+
const STATUS_ICON = { pass: '✓', warn: '⚠', error: '✖', skip: '·' };
|
|
513
662
|
|
|
514
663
|
// Map each report section to a shared-schema `category`. The lockfile-hygiene
|
|
515
664
|
// audit sections fold into `lint`; policy-ish sections into `policy`; the scan
|
|
516
|
-
// sections keep their first-class categories.
|
|
665
|
+
// sections keep their first-class categories. "unresolved" has no category of
|
|
666
|
+
// its own — every finding in that section carries an explicit `category` (the
|
|
667
|
+
// category it would have had in its own section; see collectUnresolvedFindings)
|
|
668
|
+
// which reportFindingToSchema prefers over this table.
|
|
517
669
|
const SECTION_CATEGORY = {
|
|
518
670
|
structure: 'lint',
|
|
519
671
|
'package-json': 'lint',
|
|
@@ -562,10 +714,13 @@ function reportFindingToSchema(sectionId, f) {
|
|
|
562
714
|
const isAdvisory = f.advisoryId != null || f.advisorySeverity != null;
|
|
563
715
|
const extra = { section: sectionId, reportSeverity: f.severity };
|
|
564
716
|
if (isAdvisory) Object.assign(extra, advisoryExtra(f));
|
|
717
|
+
if (f.check) extra.check = f.check; // shared "Unresolved" section: which scan couldn't complete
|
|
565
718
|
return {
|
|
566
719
|
severity: ladderSeverity(f),
|
|
567
720
|
ruleId: f.advisoryId != null ? String(f.advisoryId) : (f.ruleId || sectionId),
|
|
568
|
-
category
|
|
721
|
+
// A finding carries its own `category` when its section (unresolved) has no
|
|
722
|
+
// single category of its own; otherwise fall back to the section's category.
|
|
723
|
+
category: f.category || SECTION_CATEGORY[sectionId] || 'lint',
|
|
569
724
|
message: f.message,
|
|
570
725
|
location: f.location ? { file: f.location, line: null, column: null } : null,
|
|
571
726
|
remediation: f.fixedVersion ? `upgrade to ${f.fixedVersion}` : null,
|
|
@@ -634,20 +789,72 @@ export function formatReport(report, options = {}) {
|
|
|
634
789
|
return lines.join('\n');
|
|
635
790
|
}
|
|
636
791
|
|
|
637
|
-
//
|
|
792
|
+
// moonlitlabs/npm-check#35: the fixed status-column vocabulary. Every section
|
|
793
|
+
// renders one of these four labels — never a check-invented phrase like
|
|
794
|
+
// "valid" or "all TLS / trusted" (those are still shown, but demoted to the
|
|
795
|
+
// trailing detail parenthetical) — so the state is readable at a glance and
|
|
796
|
+
// phrased identically for a given state across every section and every run.
|
|
797
|
+
// The count in the warn/error labels is the number of findings AT that
|
|
798
|
+
// severity in the section (not the total, which may mix both tiers).
|
|
799
|
+
function statusLabel(s) {
|
|
800
|
+
if (s.status === 'pass') return 'ok';
|
|
801
|
+
if (s.status === 'skip') return 'skipped';
|
|
802
|
+
const n = s.findings.filter((f) => f.severity === s.status).length;
|
|
803
|
+
const word = s.status === 'error' ? 'error' : 'warning';
|
|
804
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Section summary table: one aligned status line per section — glyph, title,
|
|
808
|
+
// the fixed status label, then check-specific detail in a trailing
|
|
809
|
+
// parenthetical (moonlitlabs/npm-check#35).
|
|
638
810
|
function renderSummaryTable(sections) {
|
|
639
811
|
const titleWidth = Math.max(...sections.map((s) => s.title.length));
|
|
640
|
-
|
|
812
|
+
const labels = sections.map(statusLabel);
|
|
813
|
+
const labelWidth = Math.max(...labels.map((l) => l.length));
|
|
814
|
+
return sections.map((s, i) => {
|
|
815
|
+
const label = labels[i].padEnd(labelWidth);
|
|
816
|
+
const detail = s.summary ? ` (${s.summary})` : '';
|
|
817
|
+
return ` ${STATUS_ICON[s.status]} ${s.title.padEnd(titleWidth)} ${label}${detail}`;
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Literal severity tag for a detail line — mirrors the standalone `audit`
|
|
822
|
+
// command's own stylish vocabulary (formatAuditReport's `error`/`warn `), so a
|
|
823
|
+
// reader can tell at a glance which lines actually drive the exit code instead
|
|
824
|
+
// of a "problems" count that doesn't visibly correlate with any one line.
|
|
825
|
+
function severityTag(severity) {
|
|
826
|
+
return severity === 'error' ? 'error' : 'warn ';
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// moonlitlabs/npm-check#35: per-section detail-header overrides — the count
|
|
830
|
+
// next to a section's title in its own unit, matching the summary table row
|
|
831
|
+
// instead of a bare, ambiguous number. "Known vulnerabilities" is the unit
|
|
832
|
+
// mismatch the ticket called out: findings here are one per ADVISORY (a
|
|
833
|
+
// package can carry several), so the header spells out both the advisory
|
|
834
|
+
// count (== findings.length) and the distinct-package count.
|
|
835
|
+
const SECTION_HEADER_LABEL = {
|
|
836
|
+
vuln(findings) {
|
|
837
|
+
const advisories = findings.length;
|
|
838
|
+
const packages = new Set(findings.map((f) => `${f.package}@${f.version}`)).size;
|
|
839
|
+
return `${advisories} advisor${advisories === 1 ? 'y' : 'ies'} in ${packages} package${packages === 1 ? '' : 's'}`;
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
function sectionHeaderLabel(s) {
|
|
844
|
+
const custom = SECTION_HEADER_LABEL[s.id];
|
|
845
|
+
if (custom) return custom(s.findings);
|
|
846
|
+
const n = s.findings.length;
|
|
847
|
+
return `${n} finding${n === 1 ? '' : 's'}`;
|
|
641
848
|
}
|
|
642
849
|
|
|
643
850
|
// Detail block for a single section — empty unless it has findings.
|
|
644
851
|
function renderSectionDetail(s) {
|
|
645
852
|
if (s.findings.length === 0) return [];
|
|
646
|
-
const lines = ['', `${s.title}
|
|
853
|
+
const lines = ['', `${s.title} — ${sectionHeaderLabel(s)}`];
|
|
647
854
|
const shown = s.findings.slice(0, MAX_DETAIL);
|
|
648
855
|
for (const f of shown) {
|
|
649
856
|
const loc = f.location ? `${f.location} ` : '';
|
|
650
|
-
lines.push(` ${
|
|
857
|
+
lines.push(` ${severityTag(f.severity)} ${loc}${f.message}`);
|
|
651
858
|
}
|
|
652
859
|
if (s.findings.length > shown.length) {
|
|
653
860
|
lines.push(` …and ${s.findings.length - shown.length} more`);
|
|
@@ -655,9 +862,15 @@ function renderSectionDetail(s) {
|
|
|
655
862
|
return lines;
|
|
656
863
|
}
|
|
657
864
|
|
|
658
|
-
// Closing totals line: an all-clear, or an error/warning count
|
|
659
|
-
|
|
865
|
+
// Closing totals line: an all-clear, or an error/warning count plus a next-step
|
|
866
|
+
// hint when warnings alone did not fail the run (so "N warnings" doesn't read
|
|
867
|
+
// as ambiguous next to an exit code of 0).
|
|
868
|
+
function renderFooter({ errors, warnings, total, pass }) {
|
|
660
869
|
if (total === 0) return 'all checks passed';
|
|
661
870
|
const word = total === 1 ? 'problem' : 'problems';
|
|
662
|
-
|
|
871
|
+
const line = `${total} ${word} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'})`;
|
|
872
|
+
if (pass && warnings > 0) {
|
|
873
|
+
return `${line}\nwarnings above don't affect exit status; use \`npm-check report --fail-on count=0\` (or a severity gate) to fail CI on them`;
|
|
874
|
+
}
|
|
875
|
+
return line;
|
|
663
876
|
}
|