@knime/utils 1.12.1 → 2.0.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.
@@ -1,671 +0,0 @@
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
- }