@knime/utils 1.12.1 → 2.0.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 +10 -0
- package/README.md +0 -9
- package/dist/index.js +947 -745
- package/dist/index.js.map +1 -1
- package/dist/test-utils/index.js +41 -38
- package/dist/test-utils/index.js.map +1 -1
- package/package.json +7 -15
- package/audit/README.md +0 -118
- package/audit/bulkAudit.js +0 -671
- package/audit/cli.js +0 -117
package/audit/bulkAudit.js
DELETED
|
@@ -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
|
-
}
|