@knime/utils 1.10.1 → 1.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @knime/utils
2
2
 
3
+ ## 1.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ce7f504: Added a tool to provide audit for pnpm using the bulk advisory endpoint
8
+
3
9
  ## 1.10.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -18,6 +18,12 @@ To use it in your project, you can import it as follows:
18
18
  import { capitalize } from "@knime/utils";
19
19
  ```
20
20
 
21
+ ## Audit tool
22
+
23
+ This package also ships a CLI tool (`pnpm-bulk-audit`) that audits dependencies
24
+ using the npm bulk advisory API — a drop-in workaround while `pnpm audit` is
25
+ broken. See [src/audit/README.md](src/audit/README.md) for details.
26
+
21
27
  # Join the Community!
22
28
 
23
29
  - [KNIME Forum](https://forum.knime.com/)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knime/utils",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "Internal utility functions and constants shared across @knime packages.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,7 +14,7 @@
14
14
  "dependencies": {
15
15
  "date-fns": "2.30.0",
16
16
  "date-fns-tz": "2.0.0",
17
- "dompurify": "^3.3.2",
17
+ "dompurify": "^3.4.0",
18
18
  "filesize": "10.0.6"
19
19
  },
20
20
  "peerDependencies": {
@@ -34,10 +34,16 @@
34
34
  "CHANGELOG.md"
35
35
  ],
36
36
  "sideEffects": false,
37
+ "bin": {
38
+ "pnpm-bulk-audit": "./src/audit/cli.js"
39
+ },
37
40
  "exports": {
38
41
  ".": {
39
42
  "import": "./src/index.ts"
40
43
  },
44
+ "./audit": {
45
+ "import": "./src/audit/bulkAudit.js"
46
+ },
41
47
  "./globals.d.ts": {
42
48
  "import": "./src/config/globals.d.ts"
43
49
  },
@@ -0,0 +1,118 @@
1
+ # pnpm Bulk Audit
2
+
3
+ A workaround for `pnpm audit` failing with HTTP 410 after npm retired the legacy
4
+ audit endpoints. This tool calls the replacement
5
+ [bulk advisory endpoint](https://api-docs.npmjs.com/#tag/Audit) directly.
6
+
7
+ **Background:** In April 2026 npm retired `/-/npm/v1/security/audits` (and the
8
+ `/quick` variant). `pnpm audit` on all 10.x and early 11.x versions is broken
9
+ because it still calls the old endpoints. See
10
+ [pnpm/pnpm#11265](https://github.com/pnpm/pnpm/issues/11265). This tool bridges
11
+ the gap until pnpm ships a fix.
12
+
13
+ ## Quick start
14
+
15
+ Add `@knime/utils` as a dev dependency (it is already present in the monorepo),
16
+ then call the binary directly:
17
+
18
+ ```jsonc
19
+ // package.json
20
+ {
21
+ "scripts": {
22
+ "audit": "pnpm-bulk-audit",
23
+ },
24
+ "devDependencies": {
25
+ "@knime/utils": "workspace:*",
26
+ },
27
+ }
28
+ ```
29
+
30
+ Then run:
31
+
32
+ ```bash
33
+ pnpm run audit
34
+ ```
35
+
36
+ ## CLI options
37
+
38
+ ```
39
+ Usage: pnpm-bulk-audit [options]
40
+
41
+ Options:
42
+ --lockfile <path> Path to pnpm-lock.yaml (default: ./pnpm-lock.yaml)
43
+ --registry <url> npm registry URL (default: https://registry.npmjs.org)
44
+ --severity <level> Minimum severity: low, moderate, high, critical (default: low)
45
+ --prod, -P Only report vulnerabilities in production dependencies
46
+ --json Output raw JSON
47
+ --no-ignore Skip reading ignoreCves/ignoreGhsas from config
48
+ --help, -h Show this help
49
+ ```
50
+
51
+ ## Prod vs dev classification
52
+
53
+ Each vulnerability is annotated with `[prod]` or `[dev]` based on whether the
54
+ affected package is reachable from any importer's `dependencies` (as opposed to
55
+ `devDependencies`). The classifier walks the full dependency graph in the
56
+ lockfile, following workspace links transitively.
57
+
58
+ Use `--prod` (or `-P`) to only report vulnerabilities in production
59
+ dependencies.
60
+
61
+ ## Ignore lists
62
+
63
+ The tool reads `auditConfig.ignoreCves` and `auditConfig.ignoreGhsas` from two
64
+ places (both are merged):
65
+
66
+ ### pnpm-workspace.yaml
67
+
68
+ ```yaml
69
+ auditConfig:
70
+ ignoreCves:
71
+ - CVE-2026-4800
72
+ ignoreGhsas:
73
+ - GHSA-78h3-pg4x-j8cv
74
+ ```
75
+
76
+ ### package.json
77
+
78
+ ```jsonc
79
+ {
80
+ "pnpm": {
81
+ "auditConfig": {
82
+ "ignoreCves": ["CVE-2026-4800"],
83
+ "ignoreGhsas": ["GHSA-78h3-pg4x-j8cv"],
84
+ },
85
+ },
86
+ }
87
+ ```
88
+
89
+ **GHSA IDs** are matched directly from the advisory URL returned by the npm
90
+ registry.
91
+ **CVE IDs** require an extra lookup via the GitHub advisories API
92
+ (`api.github.com/advisories/{ghsa}`) because the npm bulk endpoint does not
93
+ include CVE identifiers in its response.
94
+
95
+ Use `--no-ignore` to skip all ignore-list processing.
96
+
97
+ ## Programmatic usage
98
+
99
+ ```ts
100
+ import { audit, formatAuditReport, readAuditConfig } from "@knime/utils/audit";
101
+
102
+ const config = readAuditConfig(".");
103
+ const result = await audit({
104
+ lockfilePath: "./pnpm-lock.yaml",
105
+ minSeverity: "high",
106
+ auditConfig: config,
107
+ });
108
+ console.log(formatAuditReport(result));
109
+ ```
110
+
111
+ ## How it works
112
+
113
+ 1. Parses the `packages:` section of `pnpm-lock.yaml` to extract every
114
+ `name@version` pair.
115
+ 2. POSTs them as `{ "pkg": ["version", …] }` to the npm bulk advisory endpoint
116
+ (`/-/npm/v1/security/advisories/bulk`).
117
+ 3. Filters the response by severity and ignore lists.
118
+ 4. Exits with code 1 if any vulnerabilities remain, 0 otherwise.
@@ -0,0 +1,671 @@
1
+ /* eslint-disable no-console, n/no-process-env */
2
+ /**
3
+ * Audits packages from a pnpm-lock.yaml file using the npm bulk advisory API.
4
+ *
5
+ * The legacy npm audit endpoints (/-/npm/v1/security/audits) have been retired
6
+ * (HTTP 410). This module calls the replacement bulk advisory endpoint
7
+ * (/-/npm/v1/security/advisories/bulk) directly.
8
+ *
9
+ * See: https://api-docs.npmjs.com/#tag/Audit
10
+ * See: https://github.com/pnpm/pnpm/issues/11265
11
+ */
12
+
13
+ import { execSync } from "node:child_process";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { resolve } from "node:path";
16
+
17
+ const SEVERITY_ORDER = ["low", "moderate", "high", "critical"];
18
+
19
+ /**
20
+ * Resolve a GitHub API token from GITHUB_TOKEN env var or `gh auth token`.
21
+ * Returns null (with a warning) if neither is available.
22
+ */
23
+ function getGitHubToken() {
24
+ if (process.env.GITHUB_TOKEN) {
25
+ return process.env.GITHUB_TOKEN;
26
+ }
27
+ try {
28
+ return execSync("gh auth token", { encoding: "utf-8" }).trim();
29
+ } catch {
30
+ // gh CLI not installed or not authenticated
31
+ }
32
+ console.warn(
33
+ "Warning: No GitHub token found (GITHUB_TOKEN env or `gh auth token`).\n" +
34
+ "GHSA→CVE resolution will use unauthenticated requests (60 req/hour limit).\n",
35
+ );
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Extract the GHSA ID from an advisory URL like
41
+ * `https://github.com/advisories/GHSA-r5fr-rjxr-66jc`.
42
+ */
43
+ function extractGhsaId(url) {
44
+ const match = url.match(/(GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4})/i);
45
+ return match ? match[1] : null;
46
+ }
47
+
48
+ /**
49
+ * Minimally parse a YAML value that is a string array. Handles lines like:
50
+ * - CVE-2026-4800
51
+ * - CVE-2026-2950 # comment
52
+ */
53
+ function parseYamlStringArray(lines, startIdx, indent) {
54
+ const result = [];
55
+ for (let i = startIdx; i < lines.length; i++) {
56
+ const line = lines[i];
57
+ // Skip blank lines and comment-only lines at the expected indentation
58
+ if (/^\s*$/.test(line) || new RegExp(`^ {${indent}}#`).test(line)) {
59
+ continue;
60
+ }
61
+ // Check if this line is an array item at the expected indentation
62
+ const itemMatch = line.match(new RegExp(`^ {${indent}}-\\s+(.+)`));
63
+ if (!itemMatch) {
64
+ break;
65
+ }
66
+ // Strip inline comments
67
+ const value = itemMatch[1].replace(/#.*$/, "").trim();
68
+ if (value) {
69
+ result.push(value);
70
+ }
71
+ }
72
+ return result;
73
+ }
74
+
75
+ /**
76
+ * Read auditConfig from pnpm-workspace.yaml (top-level `auditConfig` key).
77
+ */
78
+ function readAuditConfigFromWorkspace(workspaceDir) {
79
+ const config = {};
80
+ const wsPath = resolve(workspaceDir, "pnpm-workspace.yaml");
81
+ if (!existsSync(wsPath)) {
82
+ return config;
83
+ }
84
+
85
+ const content = readFileSync(wsPath, "utf-8");
86
+ const lines = content.split("\n");
87
+
88
+ let inAuditConfig = false;
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const line = lines[i];
91
+
92
+ if (/^auditConfig:\s*$/.test(line)) {
93
+ inAuditConfig = true;
94
+ continue;
95
+ }
96
+ // New top-level key ends auditConfig
97
+ if (inAuditConfig && /^\S/.test(line)) {
98
+ break;
99
+ }
100
+ if (!inAuditConfig) {
101
+ continue;
102
+ }
103
+
104
+ if (/^ {2}ignoreCves:\s*$/.test(line)) {
105
+ // eslint-disable-next-line no-magic-numbers
106
+ config.ignoreCves = parseYamlStringArray(lines, i + 1, 4);
107
+ }
108
+ if (/^ {2}ignoreGhsas:\s*$/.test(line)) {
109
+ // eslint-disable-next-line no-magic-numbers
110
+ config.ignoreGhsas = parseYamlStringArray(lines, i + 1, 4);
111
+ }
112
+ }
113
+
114
+ return config;
115
+ }
116
+
117
+ /**
118
+ * Read auditConfig from package.json (`pnpm.auditConfig` key).
119
+ */
120
+ function readAuditConfigFromPackageJson(workspaceDir) {
121
+ const config = {};
122
+ const pkgPath = resolve(workspaceDir, "package.json");
123
+ if (!existsSync(pkgPath)) {
124
+ return config;
125
+ }
126
+
127
+ try {
128
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
129
+ const auditConfig = pkg?.pnpm?.auditConfig;
130
+ if (auditConfig) {
131
+ if (Array.isArray(auditConfig.ignoreCves)) {
132
+ config.ignoreCves = auditConfig.ignoreCves;
133
+ }
134
+ if (Array.isArray(auditConfig.ignoreGhsas)) {
135
+ config.ignoreGhsas = auditConfig.ignoreGhsas;
136
+ }
137
+ }
138
+ } catch {
139
+ // ignore parse errors
140
+ }
141
+
142
+ return config;
143
+ }
144
+
145
+ /**
146
+ * Merge auditConfig from pnpm-workspace.yaml and package.json.
147
+ * Both sources are combined (union of ignore lists).
148
+ */
149
+ export function readAuditConfig(workspaceDir) {
150
+ const fromWorkspace = readAuditConfigFromWorkspace(workspaceDir);
151
+ const fromPkg = readAuditConfigFromPackageJson(workspaceDir);
152
+
153
+ return {
154
+ ignoreCves: [
155
+ ...(fromWorkspace.ignoreCves ?? []),
156
+ ...(fromPkg.ignoreCves ?? []),
157
+ ],
158
+ ignoreGhsas: [
159
+ ...(fromWorkspace.ignoreGhsas ?? []),
160
+ ...(fromPkg.ignoreGhsas ?? []),
161
+ ],
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Resolve GHSA IDs to their associated CVE IDs using the GitHub advisories API.
167
+ * Returns a map from GHSA ID to an array of CVE IDs.
168
+ */
169
+ async function resolveGhsaToCves(ghsaIds) {
170
+ const map = new Map();
171
+ if (ghsaIds.length === 0) {
172
+ return map;
173
+ }
174
+
175
+ const token = getGitHubToken();
176
+ const headers = { Accept: "application/vnd.github+json" };
177
+ if (token) {
178
+ headers.Authorization = `Bearer ${token}`;
179
+ }
180
+
181
+ // Fetch in parallel, but limit concurrency to avoid rate limits
182
+ const batchSize = 10;
183
+ for (let i = 0; i < ghsaIds.length; i += batchSize) {
184
+ const batch = ghsaIds.slice(i, i + batchSize);
185
+ const results = await Promise.all(
186
+ batch.map(async (ghsaId) => {
187
+ try {
188
+ const resp = await fetch(
189
+ `https://api.github.com/advisories/${ghsaId}`,
190
+ { headers },
191
+ );
192
+ if (!resp.ok) {
193
+ console.warn(
194
+ `Warning: GitHub advisory lookup for ${ghsaId} failed (HTTP ${resp.status}). CVE ignore rules may not apply.`,
195
+ );
196
+ return { ghsaId, cves: [] };
197
+ }
198
+ const data = await resp.json();
199
+ const cves = (data.identifiers ?? [])
200
+ .filter((id) => id.type === "CVE")
201
+ .map((id) => id.value);
202
+ return { ghsaId, cves };
203
+ } catch (err) {
204
+ console.warn(
205
+ `Warning: GitHub advisory lookup for ${ghsaId} failed: ${err instanceof Error ? err.message : String(err)}. CVE ignore rules may not apply.`,
206
+ );
207
+ return { ghsaId, cves: [] };
208
+ }
209
+ }),
210
+ );
211
+ for (const { ghsaId, cves } of results) {
212
+ map.set(ghsaId, cves);
213
+ }
214
+ }
215
+
216
+ return map;
217
+ }
218
+
219
+ /**
220
+ * Parse a snapshot key like `lodash-es@4.17.23` or
221
+ * `vite@7.3.1(@types/node@24.10.1)(terser@5.44.1)` and return the
222
+ * base `name@version` (without peer suffixes).
223
+ */
224
+ function snapshotKeyToNameVersion(key) {
225
+ // Find the version segment: last `@` before any `(`
226
+ const parenIdx = key.indexOf("(");
227
+ const base = parenIdx >= 0 ? key.slice(0, parenIdx) : key;
228
+ return base;
229
+ }
230
+
231
+ /**
232
+ * Classify every package in the lockfile as "prod" or "dev" by walking the
233
+ * dependency graph starting from each importer's `dependencies`.
234
+ *
235
+ * Returns a Set of `name@version` strings that are reachable from production
236
+ * dependency roots. Any package NOT in this set is dev-only.
237
+ */
238
+ // eslint-disable-next-line complexity
239
+ export function classifyProdPackages(lockfilePath) {
240
+ const content = readFileSync(lockfilePath, "utf-8");
241
+ const lines = content.split("\n");
242
+
243
+ // --- Phase 1: parse all importers into structured data ---
244
+ const importers = new Map();
245
+
246
+ {
247
+ let section = "none";
248
+ let currentImporter = null;
249
+ let currentDepName = null;
250
+ let inDeps = false;
251
+
252
+ for (const line of lines) {
253
+ if (/^importers:\s*$/.test(line)) {
254
+ section = "importers";
255
+ continue;
256
+ }
257
+ if (/^(packages|snapshots|catalogs|settings):\s*$/.test(line)) {
258
+ if (section === "importers") {
259
+ break;
260
+ }
261
+ continue;
262
+ }
263
+ if (section !== "importers") {
264
+ continue;
265
+ }
266
+
267
+ // Importer key (2-space indent)
268
+ if (
269
+ /^ {2}\S/.test(line) &&
270
+ !line.match(
271
+ /^ {2}(dependencies|devDependencies|peerDependencies|optionalDependencies):/,
272
+ )
273
+ ) {
274
+ currentImporter = line.replace(/^ {2}/, "").replace(/:.*$/, "");
275
+ importers.set(currentImporter, { prodVersions: [], prodLinks: [] });
276
+ inDeps = false;
277
+ continue;
278
+ }
279
+
280
+ if (!currentImporter) {
281
+ continue;
282
+ }
283
+
284
+ // Sub-section detection
285
+ if (/^ {4}dependencies:\s*$/.test(line)) {
286
+ inDeps = true;
287
+ currentDepName = null;
288
+ continue;
289
+ }
290
+ if (
291
+ /^ {4}(devDependencies|peerDependencies|optionalDependencies):\s*$/.test(
292
+ line,
293
+ )
294
+ ) {
295
+ inDeps = false;
296
+ currentDepName = null;
297
+ continue;
298
+ }
299
+
300
+ if (inDeps) {
301
+ const versionMatch = line.match(/^ {8}version:\s+'?(.+?)'?\s*$/);
302
+ if (versionMatch) {
303
+ const ver = versionMatch[1];
304
+ const data = importers.get(currentImporter);
305
+ if (ver.startsWith("link:")) {
306
+ // eslint-disable-next-line no-magic-numbers
307
+ data.prodLinks.push(ver.slice(5)); // strip "link:" prefix
308
+ } else if (currentDepName) {
309
+ data.prodVersions.push(`${currentDepName}@${ver}`);
310
+ }
311
+ }
312
+ // Package name key: ` package-name:`
313
+ const nameMatch = line.match(/^ {6}'?([^':]+)'?:\s*$/);
314
+ if (nameMatch) {
315
+ currentDepName = nameMatch[1];
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ // Resolve workspace links to importer IDs.
322
+ function resolveLink(fromImporter, linkTarget) {
323
+ if (fromImporter === ".") {
324
+ return linkTarget.replace(/^\.\//, "");
325
+ }
326
+ const parts = fromImporter.split("/");
327
+ for (const seg of linkTarget.split("/")) {
328
+ if (seg === "..") {
329
+ parts.pop();
330
+ } else if (seg !== ".") {
331
+ parts.push(seg);
332
+ }
333
+ }
334
+ return parts.join("/");
335
+ }
336
+
337
+ // Collect all prod roots
338
+ const prodRoots = new Set();
339
+ const visitedImporters = new Set();
340
+
341
+ function collectProdFromImporter(importerId) {
342
+ if (visitedImporters.has(importerId)) {
343
+ return;
344
+ }
345
+ visitedImporters.add(importerId);
346
+ const data = importers.get(importerId);
347
+ if (!data) {
348
+ return;
349
+ }
350
+
351
+ for (const ver of data.prodVersions) {
352
+ prodRoots.add(ver);
353
+ }
354
+ for (const linkTarget of data.prodLinks) {
355
+ const linkedImporter = resolveLink(importerId, linkTarget);
356
+ collectProdFromImporter(linkedImporter);
357
+ }
358
+ }
359
+
360
+ for (const importerId of importers.keys()) {
361
+ collectProdFromImporter(importerId);
362
+ }
363
+
364
+ // --- Phase 2: parse snapshots to build adjacency map ---
365
+ const snapshotDeps = new Map();
366
+ let inSnapshots = false;
367
+ let currentSnapshot = null;
368
+ let inSnapshotDeps = false;
369
+
370
+ for (const line of lines) {
371
+ if (/^snapshots:\s*$/.test(line)) {
372
+ inSnapshots = true;
373
+ continue;
374
+ }
375
+ if (inSnapshots && /^\S/.test(line) && !line.startsWith("snapshots:")) {
376
+ break;
377
+ }
378
+ if (!inSnapshots) {
379
+ continue;
380
+ }
381
+
382
+ const snapMatch = line.match(/^ {2}'?([^'\s].*?)'?:\s*(\{\})?$/);
383
+ if (snapMatch && !line.startsWith(" ")) {
384
+ currentSnapshot = snapMatch[1];
385
+ if (!snapshotDeps.has(currentSnapshot)) {
386
+ snapshotDeps.set(currentSnapshot, []);
387
+ }
388
+ inSnapshotDeps = false;
389
+ if (snapMatch[2]) {
390
+ currentSnapshot = null;
391
+ }
392
+ continue;
393
+ }
394
+
395
+ if (!currentSnapshot) {
396
+ continue;
397
+ }
398
+
399
+ if (/^ {4}(dependencies|optionalDependencies):\s*$/.test(line)) {
400
+ inSnapshotDeps = true;
401
+ continue;
402
+ }
403
+ if (
404
+ /^ {4}\S/.test(line) &&
405
+ !line.match(/^ {4}(dependencies|optionalDependencies):/)
406
+ ) {
407
+ inSnapshotDeps = false;
408
+ continue;
409
+ }
410
+
411
+ if (inSnapshotDeps) {
412
+ const depMatch = line.match(/^ {6}'?([^':]+)'?:\s+'?(.+?)'?\s*$/);
413
+ if (depMatch) {
414
+ const depName = depMatch[1];
415
+ const depVersion = depMatch[2];
416
+ snapshotDeps.get(currentSnapshot).push(`${depName}@${depVersion}`);
417
+ }
418
+ }
419
+ }
420
+
421
+ // --- Phase 3: BFS from prod roots ---
422
+ const prodPackages = new Set();
423
+ const visited = new Set();
424
+ const queue = [...prodRoots];
425
+
426
+ while (queue.length > 0) {
427
+ const key = queue.pop();
428
+ if (visited.has(key)) {
429
+ continue;
430
+ }
431
+ visited.add(key);
432
+
433
+ const baseKey = snapshotKeyToNameVersion(key);
434
+ prodPackages.add(baseKey);
435
+
436
+ const deps = snapshotDeps.get(key);
437
+ if (deps) {
438
+ queue.push(...deps);
439
+ } else {
440
+ for (const [snapKey, snapDeps] of snapshotDeps) {
441
+ if (
442
+ snapshotKeyToNameVersion(snapKey) === baseKey &&
443
+ !visited.has(snapKey)
444
+ ) {
445
+ visited.add(snapKey);
446
+ queue.push(...snapDeps);
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ return prodPackages;
453
+ }
454
+
455
+ /**
456
+ * Minimally parse pnpm-lock.yaml to extract the `packages:` keys.
457
+ */
458
+ export function extractPackagesFromLockfile(lockfilePath) {
459
+ const content = readFileSync(lockfilePath, "utf-8");
460
+ const lines = content.split("\n");
461
+
462
+ const packages = new Map();
463
+ let inPackagesSection = false;
464
+
465
+ for (const line of lines) {
466
+ if (/^packages:\s*$/.test(line)) {
467
+ inPackagesSection = true;
468
+ continue;
469
+ }
470
+
471
+ if (
472
+ inPackagesSection &&
473
+ /^\S/.test(line) &&
474
+ !line.startsWith("packages:")
475
+ ) {
476
+ break;
477
+ }
478
+
479
+ if (!inPackagesSection) {
480
+ continue;
481
+ }
482
+
483
+ const match = line.match(/^ {2}'?([^':\s][^':]*?)@(\d[^':]*)'?:\s*$/);
484
+ if (!match) {
485
+ const unquotedMatch = line.match(
486
+ /^ {2}([^':\s][^':]*?)@(\d[^':]*?):\s*$/,
487
+ );
488
+ if (unquotedMatch) {
489
+ const name = unquotedMatch[1];
490
+ const version = unquotedMatch[2].replace(/\(.*$/, "");
491
+ if (name && version) {
492
+ // eslint-disable-next-line max-depth
493
+ if (!packages.has(name)) {
494
+ packages.set(name, new Set());
495
+ }
496
+ packages.get(name).add(version);
497
+ }
498
+ }
499
+ continue;
500
+ }
501
+
502
+ const name = match[1];
503
+ const version = match[2].replace(/\(.*$/, ""); // strip peer suffixes
504
+ if (name && version) {
505
+ if (!packages.has(name)) {
506
+ packages.set(name, new Set());
507
+ }
508
+ packages.get(name).add(version);
509
+ }
510
+ }
511
+
512
+ return packages;
513
+ }
514
+
515
+ /**
516
+ * Call the npm bulk advisory endpoint and return the report.
517
+ */
518
+ export async function fetchBulkAdvisories(
519
+ packages,
520
+ registry = "https://registry.npmjs.org",
521
+ ) {
522
+ const body = {};
523
+ for (const [name, versions] of packages) {
524
+ body[name] = [...versions];
525
+ }
526
+
527
+ const url = `${registry.replace(/\/$/, "")}/-/npm/v1/security/advisories/bulk`;
528
+
529
+ const response = await fetch(url, {
530
+ method: "POST",
531
+ headers: { "Content-Type": "application/json" },
532
+ body: JSON.stringify(body),
533
+ });
534
+
535
+ if (!response.ok) {
536
+ throw new Error(
537
+ `Bulk advisory endpoint responded with ${response.status}: ${await response.text()}`,
538
+ );
539
+ }
540
+
541
+ return response.json();
542
+ }
543
+
544
+ /**
545
+ * Run a full audit: parse lockfile, query the API, return structured results.
546
+ * Respects ignore lists from pnpm-workspace.yaml and package.json.
547
+ */
548
+ export async function audit(opts) {
549
+ const lockfilePath = resolve(opts.lockfilePath);
550
+ const packages = extractPackagesFromLockfile(lockfilePath);
551
+ const advisories = await fetchBulkAdvisories(packages, opts.registry);
552
+
553
+ // Classify prod vs dev
554
+ const prodPackages = classifyProdPackages(lockfilePath);
555
+
556
+ // Collect ignore sets
557
+ const ignoredGhsas = new Set(opts.auditConfig?.ignoreGhsas ?? []);
558
+ const ignoredCves = new Set(opts.auditConfig?.ignoreCves ?? []);
559
+ const needCveResolution = ignoredCves.size > 0;
560
+
561
+ // If we have CVEs to ignore, resolve GHSA→CVE mappings
562
+ let ghsaToCves = new Map();
563
+ if (needCveResolution) {
564
+ const allGhsaIds = new Set();
565
+ for (const advList of Object.values(advisories)) {
566
+ for (const a of advList) {
567
+ const ghsaId = extractGhsaId(a.url);
568
+ if (ghsaId) {
569
+ allGhsaIds.add(ghsaId);
570
+ }
571
+ }
572
+ }
573
+ ghsaToCves = await resolveGhsaToCves([...allGhsaIds]);
574
+ }
575
+
576
+ const minIdx = SEVERITY_ORDER.indexOf(opts.minSeverity ?? "low");
577
+ const severityCounts = {};
578
+ let totalVulnerabilities = 0;
579
+ let prodVulnerabilities = 0;
580
+ let devVulnerabilities = 0;
581
+ const filteredAdvisories = {};
582
+
583
+ for (const [pkg, advList] of Object.entries(advisories)) {
584
+ const versions = packages.get(pkg);
585
+ const isProd = versions
586
+ ? [...versions].some((v) => prodPackages.has(`${pkg}@${v}`))
587
+ : false;
588
+ const depType = isProd ? "prod" : "dev";
589
+
590
+ if (opts.prod && !isProd) {
591
+ continue;
592
+ }
593
+
594
+ const filtered = advList.filter((a) => {
595
+ const idx = SEVERITY_ORDER.indexOf(a.severity);
596
+ if (idx < minIdx) {
597
+ return false;
598
+ }
599
+
600
+ const ghsaId = extractGhsaId(a.url);
601
+ if (ghsaId && ignoredGhsas.has(ghsaId)) {
602
+ return false;
603
+ }
604
+
605
+ if (ghsaId && needCveResolution) {
606
+ const cves = ghsaToCves.get(ghsaId) ?? [];
607
+ if (cves.some((cve) => ignoredCves.has(cve))) {
608
+ return false;
609
+ }
610
+ }
611
+
612
+ return true;
613
+ });
614
+ if (filtered.length > 0) {
615
+ filteredAdvisories[pkg] = filtered.map((a) => ({ ...a, depType }));
616
+ for (const a of filtered) {
617
+ severityCounts[a.severity] = (severityCounts[a.severity] ?? 0) + 1;
618
+ totalVulnerabilities++;
619
+ if (isProd) {
620
+ prodVulnerabilities++;
621
+ } else {
622
+ devVulnerabilities++;
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ return {
629
+ advisories: filteredAdvisories,
630
+ totalVulnerabilities,
631
+ severityCounts,
632
+ prodVulnerabilities,
633
+ devVulnerabilities,
634
+ };
635
+ }
636
+
637
+ /**
638
+ * Format an audit result for human-readable console output.
639
+ */
640
+ export function formatAuditReport(result) {
641
+ if (result.totalVulnerabilities === 0) {
642
+ return "No vulnerabilities found.";
643
+ }
644
+
645
+ const lines = [];
646
+ lines.push(
647
+ `Found ${result.totalVulnerabilities} vulnerabilit${result.totalVulnerabilities === 1 ? "y" : "ies"} (${result.prodVulnerabilities} prod, ${result.devVulnerabilities} dev):\n`,
648
+ );
649
+
650
+ for (const severity of SEVERITY_ORDER) {
651
+ const count = result.severityCounts[severity];
652
+ if (count) {
653
+ lines.push(` ${severity}: ${count}`);
654
+ }
655
+ }
656
+ lines.push("");
657
+
658
+ for (const [pkg, advList] of Object.entries(result.advisories)) {
659
+ for (const adv of advList) {
660
+ const tag = adv.depType === "prod" ? "prod" : "dev";
661
+ // eslint-disable-next-line no-magic-numbers
662
+ lines.push(` ${adv.severity.toUpperCase().padEnd(9)} ${pkg} [${tag}]`);
663
+ lines.push(` ${adv.title}`);
664
+ lines.push(` Vulnerable: ${adv.vulnerable_versions}`);
665
+ lines.push(` ${adv.url}`);
666
+ lines.push("");
667
+ }
668
+ }
669
+
670
+ return lines.join("\n");
671
+ }
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console, n/no-process-exit */
3
+
4
+ /**
5
+ * CLI wrapper for the bulk advisory audit.
6
+ *
7
+ * Usage:
8
+ * pnpm-bulk-audit [--lockfile path] [--registry url] [--severity level] [--json]
9
+ *
10
+ * Defaults:
11
+ * --lockfile pnpm-lock.yaml (in cwd)
12
+ * --registry https://registry.npmjs.org
13
+ * --severity low
14
+ */
15
+
16
+ import { existsSync } from "node:fs";
17
+ import { dirname, resolve } from "node:path";
18
+
19
+ import { audit, formatAuditReport, readAuditConfig } from "./bulkAudit.js";
20
+
21
+ function parseArgs(args) {
22
+ let lockfilePath = resolve("pnpm-lock.yaml");
23
+ let registry = "https://registry.npmjs.org";
24
+ let minSeverity = "low";
25
+ let json = false;
26
+ let noIgnore = false;
27
+ let prod = false;
28
+
29
+ for (let i = 0; i < args.length; i++) {
30
+ const arg = args[i];
31
+ if (arg === "--lockfile" && args[i + 1]) {
32
+ lockfilePath = resolve(args[++i]);
33
+ } else if (arg === "--registry" && args[i + 1]) {
34
+ registry = args[++i];
35
+ } else if (arg === "--severity" && args[i + 1]) {
36
+ minSeverity = args[++i];
37
+ const validSeverities = ["low", "moderate", "high", "critical"];
38
+ if (!validSeverities.includes(minSeverity)) {
39
+ console.error(
40
+ `Error: unknown severity "${minSeverity}". Must be one of: ${validSeverities.join(", ")}`,
41
+ );
42
+ process.exit(1);
43
+ }
44
+ } else if (arg === "--json") {
45
+ json = true;
46
+ } else if (arg === "--no-ignore") {
47
+ noIgnore = true;
48
+ } else if (arg === "--prod" || arg === "-P") {
49
+ prod = true;
50
+ } else if (arg === "--help" || arg === "-h") {
51
+ console.log(`Usage: pnpm-bulk-audit [options]
52
+
53
+ Options:
54
+ --lockfile <path> Path to pnpm-lock.yaml (default: ./pnpm-lock.yaml)
55
+ --registry <url> npm registry URL (default: https://registry.npmjs.org)
56
+ --severity <level> Minimum severity: low, moderate, high, critical (default: low)
57
+ --prod, -P Only report vulnerabilities in production dependencies
58
+ --json Output raw JSON
59
+ --no-ignore Skip reading ignoreCves/ignoreGhsas from config
60
+ --help, -h Show this help`);
61
+ process.exit(0);
62
+ }
63
+ }
64
+
65
+ return { lockfilePath, registry, minSeverity, json, noIgnore, prod };
66
+ }
67
+
68
+ async function main() {
69
+ const opts = parseArgs(process.argv.slice(2));
70
+
71
+ if (!existsSync(opts.lockfilePath)) {
72
+ console.error(`Error: lockfile not found at ${opts.lockfilePath}`);
73
+ process.exit(1);
74
+ }
75
+
76
+ try {
77
+ const workspaceDir = dirname(opts.lockfilePath);
78
+ const auditConfig = opts.noIgnore
79
+ ? undefined
80
+ : readAuditConfig(workspaceDir);
81
+
82
+ if (auditConfig && !opts.json) {
83
+ const ignoredCount =
84
+ (auditConfig.ignoreCves?.length ?? 0) +
85
+ (auditConfig.ignoreGhsas?.length ?? 0);
86
+ if (ignoredCount > 0) {
87
+ console.log(
88
+ `Ignoring ${ignoredCount} CVE/GHSA entr${ignoredCount === 1 ? "y" : "ies"} from config.\n`,
89
+ );
90
+ }
91
+ }
92
+
93
+ const result = await audit({
94
+ lockfilePath: opts.lockfilePath,
95
+ registry: opts.registry,
96
+ minSeverity: opts.minSeverity,
97
+ auditConfig,
98
+ prod: opts.prod,
99
+ });
100
+
101
+ if (opts.json) {
102
+ console.log(JSON.stringify(result, null, 2));
103
+ } else {
104
+ console.log(formatAuditReport(result));
105
+ }
106
+
107
+ process.exit(result.totalVulnerabilities > 0 ? 1 : 0);
108
+ } catch (error) {
109
+ console.error(
110
+ `Audit failed: ${error instanceof Error ? error.message : String(error)}`,
111
+ );
112
+ process.exit(1);
113
+ }
114
+ }
115
+
116
+ // eslint-disable-next-line no-void
117
+ void main();