@pnpm/deps.compliance.commands 1101.0.0 → 1101.1.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.
@@ -12,6 +12,7 @@ export declare function help(): string;
12
12
  export type AuditOptions = Pick<UniversalOptions, 'dir'> & {
13
13
  fix?: boolean | 'override' | 'update';
14
14
  ignoreRegistryErrors?: boolean;
15
+ interactive?: boolean;
15
16
  json?: boolean;
16
17
  lockfileDir?: string;
17
18
  registries: Registries;
@@ -5,13 +5,16 @@ import { audit, normalizeGhsaId } from '@pnpm/deps.compliance.audit';
5
5
  import { PnpmError } from '@pnpm/error';
6
6
  import { update } from '@pnpm/installing.commands';
7
7
  import { readEnvLockfile, readWantedLockfile } from '@pnpm/lockfile.fs';
8
+ import { globalInfo } from '@pnpm/logger';
8
9
  import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header';
9
10
  import { table } from '@zkochan/table';
10
11
  import chalk, {} from 'chalk';
12
+ import enquirer from 'enquirer';
11
13
  import { pick, pickBy } from 'ramda';
12
14
  import { renderHelp } from 'render-help';
13
15
  import { fix } from './fix.js';
14
16
  import { fixWithUpdate } from './fixWithUpdate.js';
17
+ import { getAuditFixChoices } from './getAuditFixChoices.js';
15
18
  import { ignore } from './ignore.js';
16
19
  const AUDIT_LEVEL_NUMBER = {
17
20
  info: 0,
@@ -64,6 +67,7 @@ export function cliOptionsTypes() {
64
67
  'workspace',
65
68
  ], update.cliOptionsTypes()),
66
69
  ...rcOptionsTypes(),
70
+ interactive: Boolean,
67
71
  };
68
72
  }
69
73
  export const shorthands = {
@@ -117,6 +121,11 @@ export function help() {
117
121
  description: 'Ignore all vulnerabilities for which no fix exists',
118
122
  name: '--ignore-unfixable',
119
123
  },
124
+ {
125
+ description: 'Show vulnerabilities and select which ones to fix interactively',
126
+ name: '--interactive',
127
+ shortAlias: '-i',
128
+ },
120
129
  ],
121
130
  },
122
131
  ],
@@ -178,7 +187,7 @@ export async function handler(opts) {
178
187
  if (opts.fix === 'update' || opts.fix === 'override') {
179
188
  fixMethod = opts.fix;
180
189
  }
181
- else if (opts.fix === true) {
190
+ else if (opts.fix === true || (opts.interactive && !opts.fix)) {
182
191
  fixMethod = DEFAULT_FIX_METHOD;
183
192
  }
184
193
  else if (!opts.fix) {
@@ -187,19 +196,29 @@ export async function handler(opts) {
187
196
  else {
188
197
  throw new PnpmError('INVALID_FIX_OPTION', `Invalid value for --fix: ${opts.fix}. Should be one of "override" or "update"`);
189
198
  }
190
- if (fixMethod === 'update') {
191
- const result = await fixWithUpdate(auditReport, { ...opts, include });
192
- let output = formatFixWithUpdateOutput(result, auditReport);
193
- if (result.addedAgeExcludes.length > 0) {
194
- output += `\n${result.addedAgeExcludes.length} entries were added to minimumReleaseAgeExclude to allow installing the patched versions:\n${result.addedAgeExcludes.join('\n')}\n`;
195
- }
196
- return {
197
- exitCode: result.remaining.length > 0 ? 1 : 0,
198
- output,
199
+ if (fixMethod != null) {
200
+ // Pre-filter by auditLevel and ignoreGhsas so the interactive prompt
201
+ // and the update-method path see the same set of advisories that
202
+ // fix.ts's getFixableAdvisories filters for the override path.
203
+ let filteredAuditReport = {
204
+ ...auditReport,
205
+ advisories: filterAdvisoriesForFix(auditReport.advisories, opts),
199
206
  };
200
- }
201
- if (fixMethod === 'override') {
202
- const { vulnOverrides, addedAgeExcludes } = await fix(auditReport, opts);
207
+ if (opts.interactive) {
208
+ filteredAuditReport = await interactiveAuditFix(filteredAuditReport);
209
+ }
210
+ if (fixMethod === 'update') {
211
+ const result = await fixWithUpdate(filteredAuditReport, { ...opts, include });
212
+ let output = formatFixWithUpdateOutput(result, filteredAuditReport);
213
+ if (result.addedAgeExcludes.length > 0) {
214
+ output += `\n${result.addedAgeExcludes.length} entries were added to minimumReleaseAgeExclude to allow installing the patched versions:\n${result.addedAgeExcludes.join('\n')}\n`;
215
+ }
216
+ return {
217
+ exitCode: result.remaining.length > 0 ? 1 : 0,
218
+ output,
219
+ };
220
+ }
221
+ const { vulnOverrides, addedAgeExcludes } = await fix(filteredAuditReport, opts);
203
222
  if (Object.values(vulnOverrides).length === 0) {
204
223
  return {
205
224
  exitCode: 0,
@@ -350,4 +369,78 @@ export function formatFixWithUpdateOutput(result, auditReport) {
350
369
  output.push('');
351
370
  return output.join('\n');
352
371
  }
372
+ function filterAdvisoriesForFix(advisories, opts) {
373
+ const auditLevel = AUDIT_LEVEL_NUMBER[opts.auditLevel ?? 'low'];
374
+ const ignoreGhsas = opts.auditConfig?.ignoreGhsas;
375
+ const ignoreGhsaSet = ignoreGhsas?.length ? new Set(ignoreGhsas.map(normalizeGhsaId)) : undefined;
376
+ return Object.fromEntries(Object.entries(advisories).filter(([, { severity, github_advisory_id: ghsaId }]) => {
377
+ if (AUDIT_LEVEL_NUMBER[severity] < auditLevel)
378
+ return false;
379
+ if (ignoreGhsaSet && ghsaId && ignoreGhsaSet.has(normalizeGhsaId(ghsaId)))
380
+ return false;
381
+ return true;
382
+ }));
383
+ }
384
+ async function interactiveAuditFix(auditReport) {
385
+ const choices = getAuditFixChoices(Object.values(auditReport.advisories));
386
+ if (choices.length === 0) {
387
+ return auditReport;
388
+ }
389
+ const { selectedVulnerabilities } = await enquirer.prompt({
390
+ choices,
391
+ footer: '\nEnter to start fixing. Ctrl-c to cancel.',
392
+ indicator(state, choice) {
393
+ return ` ${choice.enabled ? '●' : '○'}`;
394
+ },
395
+ message: 'Choose which vulnerabilities to fix ' +
396
+ `(Press ${chalk.cyan('<space>')} to select, ` +
397
+ `${chalk.cyan('<a>')} to toggle all, ` +
398
+ `${chalk.cyan('<i>')} to invert selection)`,
399
+ name: 'selectedVulnerabilities',
400
+ pointer: '❯',
401
+ result() {
402
+ return this.selected;
403
+ },
404
+ format() {
405
+ if (!this.state.submitted || this.state.cancelled)
406
+ return '';
407
+ if (Array.isArray(this.selected)) {
408
+ return this.selected
409
+ .filter((choice) => !/^\[.+\]$/.test(choice.name))
410
+ .map((choice) => this.styles.primary(choice.name)).join(', ');
411
+ }
412
+ return this.styles.primary(this.selected.name);
413
+ },
414
+ styles: {
415
+ dark: chalk.reset,
416
+ em: chalk.bgBlack.whiteBright,
417
+ success: chalk.reset,
418
+ },
419
+ type: 'multiselect',
420
+ validate(value) {
421
+ if (value.length === 0) {
422
+ return 'You must choose at least one vulnerability.';
423
+ }
424
+ return true;
425
+ },
426
+ j() {
427
+ return this.down();
428
+ },
429
+ k() {
430
+ return this.up();
431
+ },
432
+ cancel() {
433
+ // By default, canceling the prompt via Ctrl+c throws an empty string.
434
+ // The custom cancel function prevents that behavior.
435
+ // Otherwise, pnpm CLI would print an error and confuse users.
436
+ // See related issue: https://github.com/enquirer/enquirer/issues/225
437
+ globalInfo('Audit fix canceled');
438
+ process.exit(0);
439
+ },
440
+ }); // eslint-disable-line @typescript-eslint/no-explicit-any
441
+ const selectedKeys = new Set(selectedVulnerabilities.map((c) => c.value));
442
+ const selectedAdvisories = Object.fromEntries(Object.entries(auditReport.advisories)
443
+ .filter(([, advisory]) => selectedKeys.has(`${advisory.module_name}@${advisory.vulnerable_versions}`)));
444
+ return { ...auditReport, advisories: selectedAdvisories };
445
+ }
353
446
  //# sourceMappingURL=audit.js.map
@@ -5,4 +5,5 @@ export interface FixResult {
5
5
  addedAgeExcludes: string[];
6
6
  }
7
7
  export declare function fix(auditReport: AuditReport, opts: AuditOptions): Promise<FixResult>;
8
+ export declare function caretRangeForPatched(patchedRange: string): string;
8
9
  export declare function createMinimumReleaseAgeExcludes(advisories: AuditAdvisory[]): string[];
package/lib/audit/fix.js CHANGED
@@ -32,10 +32,18 @@ function createOverrides(advisories) {
32
32
  for (const advisory of advisories) {
33
33
  if (!advisory.patched_versions)
34
34
  continue;
35
- entries.push([`${advisory.module_name}@${advisory.vulnerable_versions}`, advisory.patched_versions]);
35
+ entries.push([`${advisory.module_name}@${advisory.vulnerable_versions}`, caretRangeForPatched(advisory.patched_versions)]);
36
36
  }
37
37
  return Object.fromEntries(entries);
38
38
  }
39
+ // Use the minimum patched version with a caret so pnpm stays within the
40
+ // same major as the fix. `>=X.Y.Z` alone can silently promote a dep to a
41
+ // later breaking major; `^X.Y.Z` still satisfies the patch while
42
+ // preserving the major the user originally pinned to.
43
+ export function caretRangeForPatched(patchedRange) {
44
+ const min = semver.minVersion(patchedRange);
45
+ return min ? `^${min.version}` : patchedRange;
46
+ }
39
47
  export function createMinimumReleaseAgeExcludes(advisories) {
40
48
  const excludes = new Set();
41
49
  for (const advisory of advisories) {
@@ -0,0 +1,14 @@
1
+ import type { AuditAdvisory } from '@pnpm/deps.compliance.audit';
2
+ export interface AuditChoiceRow {
3
+ name: string;
4
+ value: string;
5
+ disabled?: boolean;
6
+ }
7
+ type AuditChoiceGroup = Array<{
8
+ name: string;
9
+ message: string;
10
+ choices: AuditChoiceRow[];
11
+ disabled?: boolean;
12
+ }>;
13
+ export declare function getAuditFixChoices(advisories: AuditAdvisory[]): AuditChoiceGroup;
14
+ export {};
@@ -0,0 +1,116 @@
1
+ import { getBorderCharacters, table } from '@zkochan/table';
2
+ import chalk from 'chalk';
3
+ import { groupBy } from 'ramda';
4
+ import { caretRangeForPatched } from './fix.js';
5
+ const AUDIT_COLOR = {
6
+ info: chalk.dim,
7
+ low: chalk.bold,
8
+ moderate: chalk.bold.yellow,
9
+ high: chalk.bold.red,
10
+ critical: chalk.bold.red,
11
+ };
12
+ const SEVERITY_ORDER = ['critical', 'high', 'moderate', 'low', 'info'];
13
+ const SEVERITY_RANK = {
14
+ info: 0,
15
+ low: 1,
16
+ moderate: 2,
17
+ high: 3,
18
+ critical: 4,
19
+ };
20
+ const COLUMN_HEADER = ['Package', 'Vulnerable', 'Patched', 'Advisories'];
21
+ export function getAuditFixChoices(advisories) {
22
+ if (advisories.length === 0) {
23
+ return [];
24
+ }
25
+ const fixable = advisories.filter(({ patched_versions: p }) => p != null);
26
+ if (fixable.length === 0) {
27
+ return [];
28
+ }
29
+ // Collapse advisories that share module_name@vulnerable_versions: they
30
+ // produce the same override, so showing them as separate rows would
31
+ // duplicate every choice. Titles are joined; the highest severity wins.
32
+ const deduped = dedupeByFixKey(fixable);
33
+ const grouped = groupBy((a) => a.severity, deduped);
34
+ const finalChoices = [];
35
+ for (const severity of SEVERITY_ORDER) {
36
+ const groupAdvisories = grouped[severity];
37
+ if (!groupAdvisories?.length)
38
+ continue;
39
+ const rows = [
40
+ { raw: COLUMN_HEADER, key: '', disabled: true },
41
+ ];
42
+ for (const advisory of groupAdvisories) {
43
+ const key = `${advisory.module_name}@${advisory.vulnerable_versions}`;
44
+ rows.push({
45
+ raw: [
46
+ advisory.module_name,
47
+ advisory.vulnerable_versions,
48
+ advisory.patched_versions ? caretRangeForPatched(advisory.patched_versions) : '',
49
+ advisory.github_advisory_id ?? '',
50
+ ],
51
+ key,
52
+ });
53
+ }
54
+ const rendered = alignColumns(rows.map(r => r.raw));
55
+ const choices = rows.map((row, i) => {
56
+ if (i === 0) {
57
+ return {
58
+ name: rendered[i],
59
+ value: '',
60
+ disabled: true,
61
+ hint: '',
62
+ };
63
+ }
64
+ return {
65
+ name: row.key,
66
+ message: rendered[i],
67
+ value: row.key,
68
+ };
69
+ });
70
+ finalChoices.push({
71
+ name: `[${severity}]`,
72
+ choices,
73
+ message: AUDIT_COLOR[severity](severity),
74
+ });
75
+ }
76
+ return finalChoices;
77
+ }
78
+ function alignColumns(rows) {
79
+ // No per-column width / truncate: each cell is short (package name,
80
+ // semver range, one or two GHSA IDs), so the table library can
81
+ // auto-size columns and still keep each row on a single line. Keeping
82
+ // rows 1:1 with rendered lines is required because the caller picks
83
+ // `rendered[i]` for choice `i`.
84
+ return table(rows, {
85
+ border: getBorderCharacters('void'),
86
+ columnDefault: {
87
+ paddingLeft: 0,
88
+ paddingRight: 2,
89
+ },
90
+ drawHorizontalLine: () => false,
91
+ }).split('\n').filter((line) => line.trim() !== '');
92
+ }
93
+ function dedupeByFixKey(advisories) {
94
+ const byKey = new Map();
95
+ for (const advisory of advisories) {
96
+ const key = `${advisory.module_name}@${advisory.vulnerable_versions}`;
97
+ const existing = byKey.get(key);
98
+ if (!existing) {
99
+ byKey.set(key, advisory);
100
+ continue;
101
+ }
102
+ const keepSeverity = SEVERITY_RANK[advisory.severity] > SEVERITY_RANK[existing.severity]
103
+ ? advisory.severity
104
+ : existing.severity;
105
+ const mergedId = existing.github_advisory_id && advisory.github_advisory_id && existing.github_advisory_id !== advisory.github_advisory_id
106
+ ? `${existing.github_advisory_id}, ${advisory.github_advisory_id}`
107
+ : existing.github_advisory_id || advisory.github_advisory_id;
108
+ byKey.set(key, {
109
+ ...existing,
110
+ severity: keepSeverity,
111
+ github_advisory_id: mergedId,
112
+ });
113
+ }
114
+ return Array.from(byKey.values());
115
+ }
116
+ //# sourceMappingURL=getAuditFixChoices.js.map
package/lib/sbom/sbom.js CHANGED
@@ -3,6 +3,7 @@ import { packageManager } from '@pnpm/cli.meta';
3
3
  import { docsUrl, readProjectManifestOnly } from '@pnpm/cli.utils';
4
4
  import { types as allTypes } from '@pnpm/config.reader';
5
5
  import { WANTED_LOCKFILE } from '@pnpm/constants';
6
+ import { isSpdxLicenseExpression, resolveLicenseFromDir } from '@pnpm/deps.compliance.license-resolver';
6
7
  import { collectSbomComponents, serializeCycloneDx, serializeSpdx, } from '@pnpm/deps.compliance.sbom';
7
8
  import { PnpmError } from '@pnpm/error';
8
9
  import { getLockfileImporterId, readWantedLockfile } from '@pnpm/lockfile.fs';
@@ -103,7 +104,14 @@ export async function handler(opts, _params = []) {
103
104
  const manifest = await readProjectManifestOnly(opts.dir);
104
105
  const rootName = manifest.name ?? 'unknown';
105
106
  const rootVersion = manifest.version ?? '0.0.0';
106
- const rootLicense = typeof manifest.license === 'string' ? manifest.license : undefined;
107
+ // Keep the root in sync with transitive deps: consult manifest `license` /
108
+ // legacy `licenses` first, then fall back to an on-disk LICENSE file. Drop
109
+ // file-scanned values that aren't SPDX-valid to avoid non-compliant output.
110
+ const rootLicenseInfo = await resolveLicenseFromDir({ manifest, dir: opts.dir });
111
+ const rootLicense = rootLicenseInfo && rootLicenseInfo.name !== 'Unknown' &&
112
+ (!rootLicenseInfo.licenseFile || isSpdxLicenseExpression(rootLicenseInfo.name))
113
+ ? rootLicenseInfo.name
114
+ : undefined;
107
115
  const rootAuthor = typeof manifest.author === 'string'
108
116
  ? manifest.author
109
117
  : manifest.author?.name;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/deps.compliance.commands",
3
- "version": "1101.0.0",
3
+ "version": "1101.1.0",
4
4
  "description": "pnpm commands for audit, licenses, and sbom",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -29,32 +29,38 @@
29
29
  "dependencies": {
30
30
  "@zkochan/table": "^2.0.1",
31
31
  "chalk": "^5.6.0",
32
+ "enquirer": "^2.4.1",
32
33
  "memoize": "^10.2.0",
33
34
  "ramda": "npm:@pnpm/ramda@0.28.1",
34
35
  "render-help": "^2.0.0",
35
36
  "semver": "^7.7.2",
36
37
  "@pnpm/cli.command": "1100.0.0",
37
- "@pnpm/cli.common-cli-options-help": "1100.0.0",
38
- "@pnpm/cli.utils": "1100.0.1",
38
+ "@pnpm/config.writer": "1100.0.2",
39
+ "@pnpm/config.reader": "1101.1.0",
39
40
  "@pnpm/cli.meta": "1100.0.1",
40
- "@pnpm/config.reader": "1100.0.1",
41
- "@pnpm/deps.compliance.audit": "1101.0.0",
41
+ "@pnpm/cli.common-cli-options-help": "1100.0.0",
42
+ "@pnpm/deps.compliance.audit": "1101.0.1",
42
43
  "@pnpm/constants": "1100.0.0",
43
- "@pnpm/deps.compliance.license-scanner": "1100.0.1",
44
- "@pnpm/lockfile.fs": "1100.0.1",
45
- "@pnpm/deps.compliance.sbom": "1100.0.1",
46
- "@pnpm/lockfile.types": "1100.0.1",
47
44
  "@pnpm/error": "1100.0.0",
48
- "@pnpm/installing.commands": "1100.0.1",
49
- "@pnpm/lockfile.utils": "1100.0.1",
50
- "@pnpm/lockfile.walker": "1100.0.1",
45
+ "@pnpm/deps.compliance.license-resolver": "1100.0.0",
46
+ "@pnpm/deps.compliance.license-scanner": "1100.0.3",
47
+ "@pnpm/cli.utils": "1101.0.0",
48
+ "@pnpm/installing.commands": "1100.1.1",
49
+ "@pnpm/lockfile.fs": "1100.0.2",
50
+ "@pnpm/lockfile.types": "1100.0.2",
51
+ "@pnpm/deps.compliance.sbom": "1100.0.3",
52
+ "@pnpm/lockfile.utils": "1100.0.2",
53
+ "@pnpm/lockfile.walker": "1100.0.2",
51
54
  "@pnpm/network.auth-header": "1100.0.1",
52
- "@pnpm/workspace.project-manifest-reader": "1100.0.1",
53
- "@pnpm/store.path": "1100.0.0",
54
- "@pnpm/config.writer": "1100.0.1",
55
- "@pnpm/types": "1101.0.0"
55
+ "@pnpm/types": "1101.0.0",
56
+ "@pnpm/workspace.project-manifest-reader": "1100.0.2",
57
+ "@pnpm/store.path": "1100.0.0"
58
+ },
59
+ "peerDependencies": {
60
+ "@pnpm/logger": ">=1001.0.0 <1002.0.0"
56
61
  },
57
62
  "devDependencies": {
63
+ "@jest/globals": "30.3.0",
58
64
  "@pnpm/registry-mock": "6.0.0",
59
65
  "@types/ramda": "0.31.1",
60
66
  "@types/semver": "7.7.1",
@@ -63,13 +69,14 @@
63
69
  "nock": "13.3.4",
64
70
  "read-yaml-file": "^3.0.0",
65
71
  "tempy": "3.0.0",
66
- "@pnpm/deps.compliance.commands": "1101.0.0",
67
- "@pnpm/pkg-manifest.reader": "1100.0.1",
68
- "@pnpm/prepare": "1100.0.1",
69
- "@pnpm/testing.command-defaults": "1100.0.0",
70
72
  "@pnpm/test-fixtures": "1100.0.0",
73
+ "@pnpm/logger": "1100.0.0",
74
+ "@pnpm/testing.command-defaults": "1100.0.1",
75
+ "@pnpm/pkg-manifest.reader": "1100.0.1",
71
76
  "@pnpm/testing.mock-agent": "1100.0.1",
72
- "@pnpm/workspace.projects-filter": "1100.0.1"
77
+ "@pnpm/workspace.projects-filter": "1100.0.3",
78
+ "@pnpm/prepare": "1100.0.2",
79
+ "@pnpm/deps.compliance.commands": "1101.1.0"
73
80
  },
74
81
  "engines": {
75
82
  "node": ">=22.13"