verimu 0.0.14 → 0.0.16

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/dist/cli.js DELETED
@@ -1,3188 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/cli.ts
4
- import { resolve } from "path";
5
- import { createRequire } from "module";
6
-
7
- // src/scan.ts
8
- import { writeFile } from "fs/promises";
9
- import { basename, join, parse } from "path";
10
-
11
- // src/scanners/npm/npm-scanner.ts
12
- import { readFile } from "fs/promises";
13
- import { existsSync } from "fs";
14
- import path from "path";
15
-
16
- // src/core/errors.ts
17
- var VerimuError = class extends Error {
18
- constructor(message, code) {
19
- super(message);
20
- this.code = code;
21
- this.name = "VerimuError";
22
- }
23
- };
24
- var NoLockfileError = class extends VerimuError {
25
- constructor(projectPath) {
26
- super(
27
- `No supported lockfile found in ${projectPath}. Supported: package-lock.json (npm), packages.lock.json (NuGet), Cargo.lock (Rust), requirements.txt / Pipfile.lock (pip), poetry.lock (Poetry), uv.lock (uv), pom.xml (Maven), go.sum (Go), Gemfile.lock (Ruby), composer.lock (Composer), yarn.lock (Yarn), pnpm-lock.yaml (pnpm)`,
28
- "NO_LOCKFILE"
29
- );
30
- this.name = "NoLockfileError";
31
- }
32
- };
33
- var LockfileParseError = class extends VerimuError {
34
- constructor(lockfilePath, reason) {
35
- super(`Failed to parse ${lockfilePath}: ${reason}`, "LOCKFILE_PARSE_ERROR");
36
- this.name = "LockfileParseError";
37
- }
38
- };
39
-
40
- // src/scanners/npm/npm-scanner.ts
41
- var NpmScanner = class {
42
- ecosystem = "npm";
43
- lockfileNames = ["package-lock.json"];
44
- async detect(projectPath) {
45
- const lockfilePath = path.join(projectPath, "package-lock.json");
46
- return existsSync(lockfilePath) ? lockfilePath : null;
47
- }
48
- async scan(projectPath, lockfilePath) {
49
- const [lockfileRaw, packageJsonRaw] = await Promise.all([
50
- readFile(lockfilePath, "utf-8"),
51
- readFile(path.join(projectPath, "package.json"), "utf-8").catch(() => null)
52
- ]);
53
- let lockfile;
54
- try {
55
- lockfile = JSON.parse(lockfileRaw);
56
- } catch {
57
- throw new LockfileParseError(lockfilePath, "Invalid JSON");
58
- }
59
- const directNames = /* @__PURE__ */ new Set();
60
- if (packageJsonRaw) {
61
- try {
62
- const pkg2 = JSON.parse(packageJsonRaw);
63
- for (const name of Object.keys(pkg2.dependencies ?? {})) {
64
- directNames.add(name);
65
- }
66
- for (const name of Object.keys(pkg2.devDependencies ?? {})) {
67
- directNames.add(name);
68
- }
69
- } catch {
70
- }
71
- }
72
- const dependencies = this.parseLockfile(lockfile, directNames);
73
- return {
74
- projectPath,
75
- ecosystem: "npm",
76
- dependencies,
77
- lockfilePath,
78
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
79
- };
80
- }
81
- /**
82
- * Parses package-lock.json and extracts dependencies.
83
- * Supports lockfile v2 and v3 (uses the `packages` field).
84
- * Falls back to `dependencies` field for lockfile v1.
85
- */
86
- parseLockfile(lockfile, directNames) {
87
- const deps = [];
88
- if (lockfile.packages) {
89
- for (const [pkgPath, pkgInfo] of Object.entries(lockfile.packages)) {
90
- if (pkgPath === "") continue;
91
- const name = this.extractPackageName(pkgPath);
92
- if (!name || !pkgInfo.version) continue;
93
- if (pkgInfo.link) continue;
94
- deps.push({
95
- name,
96
- version: pkgInfo.version,
97
- direct: directNames.has(name),
98
- ecosystem: "npm",
99
- purl: this.buildPurl(name, pkgInfo.version)
100
- });
101
- }
102
- } else if (lockfile.dependencies) {
103
- this.parseDependenciesV1(lockfile.dependencies, directNames, deps);
104
- }
105
- return deps;
106
- }
107
- /**
108
- * Builds a purl (Package URL) for an npm package.
109
- *
110
- * Per the purl spec (https://github.com/package-url/purl-spec/blob/main/types-doc/npm-definition.md):
111
- * "The npm scope @ sign prefix is always percent encoded."
112
- *
113
- * So @types/node@20.11.5 → pkg:npm/%40types/node@20.11.5
114
- * And express@4.18.2 → pkg:npm/express@4.18.2
115
- */
116
- buildPurl(name, version) {
117
- if (name.startsWith("@")) {
118
- return `pkg:npm/%40${name.slice(1)}@${version}`;
119
- }
120
- return `pkg:npm/${name}@${version}`;
121
- }
122
- /** Extracts the package name from a node_modules path */
123
- extractPackageName(pkgPath) {
124
- const parts = pkgPath.split("node_modules/");
125
- const last = parts[parts.length - 1];
126
- return last || null;
127
- }
128
- /** Recursively parses lockfile v1 `dependencies` tree */
129
- parseDependenciesV1(depsObj, directNames, result) {
130
- for (const [name, info] of Object.entries(depsObj)) {
131
- if (info.version) {
132
- result.push({
133
- name,
134
- version: info.version,
135
- direct: directNames.has(name),
136
- ecosystem: "npm",
137
- purl: this.buildPurl(name, info.version)
138
- });
139
- }
140
- if (info.dependencies) {
141
- this.parseDependenciesV1(info.dependencies, directNames, result);
142
- }
143
- }
144
- }
145
- };
146
-
147
- // src/scanners/nuget/nuget-scanner.ts
148
- import { readFile as readFile2 } from "fs/promises";
149
- import { existsSync as existsSync2 } from "fs";
150
- import path2 from "path";
151
- var NugetScanner = class {
152
- ecosystem = "nuget";
153
- lockfileNames = ["packages.lock.json"];
154
- async detect(projectPath) {
155
- const lockfilePath = path2.join(projectPath, "packages.lock.json");
156
- return existsSync2(lockfilePath) ? lockfilePath : null;
157
- }
158
- async scan(projectPath, lockfilePath) {
159
- const lockfileRaw = await readFile2(lockfilePath, "utf-8");
160
- let lockfile;
161
- try {
162
- lockfile = JSON.parse(lockfileRaw);
163
- } catch {
164
- throw new LockfileParseError(lockfilePath, "Invalid JSON");
165
- }
166
- if (!lockfile.dependencies) {
167
- throw new LockfileParseError(lockfilePath, 'Missing "dependencies" field');
168
- }
169
- const dependencies = this.parseLockfile(lockfile);
170
- return {
171
- projectPath,
172
- ecosystem: "nuget",
173
- dependencies,
174
- lockfilePath,
175
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
176
- };
177
- }
178
- /**
179
- * Parses packages.lock.json and extracts dependencies across all
180
- * target frameworks. Deduplicates by package name (keeps highest version
181
- * if the same package appears under multiple frameworks).
182
- */
183
- parseLockfile(lockfile) {
184
- const depMap = /* @__PURE__ */ new Map();
185
- for (const [_framework, packages] of Object.entries(lockfile.dependencies)) {
186
- for (const [name, info] of Object.entries(packages)) {
187
- if (!info.resolved) continue;
188
- const isDirect = info.type === "Direct";
189
- const existing = depMap.get(name);
190
- if (!existing) {
191
- depMap.set(name, {
192
- name,
193
- version: info.resolved,
194
- direct: isDirect,
195
- ecosystem: "nuget",
196
- purl: this.buildPurl(name, info.resolved)
197
- });
198
- } else if (isDirect && !existing.direct) {
199
- existing.direct = true;
200
- }
201
- }
202
- }
203
- return Array.from(depMap.values());
204
- }
205
- /**
206
- * Builds a purl for a NuGet package.
207
- * NuGet purls are straightforward: pkg:nuget/Name@Version
208
- */
209
- buildPurl(name, version) {
210
- return `pkg:nuget/${name}@${version}`;
211
- }
212
- };
213
-
214
- // src/scanners/cargo/cargo-scanner.ts
215
- import { readFile as readFile3 } from "fs/promises";
216
- import { existsSync as existsSync3 } from "fs";
217
- import path3 from "path";
218
- var CargoScanner = class {
219
- ecosystem = "cargo";
220
- lockfileNames = ["Cargo.lock"];
221
- async detect(projectPath) {
222
- const lockfilePath = path3.join(projectPath, "Cargo.lock");
223
- return existsSync3(lockfilePath) ? lockfilePath : null;
224
- }
225
- async scan(projectPath, lockfilePath) {
226
- const [lockfileRaw, cargoTomlRaw] = await Promise.all([
227
- readFile3(lockfilePath, "utf-8"),
228
- readFile3(path3.join(projectPath, "Cargo.toml"), "utf-8").catch(() => null)
229
- ]);
230
- const packages = this.parseLockfile(lockfileRaw, lockfilePath);
231
- const directNames = cargoTomlRaw ? this.parseCargoToml(cargoTomlRaw) : /* @__PURE__ */ new Set();
232
- const rootName = packages.length > 0 ? packages[0].name : null;
233
- const dependencies = [];
234
- for (const pkg2 of packages) {
235
- if (pkg2.name === rootName && pkg2.source === void 0) continue;
236
- dependencies.push({
237
- name: pkg2.name,
238
- version: pkg2.version,
239
- direct: directNames.has(pkg2.name),
240
- ecosystem: "cargo",
241
- purl: this.buildPurl(pkg2.name, pkg2.version)
242
- });
243
- }
244
- return {
245
- projectPath,
246
- ecosystem: "cargo",
247
- dependencies,
248
- lockfilePath,
249
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
250
- };
251
- }
252
- /**
253
- * Parses Cargo.lock by splitting on [[package]] blocks.
254
- * This is a lightweight parser that handles the regular structure
255
- * of Cargo.lock without needing a full TOML parser.
256
- */
257
- parseLockfile(content, lockfilePath) {
258
- const packages = [];
259
- const blocks = content.split(/^\[\[package\]\]$/m);
260
- for (const block of blocks) {
261
- if (!block.trim()) continue;
262
- const name = this.extractField(block, "name");
263
- const version = this.extractField(block, "version");
264
- const source = this.extractField(block, "source");
265
- if (name && version) {
266
- packages.push({ name, version, source: source || void 0 });
267
- }
268
- }
269
- if (packages.length === 0 && content.includes("[[package]]")) {
270
- throw new LockfileParseError(lockfilePath, "Failed to parse any packages from Cargo.lock");
271
- }
272
- return packages;
273
- }
274
- /**
275
- * Extracts a string field value from a TOML block.
276
- * Handles: `name = "value"` format.
277
- */
278
- extractField(block, fieldName) {
279
- const regex = new RegExp(`^${fieldName}\\s*=\\s*"([^"]*)"`, "m");
280
- const match = block.match(regex);
281
- return match ? match[1] : null;
282
- }
283
- /**
284
- * Parses Cargo.toml to extract direct dependency names.
285
- * Looks for [dependencies] and [dev-dependencies] sections.
286
- */
287
- parseCargoToml(content) {
288
- const directNames = /* @__PURE__ */ new Set();
289
- let inDepsSection = false;
290
- for (const rawLine of content.split("\n")) {
291
- const line = rawLine.trim();
292
- if (line.startsWith("[")) {
293
- inDepsSection = line === "[dependencies]" || line === "[dev-dependencies]" || line === "[build-dependencies]";
294
- continue;
295
- }
296
- if (inDepsSection && line && !line.startsWith("#")) {
297
- const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/);
298
- if (match) {
299
- directNames.add(match[1]);
300
- }
301
- }
302
- }
303
- return directNames;
304
- }
305
- /**
306
- * Builds a purl for a Cargo (crates.io) package.
307
- */
308
- buildPurl(name, version) {
309
- return `pkg:cargo/${name}@${version}`;
310
- }
311
- };
312
-
313
- // src/scanners/pip/pip-scanner.ts
314
- import { readFile as readFile4 } from "fs/promises";
315
- import { existsSync as existsSync4 } from "fs";
316
- import path4 from "path";
317
- var PipScanner = class {
318
- ecosystem = "pip";
319
- lockfileNames = ["Pipfile.lock", "requirements.txt"];
320
- async detect(projectPath) {
321
- for (const lockfile of this.lockfileNames) {
322
- const fullPath = path4.join(projectPath, lockfile);
323
- if (existsSync4(fullPath)) return fullPath;
324
- }
325
- return null;
326
- }
327
- async scan(projectPath, lockfilePath) {
328
- const raw = await readFile4(lockfilePath, "utf-8");
329
- const filename = path4.basename(lockfilePath);
330
- let dependencies;
331
- if (filename === "Pipfile.lock") {
332
- dependencies = this.parsePipfileLock(raw, lockfilePath);
333
- } else {
334
- dependencies = await this.parseRequirementsTxt(raw, lockfilePath);
335
- }
336
- return {
337
- projectPath,
338
- ecosystem: "pip",
339
- dependencies,
340
- lockfilePath,
341
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
342
- };
343
- }
344
- /**
345
- * Parses `requirements.txt` format.
346
- *
347
- * Supports:
348
- * - `package==1.2.3` (pinned) — REQUIRED
349
- * - Comments (`#`) and blank lines are skipped
350
- * - `-r other-file.txt` (include directive) — recursively parsed
351
- * - `--index-url` and other pip flags — skipped
352
- *
353
- * Throws if any dependency is not strictly pinned with ==.
354
- * Use `pip freeze` to generate a properly pinned requirements.txt.
355
- */
356
- async parseRequirementsTxt(content, lockfilePath, visited = /* @__PURE__ */ new Set()) {
357
- const deps = [];
358
- const currentDir = path4.dirname(lockfilePath);
359
- const normalizedPath = path4.resolve(lockfilePath);
360
- if (visited.has(normalizedPath)) {
361
- return deps;
362
- }
363
- visited.add(normalizedPath);
364
- for (const rawLine of content.split("\n")) {
365
- const line = rawLine.trim();
366
- if (!line || line.startsWith("#")) {
367
- continue;
368
- }
369
- const includeMatch = line.match(/^-r\s+(.+)$/) || line.match(/^--requirement\s+(.+)$/);
370
- if (includeMatch) {
371
- const includePath = path4.resolve(currentDir, includeMatch[1].trim());
372
- if (existsSync4(includePath)) {
373
- const includeContent = await readFile4(includePath, "utf-8");
374
- const includedDeps = await this.parseRequirementsTxt(includeContent, includePath, visited);
375
- deps.push(...includedDeps);
376
- }
377
- continue;
378
- }
379
- if (line.startsWith("-") || line.startsWith("--")) {
380
- continue;
381
- }
382
- const pinnedMatch = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*==\s*([^,\s]+)$/);
383
- if (pinnedMatch) {
384
- const [, name, version] = pinnedMatch;
385
- if (name && version) {
386
- deps.push({
387
- name: this.normalizePipName(name),
388
- version,
389
- direct: true,
390
- // requirements.txt doesn't distinguish
391
- ecosystem: "pip",
392
- purl: this.buildPurl(name, version)
393
- });
394
- }
395
- continue;
396
- }
397
- const depMatch = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*([~=!<>].*)$/);
398
- if (depMatch) {
399
- throw new LockfileParseError(
400
- lockfilePath,
401
- `Non-pinned dependency detected: "${line}". Use pip freeze or Pipfile.lock.`
402
- );
403
- }
404
- }
405
- return deps;
406
- }
407
- /**
408
- * Parses `Pipfile.lock` (JSON format from Pipenv).
409
- *
410
- * Structure:
411
- * ```json
412
- * {
413
- * "_meta": { ... },
414
- * "default": {
415
- * "requests": { "version": "==2.31.0", ... }
416
- * },
417
- * "develop": {
418
- * "pytest": { "version": "==7.4.0", ... }
419
- * }
420
- * }
421
- * ```
422
- */
423
- parsePipfileLock(content, lockfilePath) {
424
- let lockfile;
425
- try {
426
- lockfile = JSON.parse(content);
427
- } catch {
428
- throw new LockfileParseError(lockfilePath, "Invalid JSON in Pipfile.lock");
429
- }
430
- const deps = [];
431
- if (lockfile.default) {
432
- for (const [name, info] of Object.entries(lockfile.default)) {
433
- const version = info.version?.replace(/^==/, "") ?? "";
434
- if (version) {
435
- deps.push({
436
- name: this.normalizePipName(name),
437
- version,
438
- direct: true,
439
- ecosystem: "pip",
440
- purl: this.buildPurl(name, version)
441
- });
442
- }
443
- }
444
- }
445
- if (lockfile.develop) {
446
- for (const [name, info] of Object.entries(lockfile.develop)) {
447
- const version = info.version?.replace(/^==/, "") ?? "";
448
- if (version) {
449
- deps.push({
450
- name: this.normalizePipName(name),
451
- version,
452
- direct: true,
453
- ecosystem: "pip",
454
- purl: this.buildPurl(name, version)
455
- });
456
- }
457
- }
458
- }
459
- return deps;
460
- }
461
- /**
462
- * Normalizes a pip package name per PEP 503.
463
- * Converts to lowercase and replaces any run of [-_.] with a single hyphen.
464
- */
465
- normalizePipName(name) {
466
- return name.toLowerCase().replace(/[-_.]+/g, "-");
467
- }
468
- /**
469
- * Builds a purl for a PyPI package.
470
- * Per purl spec, the type is "pypi" (not "pip").
471
- */
472
- buildPurl(name, version) {
473
- return `pkg:pypi/${this.normalizePipName(name)}@${version}`;
474
- }
475
- };
476
-
477
- // src/scanners/maven/maven-scanner.ts
478
- import { readFile as readFile5 } from "fs/promises";
479
- import { existsSync as existsSync5 } from "fs";
480
- import { execSync } from "child_process";
481
- import path5 from "path";
482
- var MavenScanner = class {
483
- ecosystem = "maven";
484
- lockfileNames = ["pom.xml"];
485
- /** Allow injection for testing */
486
- execSyncFn;
487
- constructor(execSyncImpl) {
488
- this.execSyncFn = execSyncImpl ?? execSync;
489
- }
490
- async detect(projectPath) {
491
- const pomPath = path5.join(projectPath, "pom.xml");
492
- return existsSync5(pomPath) ? pomPath : null;
493
- }
494
- async scan(projectPath, _lockfilePath) {
495
- const pomPath = path5.join(projectPath, "pom.xml");
496
- const pomContent = await readFile5(pomPath, "utf-8").catch(() => null);
497
- const directDeps = pomContent ? this.parsePomDependencies(pomContent) : /* @__PURE__ */ new Set();
498
- const depTreePath = path5.join(projectPath, "dependency-tree.txt");
499
- if (existsSync5(depTreePath)) {
500
- const content = await readFile5(depTreePath, "utf-8");
501
- const dependencies = this.parseDependencyList(content, directDeps);
502
- return this.buildResult(projectPath, depTreePath, dependencies);
503
- }
504
- if (this.isMavenAvailable()) {
505
- const output = this.runMavenDependencyList(projectPath);
506
- const dependencies = this.parseDependencyList(output, directDeps);
507
- return this.buildResult(projectPath, pomPath, dependencies);
508
- }
509
- throw new LockfileParseError(
510
- pomPath,
511
- "Maven project detected (pom.xml found) but could not resolve dependencies. Either install Maven (`mvn` must be on $PATH) or pre-generate a dependency list:\n mvn dependency:list -DoutputFile=dependency-tree.txt -DappendOutput=true"
512
- );
513
- }
514
- /**
515
- * Parses pom.xml to extract direct dependency coordinates (groupId:artifactId).
516
- * This is a simple regex-based parser that handles standard dependency declarations.
517
- */
518
- parsePomDependencies(pomContent) {
519
- const directDeps = /* @__PURE__ */ new Set();
520
- const depBlockRegex = /<dependency>\s*([\s\S]*?)<\/dependency>/g;
521
- const groupIdRegex = /<groupId>\s*([^<]+)\s*<\/groupId>/;
522
- const artifactIdRegex = /<artifactId>\s*([^<]+)\s*<\/artifactId>/;
523
- let match;
524
- while ((match = depBlockRegex.exec(pomContent)) !== null) {
525
- const block = match[1];
526
- const groupMatch = block.match(groupIdRegex);
527
- const artifactMatch = block.match(artifactIdRegex);
528
- if (groupMatch && artifactMatch) {
529
- const groupId = groupMatch[1].trim();
530
- const artifactId = artifactMatch[1].trim();
531
- directDeps.add(`${groupId}:${artifactId}`);
532
- }
533
- }
534
- return directDeps;
535
- }
536
- /**
537
- * Parses Maven `dependency:list` output.
538
- *
539
- * Each dependency line has the format:
540
- * groupId:artifactId:type:version:scope (5 parts)
541
- * groupId:artifactId:type:classifier:version:scope (6 parts)
542
- *
543
- * Lines are typically indented with leading whitespace.
544
- */
545
- parseDependencyList(content, directDeps) {
546
- const deps = [];
547
- const seen = /* @__PURE__ */ new Set();
548
- for (const rawLine of content.split("\n")) {
549
- const line = rawLine.trim();
550
- if (!line) continue;
551
- const parts = line.split(":");
552
- if (parts.length < 5) continue;
553
- const groupId = parts[0];
554
- const artifactId = parts[1];
555
- let version;
556
- let scope;
557
- if (parts.length >= 6) {
558
- version = parts[4];
559
- scope = parts[5];
560
- } else {
561
- version = parts[3];
562
- scope = parts[4];
563
- }
564
- if (!groupId || !artifactId || !version) continue;
565
- if (scope === "test") continue;
566
- const name = `${groupId}:${artifactId}`;
567
- if (seen.has(name)) continue;
568
- seen.add(name);
569
- const isDirect = directDeps.has(name);
570
- deps.push({
571
- name,
572
- version,
573
- direct: isDirect,
574
- ecosystem: "maven",
575
- purl: this.buildPurl(groupId, artifactId, version)
576
- });
577
- }
578
- return deps;
579
- }
580
- /** Checks if `mvn` is available on PATH */
581
- isMavenAvailable() {
582
- try {
583
- this.execSyncFn("mvn --version", { stdio: "pipe", timeout: 1e4 });
584
- return true;
585
- } catch {
586
- return false;
587
- }
588
- }
589
- /**
590
- * Runs `mvn dependency:list` and returns the output.
591
- */
592
- runMavenDependencyList(projectPath) {
593
- try {
594
- const output = this.execSyncFn(
595
- "mvn dependency:list -DoutputType=text -DincludeScope=compile",
596
- {
597
- cwd: projectPath,
598
- stdio: "pipe",
599
- timeout: 12e4,
600
- // 2 minute timeout
601
- encoding: "utf-8"
602
- }
603
- );
604
- return output.toString();
605
- } catch (err) {
606
- const message = err instanceof Error ? err.message : String(err);
607
- throw new LockfileParseError(
608
- path5.join(projectPath, "pom.xml"),
609
- `Failed to run 'mvn dependency:list': ${message}`
610
- );
611
- }
612
- }
613
- /**
614
- * Builds a purl for a Maven package.
615
- * Format: pkg:maven/groupId/artifactId@version
616
- */
617
- buildPurl(groupId, artifactId, version) {
618
- return `pkg:maven/${groupId}/${artifactId}@${version}`;
619
- }
620
- buildResult(projectPath, lockfilePath, dependencies) {
621
- return {
622
- projectPath,
623
- ecosystem: "maven",
624
- dependencies,
625
- lockfilePath,
626
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
627
- };
628
- }
629
- };
630
-
631
- // src/scanners/go/go-scanner.ts
632
- import { readFile as readFile6 } from "fs/promises";
633
- import { existsSync as existsSync6 } from "fs";
634
- import path6 from "path";
635
- var GoScanner = class {
636
- ecosystem = "go";
637
- lockfileNames = ["go.sum"];
638
- async detect(projectPath) {
639
- const goSumPath = path6.join(projectPath, "go.sum");
640
- return existsSync6(goSumPath) ? goSumPath : null;
641
- }
642
- async scan(projectPath, lockfilePath) {
643
- const [goSumRaw, goModRaw] = await Promise.all([
644
- readFile6(lockfilePath, "utf-8"),
645
- readFile6(path6.join(projectPath, "go.mod"), "utf-8").catch(() => null)
646
- ]);
647
- const { directNames, indirectNames } = goModRaw ? this.parseGoMod(goModRaw) : { directNames: /* @__PURE__ */ new Set(), indirectNames: /* @__PURE__ */ new Set() };
648
- const dependencies = this.parseGoSum(goSumRaw, lockfilePath, directNames, indirectNames);
649
- return {
650
- projectPath,
651
- ecosystem: "go",
652
- dependencies,
653
- lockfilePath,
654
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
655
- };
656
- }
657
- /**
658
- * Parses go.sum and extracts unique module dependencies.
659
- *
660
- * Each module may appear twice in go.sum (once for the source archive,
661
- * once for go.mod). We deduplicate by module path + version, keeping
662
- * only the `h1:` entry (not the `/go.mod` entry).
663
- */
664
- parseGoSum(content, lockfilePath, directNames, indirectNames) {
665
- const depMap = /* @__PURE__ */ new Map();
666
- for (const rawLine of content.split("\n")) {
667
- const line = rawLine.trim();
668
- if (!line) continue;
669
- const parts = line.split(/\s+/);
670
- if (parts.length < 3) continue;
671
- const modulePath = parts[0];
672
- let version = parts[1];
673
- if (version.endsWith("/go.mod")) continue;
674
- version = version.replace(/\+incompatible$/, "");
675
- const key = `${modulePath}@${version}`;
676
- if (depMap.has(key)) continue;
677
- const isDirect = directNames.size > 0 || indirectNames.size > 0 ? directNames.has(modulePath) || (!indirectNames.has(modulePath) && !directNames.has(modulePath) ? false : directNames.has(modulePath)) : true;
678
- depMap.set(key, {
679
- name: modulePath,
680
- version,
681
- direct: isDirect,
682
- ecosystem: "go",
683
- purl: this.buildPurl(modulePath, version)
684
- });
685
- }
686
- return Array.from(depMap.values());
687
- }
688
- /**
689
- * Parses go.mod to extract direct and indirect dependency names.
690
- *
691
- * Handles both single-line and block `require` directives:
692
- * ```
693
- * require github.com/pkg/errors v0.9.1
694
- *
695
- * require (
696
- * github.com/gin-gonic/gin v1.9.1
697
- * golang.org/x/text v0.14.0 // indirect
698
- * )
699
- * ```
700
- */
701
- parseGoMod(content) {
702
- const directNames = /* @__PURE__ */ new Set();
703
- const indirectNames = /* @__PURE__ */ new Set();
704
- let inRequireBlock = false;
705
- for (const rawLine of content.split("\n")) {
706
- const line = rawLine.trim();
707
- if (line.startsWith("require ") && !line.includes("(")) {
708
- const match = line.match(/^require\s+(\S+)\s+\S+(.*)$/);
709
- if (match) {
710
- const modulePath = match[1];
711
- const rest = match[2];
712
- if (rest.includes("// indirect")) {
713
- indirectNames.add(modulePath);
714
- } else {
715
- directNames.add(modulePath);
716
- }
717
- }
718
- continue;
719
- }
720
- if (line === "require (" || line.startsWith("require (")) {
721
- inRequireBlock = true;
722
- continue;
723
- }
724
- if (inRequireBlock && line === ")") {
725
- inRequireBlock = false;
726
- continue;
727
- }
728
- if (inRequireBlock && line && !line.startsWith("//")) {
729
- const match = line.match(/^(\S+)\s+\S+(.*)$/);
730
- if (match) {
731
- const modulePath = match[1];
732
- const rest = match[2];
733
- if (rest.includes("// indirect")) {
734
- indirectNames.add(modulePath);
735
- } else {
736
- directNames.add(modulePath);
737
- }
738
- }
739
- }
740
- }
741
- return { directNames, indirectNames };
742
- }
743
- /**
744
- * Builds a purl for a Go module.
745
- *
746
- * Per purl spec, the type is "golang" and the module path
747
- * uses `/` separators (no encoding needed for path segments).
748
- *
749
- * Example: `pkg:golang/github.com/gin-gonic/gin@v1.9.1`
750
- */
751
- buildPurl(modulePath, version) {
752
- return `pkg:golang/${modulePath}@${version}`;
753
- }
754
- };
755
-
756
- // src/scanners/ruby/ruby-scanner.ts
757
- import { readFile as readFile7 } from "fs/promises";
758
- import { existsSync as existsSync7 } from "fs";
759
- import path7 from "path";
760
- var RubyScanner = class {
761
- ecosystem = "ruby";
762
- lockfileNames = ["Gemfile.lock"];
763
- async detect(projectPath) {
764
- const lockfilePath = path7.join(projectPath, "Gemfile.lock");
765
- return existsSync7(lockfilePath) ? lockfilePath : null;
766
- }
767
- async scan(projectPath, lockfilePath) {
768
- const content = await readFile7(lockfilePath, "utf-8");
769
- const specs = this.parseSpecs(content, lockfilePath);
770
- const directNames = this.parseDependencies(content);
771
- const dependencies = specs.map(({ name, version }) => ({
772
- name,
773
- version,
774
- direct: directNames.has(name),
775
- ecosystem: "ruby",
776
- purl: `pkg:gem/${name}@${version}`
777
- }));
778
- return {
779
- projectPath,
780
- ecosystem: "ruby",
781
- dependencies,
782
- lockfilePath,
783
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
784
- };
785
- }
786
- /**
787
- * Parses the GEM > specs section to extract all resolved gems.
788
- *
789
- * Gems at the top level of the specs section (indented 4 spaces) are
790
- * resolved packages. Their sub-dependencies (indented 6+ spaces) are
791
- * constraints, not separate entries — those sub-deps appear as their
792
- * own top-level spec entries elsewhere.
793
- *
794
- * Format: ` gem-name (1.2.3)`
795
- */
796
- parseSpecs(content, lockfilePath) {
797
- const gems = [];
798
- let inGemSection = false;
799
- let inSpecs = false;
800
- for (const rawLine of content.split("\n")) {
801
- const line = rawLine;
802
- if (line.length > 0 && line[0] !== " ") {
803
- if (line.startsWith("GEM")) {
804
- inGemSection = true;
805
- inSpecs = false;
806
- continue;
807
- }
808
- inGemSection = false;
809
- inSpecs = false;
810
- continue;
811
- }
812
- if (inGemSection && line.trimStart().startsWith("specs:")) {
813
- inSpecs = true;
814
- continue;
815
- }
816
- if (!inSpecs) continue;
817
- const match = line.match(/^ {4}(\S+)\s+\(([^)]+)\)$/);
818
- if (match) {
819
- const [, name, version] = match;
820
- gems.push({ name, version });
821
- }
822
- }
823
- if (gems.length === 0) {
824
- throw new LockfileParseError(
825
- lockfilePath,
826
- "No gems found in GEM specs section"
827
- );
828
- }
829
- return gems;
830
- }
831
- /**
832
- * Parses the DEPENDENCIES section to get direct dependency names.
833
- *
834
- * Format: ` gem-name (>= 1.0)` or ` gem-name`
835
- * The version constraint is optional and we only need the name.
836
- */
837
- parseDependencies(content) {
838
- const directNames = /* @__PURE__ */ new Set();
839
- let inDependencies = false;
840
- for (const rawLine of content.split("\n")) {
841
- const line = rawLine;
842
- if (line.length > 0 && line[0] !== " ") {
843
- if (line.startsWith("DEPENDENCIES")) {
844
- inDependencies = true;
845
- continue;
846
- }
847
- if (inDependencies) break;
848
- continue;
849
- }
850
- if (!inDependencies) continue;
851
- const match = line.match(/^ {2}(\S+?)!?\s*(?:\(|$)/);
852
- if (match) {
853
- directNames.add(match[1]);
854
- }
855
- }
856
- return directNames;
857
- }
858
- };
859
-
860
- // src/scanners/composer/composer-scanner.ts
861
- import { readFile as readFile8 } from "fs/promises";
862
- import { existsSync as existsSync8 } from "fs";
863
- import path8 from "path";
864
- var ComposerScanner = class {
865
- ecosystem = "composer";
866
- lockfileNames = ["composer.lock"];
867
- async detect(projectPath) {
868
- const lockfilePath = path8.join(projectPath, "composer.lock");
869
- return existsSync8(lockfilePath) ? lockfilePath : null;
870
- }
871
- async scan(projectPath, lockfilePath) {
872
- const [lockRaw, manifestRaw] = await Promise.all([
873
- readFile8(lockfilePath, "utf-8"),
874
- readFile8(path8.join(projectPath, "composer.json"), "utf-8").catch(() => null)
875
- ]);
876
- const directNames = manifestRaw ? this.parseComposerManifest(manifestRaw) : null;
877
- const dependencies = this.parseComposerLock(lockRaw, lockfilePath, directNames);
878
- return {
879
- projectPath,
880
- ecosystem: "composer",
881
- dependencies,
882
- lockfilePath,
883
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
884
- };
885
- }
886
- parseComposerLock(content, lockfilePath, directNames) {
887
- let lock;
888
- try {
889
- lock = JSON.parse(content);
890
- } catch {
891
- throw new LockfileParseError(lockfilePath, "Invalid JSON in composer.lock");
892
- }
893
- const allPackages = [...lock.packages ?? [], ...lock["packages-dev"] ?? []];
894
- if (allPackages.length === 0) {
895
- throw new LockfileParseError(lockfilePath, "No packages found in composer.lock");
896
- }
897
- return allPackages.filter((pkg2) => pkg2.name && pkg2.version).map((pkg2) => ({
898
- name: pkg2.name,
899
- version: this.normalizeVersion(pkg2.version),
900
- direct: directNames ? directNames.has(pkg2.name) : true,
901
- ecosystem: "composer",
902
- purl: this.buildPurl(pkg2.name, this.normalizeVersion(pkg2.version))
903
- }));
904
- }
905
- parseComposerManifest(content) {
906
- let manifest;
907
- try {
908
- manifest = JSON.parse(content);
909
- } catch {
910
- return /* @__PURE__ */ new Set();
911
- }
912
- const names = /* @__PURE__ */ new Set();
913
- for (const section of [manifest.require ?? {}, manifest["require-dev"] ?? {}]) {
914
- for (const name of Object.keys(section)) {
915
- if (name === "php" || name.startsWith("ext-") || name.startsWith("lib-")) continue;
916
- names.add(name);
917
- }
918
- }
919
- return names;
920
- }
921
- normalizeVersion(version) {
922
- return version.trim();
923
- }
924
- buildPurl(name, version) {
925
- return `pkg:composer/${name}@${version}`;
926
- }
927
- };
928
-
929
- // src/scanners/yarn/yarn-scanner.ts
930
- import { readFile as readFile9 } from "fs/promises";
931
- import { existsSync as existsSync9 } from "fs";
932
- import path9 from "path";
933
- import { parse as parseYaml } from "yaml";
934
- var YarnScanner = class {
935
- ecosystem = "npm";
936
- lockfileNames = ["yarn.lock"];
937
- async detect(projectPath) {
938
- const lockfilePath = path9.join(projectPath, "yarn.lock");
939
- return existsSync9(lockfilePath) ? lockfilePath : null;
940
- }
941
- async scan(projectPath, lockfilePath) {
942
- const [lockfileRaw, packageJsonRaw] = await Promise.all([
943
- readFile9(lockfilePath, "utf-8"),
944
- readFile9(path9.join(projectPath, "package.json"), "utf-8").catch(() => null)
945
- ]);
946
- const directNames = /* @__PURE__ */ new Set();
947
- if (packageJsonRaw) {
948
- try {
949
- const pkg2 = JSON.parse(packageJsonRaw);
950
- for (const name of Object.keys(pkg2.dependencies ?? {})) {
951
- directNames.add(name);
952
- }
953
- for (const name of Object.keys(pkg2.devDependencies ?? {})) {
954
- directNames.add(name);
955
- }
956
- } catch {
957
- }
958
- }
959
- const dependencies = this.parseLockfile(lockfileRaw, lockfilePath, directNames);
960
- return {
961
- projectPath,
962
- ecosystem: "npm",
963
- dependencies,
964
- lockfilePath,
965
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
966
- };
967
- }
968
- /**
969
- * Parses yarn.lock file and extracts dependencies.
970
- * Automatically detects and handles both v1 (Classic) and v2+ (Berry) formats.
971
- */
972
- parseLockfile(content, lockfilePath, directNames) {
973
- try {
974
- const isV2Plus = this.isYarnV2Plus(content);
975
- if (isV2Plus) {
976
- return this.parseLockfileV2Plus(content, lockfilePath, directNames);
977
- } else {
978
- return this.parseLockfileV1(content, lockfilePath, directNames);
979
- }
980
- } catch (err) {
981
- throw new LockfileParseError(
982
- lockfilePath,
983
- `Failed to parse yarn.lock: ${err instanceof Error ? err.message : "Unknown error"}`
984
- );
985
- }
986
- }
987
- /**
988
- * Detects if the lockfile is Yarn v2+ (Berry) format.
989
- * v2+ uses YAML format and contains __metadata section.
990
- */
991
- isYarnV2Plus(content) {
992
- return content.startsWith("__metadata:") || content.includes("\n__metadata:");
993
- }
994
- /**
995
- * Parses Yarn v2+ (Berry) lockfile format.
996
- *
997
- * Yarn v2+ format (YAML):
998
- * ```yaml
999
- * __metadata:
1000
- * version: 6
1001
- *
1002
- * "package-name@npm:^1.0.0":
1003
- * version: 1.2.3
1004
- * resolution: "package-name@npm:1.2.3"
1005
- * dependencies:
1006
- * dep1: ^2.0.0
1007
- * checksum: ...
1008
- * languageName: node
1009
- * linkType: hard
1010
- * ```
1011
- */
1012
- parseLockfileV2Plus(content, lockfilePath, directNames) {
1013
- const deps = [];
1014
- const seen = /* @__PURE__ */ new Map();
1015
- try {
1016
- const parsed = parseYaml(content);
1017
- if (!parsed || typeof parsed !== "object") {
1018
- throw new Error("Invalid YAML format");
1019
- }
1020
- for (const [key, value] of Object.entries(parsed)) {
1021
- if (key === "__metadata" || key.includes("@workspace:")) {
1022
- continue;
1023
- }
1024
- if (typeof value !== "object" || value === null) {
1025
- continue;
1026
- }
1027
- const entry = value;
1028
- let name = null;
1029
- if (entry.resolution && typeof entry.resolution === "string") {
1030
- name = this.extractPackageNameFromResolution(entry.resolution);
1031
- }
1032
- if (!name) {
1033
- name = this.extractPackageNameV2Plus(key);
1034
- }
1035
- const version = entry.version;
1036
- if (!name || !version || typeof version !== "string") {
1037
- continue;
1038
- }
1039
- const depKey = `${name}@${version}`;
1040
- if (seen.has(depKey)) {
1041
- continue;
1042
- }
1043
- seen.set(depKey, true);
1044
- deps.push({
1045
- name,
1046
- version,
1047
- direct: directNames.has(name),
1048
- ecosystem: "npm",
1049
- purl: this.buildPurl(name, version)
1050
- });
1051
- }
1052
- } catch (err) {
1053
- throw new Error(`Failed to parse Yarn v2+ lockfile: ${err instanceof Error ? err.message : "Unknown error"}`);
1054
- }
1055
- return deps;
1056
- }
1057
- /**
1058
- * Extracts package name from Yarn v2+ resolution field.
1059
- * The resolution field contains the real package name.
1060
- * Examples:
1061
- * "express@npm:4.18.2" → "express"
1062
- * "@types/node@npm:20.11.5" → "@types/node"
1063
- * "lodash@npm:4.17.21" → "lodash"
1064
- */
1065
- extractPackageNameFromResolution(resolution) {
1066
- if (resolution.startsWith("@")) {
1067
- const match2 = resolution.match(/^(@[^@]+\/[^@]+)@/);
1068
- if (match2) {
1069
- return match2[1];
1070
- }
1071
- }
1072
- const match = resolution.match(/^([^@]+)@/);
1073
- if (match) {
1074
- return match[1];
1075
- }
1076
- return null;
1077
- }
1078
- /**
1079
- * Extracts package name from Yarn v2+ package key.
1080
- * Examples:
1081
- * "express@npm:^4.18.0" → "express"
1082
- * "@types/node@npm:^20.0.0" → "@types/node"
1083
- * "pkg@npm:other@npm:^1.0.0" → "pkg" (aliased packages)
1084
- */
1085
- extractPackageNameV2Plus(key) {
1086
- if (key.startsWith("@")) {
1087
- const match2 = key.match(/^(@[^@]+\/[^@]+)@/);
1088
- if (match2) {
1089
- return match2[1];
1090
- }
1091
- }
1092
- const match = key.match(/^([^@]+)@/);
1093
- if (match) {
1094
- return match[1];
1095
- }
1096
- return null;
1097
- }
1098
- /**
1099
- * Parses Yarn v1 (Classic) lockfile format.
1100
- *
1101
- * Yarn v1 format:
1102
- * ```
1103
- * "package-name@^1.0.0":
1104
- * version "1.2.3"
1105
- * resolved "https://..."
1106
- * integrity sha512-...
1107
- * dependencies:
1108
- * dep1 "^2.0.0"
1109
- * ```
1110
- */
1111
- parseLockfileV1(content, lockfilePath, directNames) {
1112
- const deps = [];
1113
- const seen = /* @__PURE__ */ new Map();
1114
- const lines = content.split("\n");
1115
- let currentPackage = null;
1116
- for (let i = 0; i < lines.length; i++) {
1117
- const line = lines[i];
1118
- if (line.trim().startsWith("#") || line.trim() === "") {
1119
- continue;
1120
- }
1121
- if (line.match(/^["\w@]/) && line.includes(":") && !line.startsWith(" ")) {
1122
- if (currentPackage?.version) {
1123
- this.addDependency(currentPackage, directNames, seen, deps);
1124
- }
1125
- const pkgLine = line.substring(0, line.lastIndexOf(":"));
1126
- const names = pkgLine.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).map((s) => this.extractPackageName(s)).filter((s) => !!s);
1127
- currentPackage = { names, version: void 0 };
1128
- } else if (line.trim().startsWith("version ") && currentPackage) {
1129
- const match = line.match(/version\s+"([^"]+)"/);
1130
- if (match) {
1131
- currentPackage.version = match[1];
1132
- }
1133
- }
1134
- }
1135
- if (currentPackage?.version) {
1136
- this.addDependency(currentPackage, directNames, seen, deps);
1137
- }
1138
- return deps;
1139
- }
1140
- /**
1141
- * Adds a dependency to the result list (deduplicates by name@version)
1142
- */
1143
- addDependency(pkg2, directNames, seen, deps) {
1144
- if (!pkg2.version) return;
1145
- const name = pkg2.names[0];
1146
- if (!name) return;
1147
- const key = `${name}@${pkg2.version}`;
1148
- if (seen.has(key)) return;
1149
- seen.set(key, true);
1150
- deps.push({
1151
- name,
1152
- version: pkg2.version,
1153
- direct: directNames.has(name),
1154
- ecosystem: "npm",
1155
- purl: this.buildPurl(name, pkg2.version)
1156
- });
1157
- }
1158
- /**
1159
- * Extracts package name from yarn.lock package declaration.
1160
- * Examples:
1161
- * "express@^4.18.0" → "express"
1162
- * "@types/node@^20.0.0" → "@types/node"
1163
- * "pkg@npm:other@^1.0.0" → "pkg" (aliased packages)
1164
- */
1165
- extractPackageName(pkgDeclaration) {
1166
- if (pkgDeclaration.includes("@npm:")) {
1167
- const beforeAlias = pkgDeclaration.split("@npm:")[0];
1168
- return beforeAlias || null;
1169
- }
1170
- if (pkgDeclaration.startsWith("@")) {
1171
- const parts = pkgDeclaration.split("@");
1172
- if (parts.length >= 3) {
1173
- return `@${parts[1]}`;
1174
- }
1175
- } else {
1176
- const atIndex = pkgDeclaration.indexOf("@");
1177
- if (atIndex > 0) {
1178
- return pkgDeclaration.substring(0, atIndex);
1179
- }
1180
- }
1181
- return null;
1182
- }
1183
- /**
1184
- * Builds a purl (Package URL) for an npm package.
1185
- *
1186
- * Per the purl spec:
1187
- * "The npm scope @ sign prefix is always percent encoded."
1188
- *
1189
- * So @types/node@20.11.5 → pkg:npm/%40types/node@20.11.5
1190
- * And express@4.18.2 → pkg:npm/express@4.18.2
1191
- */
1192
- buildPurl(name, version) {
1193
- if (name.startsWith("@")) {
1194
- return `pkg:npm/%40${name.slice(1)}@${version}`;
1195
- }
1196
- return `pkg:npm/${name}@${version}`;
1197
- }
1198
- };
1199
-
1200
- // src/scanners/pnpm/pnpm-scanner.ts
1201
- import { readFile as readFile10 } from "fs/promises";
1202
- import { existsSync as existsSync10 } from "fs";
1203
- import path10 from "path";
1204
- import { parse as parseYaml2 } from "yaml";
1205
- var PnpmScanner = class {
1206
- ecosystem = "npm";
1207
- lockfileNames = ["pnpm-lock.yaml"];
1208
- async detect(projectPath) {
1209
- const lockfilePath = path10.join(projectPath, "pnpm-lock.yaml");
1210
- return existsSync10(lockfilePath) ? lockfilePath : null;
1211
- }
1212
- async scan(projectPath, lockfilePath) {
1213
- const lockfileRaw = await readFile10(lockfilePath, "utf-8");
1214
- const dependencies = this.parseLockfile(lockfileRaw, lockfilePath);
1215
- return {
1216
- projectPath,
1217
- ecosystem: "npm",
1218
- dependencies,
1219
- lockfilePath,
1220
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
1221
- };
1222
- }
1223
- /**
1224
- * Parses pnpm-lock.yaml file and extracts dependencies.
1225
- *
1226
- * pnpm-lock.yaml format (v5.4+):
1227
- * ```yaml
1228
- * lockfileVersion: 5.4
1229
- *
1230
- * dependencies:
1231
- * express: 4.18.2
1232
- *
1233
- * devDependencies:
1234
- * typescript: 5.0.0
1235
- *
1236
- * packages:
1237
- * /express/4.18.2:
1238
- * resolution: {integrity: sha512-...}
1239
- * dependencies:
1240
- * accepts: 1.3.8
1241
- * /@types/node/20.11.5:
1242
- * resolution: {integrity: sha512-...}
1243
- * dev: true
1244
- * ```
1245
- *
1246
- * pnpm-lock.yaml format (v6.0+):
1247
- * ```yaml
1248
- * lockfileVersion: '6.0'
1249
- *
1250
- * dependencies:
1251
- * express:
1252
- * specifier: ^4.18.0
1253
- * version: 4.18.2
1254
- *
1255
- * packages:
1256
- * /express@4.18.2:
1257
- * resolution: {integrity: sha512-...}
1258
- * ```
1259
- */
1260
- parseLockfile(content, lockfilePath) {
1261
- try {
1262
- const parsed = parseYaml2(content);
1263
- if (!parsed || typeof parsed !== "object") {
1264
- throw new Error("Invalid YAML format");
1265
- }
1266
- const lockfile = parsed;
1267
- const lockfileVersion = this.parseLockfileVersion(lockfile.lockfileVersion);
1268
- const directNames = this.extractDirectDependencies(lockfile);
1269
- return this.extractDependencies(lockfile, lockfileVersion, directNames);
1270
- } catch (err) {
1271
- throw new LockfileParseError(
1272
- lockfilePath,
1273
- `Failed to parse pnpm-lock.yaml: ${err instanceof Error ? err.message : "Unknown error"}`
1274
- );
1275
- }
1276
- }
1277
- /**
1278
- * Extracts direct dependency names from pnpm lockfile.
1279
- *
1280
- * Supports both formats:
1281
- * - pnpm v5.x: root-level dependencies/devDependencies
1282
- * - pnpm v6+: importers['.'].dependencies/devDependencies
1283
- */
1284
- extractDirectDependencies(lockfile) {
1285
- const directNames = /* @__PURE__ */ new Set();
1286
- if (lockfile.importers && typeof lockfile.importers === "object") {
1287
- const rootImporter = lockfile.importers["."];
1288
- if (rootImporter && typeof rootImporter === "object") {
1289
- if (rootImporter.dependencies && typeof rootImporter.dependencies === "object") {
1290
- for (const name of Object.keys(rootImporter.dependencies)) {
1291
- directNames.add(name);
1292
- }
1293
- }
1294
- if (rootImporter.devDependencies && typeof rootImporter.devDependencies === "object") {
1295
- for (const name of Object.keys(rootImporter.devDependencies)) {
1296
- directNames.add(name);
1297
- }
1298
- }
1299
- }
1300
- }
1301
- if (directNames.size === 0) {
1302
- if (lockfile.dependencies && typeof lockfile.dependencies === "object") {
1303
- for (const name of Object.keys(lockfile.dependencies)) {
1304
- directNames.add(name);
1305
- }
1306
- }
1307
- if (lockfile.devDependencies && typeof lockfile.devDependencies === "object") {
1308
- for (const name of Object.keys(lockfile.devDependencies)) {
1309
- directNames.add(name);
1310
- }
1311
- }
1312
- }
1313
- return directNames;
1314
- }
1315
- /**
1316
- * Parses lockfile version (can be string or number)
1317
- */
1318
- parseLockfileVersion(version) {
1319
- if (typeof version === "number") {
1320
- return version;
1321
- }
1322
- if (typeof version === "string") {
1323
- const parsed = parseFloat(version);
1324
- return isNaN(parsed) ? 5.4 : parsed;
1325
- }
1326
- return 5.4;
1327
- }
1328
- /**
1329
- * Extracts dependencies from the lockfile packages section
1330
- */
1331
- extractDependencies(lockfile, lockfileVersion, directNames) {
1332
- const deps = [];
1333
- const seen = /* @__PURE__ */ new Map();
1334
- if (!lockfile.packages || typeof lockfile.packages !== "object") {
1335
- return deps;
1336
- }
1337
- for (const [pkgPath, pkgInfo] of Object.entries(lockfile.packages)) {
1338
- if (!pkgInfo || typeof pkgInfo !== "object") {
1339
- continue;
1340
- }
1341
- if (pkgPath.includes("@workspace:")) {
1342
- continue;
1343
- }
1344
- const { name, version } = this.parsePackagePath(pkgPath, lockfileVersion);
1345
- if (!name || !version) {
1346
- continue;
1347
- }
1348
- const depKey = `${name}@${version}`;
1349
- if (seen.has(depKey)) {
1350
- continue;
1351
- }
1352
- seen.set(depKey, true);
1353
- deps.push({
1354
- name,
1355
- version,
1356
- direct: directNames.has(name),
1357
- ecosystem: "npm",
1358
- purl: this.buildPurl(name, version)
1359
- });
1360
- }
1361
- return deps;
1362
- }
1363
- /**
1364
- * Parses package path to extract name and version.
1365
- *
1366
- * pnpm v5.x format:
1367
- * "/express/4.18.2" → name: "express", version: "4.18.2"
1368
- * "/@types/node/20.11.5" → name: "@types/node", version: "20.11.5"
1369
- * "/accepts/1.3.8" → name: "accepts", version: "1.3.8"
1370
- *
1371
- * pnpm v6+ format:
1372
- * "/express@4.18.2" → name: "express", version: "4.18.2"
1373
- * "/@types/node@20.11.5" → name: "@types/node", version: "20.11.5"
1374
- * "/accepts@1.3.8" → name: "accepts", version: "1.3.8"
1375
- *
1376
- * Also handles peer dependency suffixes:
1377
- * "/pkg@1.0.0_dep@2.0.0" → name: "pkg", version: "1.0.0"
1378
- * "/pkg@1.0.0(dep@2.0.0)" → name: "pkg", version: "1.0.0"
1379
- */
1380
- parsePackagePath(pkgPath, lockfileVersion) {
1381
- const path14 = pkgPath.startsWith("/") ? pkgPath.slice(1) : pkgPath;
1382
- const cleanPath = path14.split("_")[0].split("(")[0];
1383
- if (!cleanPath) {
1384
- return { name: null, version: null };
1385
- }
1386
- if (lockfileVersion >= 6) {
1387
- return this.parseV6Format(cleanPath);
1388
- }
1389
- return this.parseV5Format(cleanPath);
1390
- }
1391
- /**
1392
- * Parses v6+ format: "express@4.18.2" or "@types/node@20.11.5"
1393
- */
1394
- parseV6Format(path14) {
1395
- if (path14.startsWith("@")) {
1396
- const lastAtIndex = path14.lastIndexOf("@");
1397
- if (lastAtIndex <= 0) {
1398
- return { name: null, version: null };
1399
- }
1400
- const name2 = path14.substring(0, lastAtIndex);
1401
- const version2 = path14.substring(lastAtIndex + 1);
1402
- return { name: name2, version: version2 };
1403
- }
1404
- const atIndex = path14.indexOf("@");
1405
- if (atIndex < 0) {
1406
- return { name: null, version: null };
1407
- }
1408
- const name = path14.substring(0, atIndex);
1409
- const version = path14.substring(atIndex + 1);
1410
- return { name, version };
1411
- }
1412
- /**
1413
- * Parses v5.x format: "express/4.18.2" or "@types/node/20.11.5"
1414
- */
1415
- parseV5Format(path14) {
1416
- if (path14.startsWith("@")) {
1417
- const parts = path14.split("/");
1418
- if (parts.length < 3) {
1419
- return { name: null, version: null };
1420
- }
1421
- const name2 = `${parts[0]}/${parts[1]}`;
1422
- const version2 = parts[2];
1423
- return { name: name2, version: version2 };
1424
- }
1425
- const slashIndex = path14.indexOf("/");
1426
- if (slashIndex < 0) {
1427
- return { name: null, version: null };
1428
- }
1429
- const name = path14.substring(0, slashIndex);
1430
- const version = path14.substring(slashIndex + 1);
1431
- return { name, version };
1432
- }
1433
- /**
1434
- * Builds a purl (Package URL) for an npm package.
1435
- *
1436
- * Per the purl spec:
1437
- * "The npm scope @ sign prefix is always percent encoded."
1438
- *
1439
- * So @types/node@20.11.5 → pkg:npm/%40types/node@20.11.5
1440
- * And express@4.18.2 → pkg:npm/express@4.18.2
1441
- */
1442
- buildPurl(name, version) {
1443
- if (name.startsWith("@")) {
1444
- return `pkg:npm/%40${name.slice(1)}@${version}`;
1445
- }
1446
- return `pkg:npm/${name}@${version}`;
1447
- }
1448
- };
1449
-
1450
- // src/scanners/deno/deno-scanner.ts
1451
- import { readFile as readFile11 } from "fs/promises";
1452
- import { existsSync as existsSync11 } from "fs";
1453
- import path11 from "path";
1454
- var DenoScanner = class {
1455
- ecosystem = "deno";
1456
- lockfileNames = ["deno.lock"];
1457
- async detect(projectPath) {
1458
- for (const name of this.lockfileNames) {
1459
- const lockfilePath = path11.join(projectPath, name);
1460
- if (existsSync11(lockfilePath)) return lockfilePath;
1461
- }
1462
- return null;
1463
- }
1464
- async scan(projectPath, lockfilePath) {
1465
- const lockfileRaw = await readFile11(lockfilePath, "utf-8");
1466
- let lockfile;
1467
- try {
1468
- lockfile = JSON.parse(lockfileRaw);
1469
- } catch {
1470
- throw new LockfileParseError(lockfilePath, "Invalid JSON");
1471
- }
1472
- const dependencies = this.parseLockfile(lockfile);
1473
- return {
1474
- projectPath,
1475
- ecosystem: "deno",
1476
- dependencies,
1477
- lockfilePath,
1478
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
1479
- };
1480
- }
1481
- /**
1482
- * Parses the lockfile and extracts dependencies from both
1483
- * npm and jsr package registries.
1484
- *
1485
- * Supports v3 (packages nested under `packages`), v4, and v5 (top-level jsr/npm sections).
1486
- * Uses lockfile specifiers to determine direct vs transitive dependencies.
1487
- */
1488
- parseLockfile(lockfile) {
1489
- const deps = [];
1490
- const directNames = this.extractDirectDependencies(lockfile);
1491
- const jsrPackages = lockfile.jsr ?? lockfile.packages?.jsr ?? {};
1492
- const npmPackages = lockfile.npm ?? lockfile.packages?.npm ?? {};
1493
- for (const key of Object.keys(jsrPackages)) {
1494
- const parsed = this.parsePackageKey(key);
1495
- if (!parsed) continue;
1496
- deps.push({
1497
- name: parsed.name,
1498
- version: parsed.version,
1499
- direct: directNames.has(`jsr:${parsed.name}`),
1500
- ecosystem: "deno",
1501
- // JSR packages belong to Deno ecosystem
1502
- purl: this.buildJsrPurl(parsed.name, parsed.version)
1503
- });
1504
- }
1505
- for (const key of Object.keys(npmPackages)) {
1506
- const parsed = this.parsePackageKey(key);
1507
- if (!parsed) continue;
1508
- deps.push({
1509
- name: parsed.name,
1510
- version: parsed.version,
1511
- direct: directNames.has(`npm:${parsed.name}`),
1512
- ecosystem: "npm",
1513
- // npm packages belong to npm ecosystem (for CVE tracking)
1514
- purl: this.buildNpmPurl(parsed.name, parsed.version)
1515
- });
1516
- }
1517
- return deps;
1518
- }
1519
- /**
1520
- * Extracts direct dependency names from lockfile specifiers.
1521
- *
1522
- * Returns a set of ecosystem-qualified package names like "jsr:@std/assert" or "npm:express".
1523
- * The ecosystem prefix prevents collisions if the same package name exists in both registries.
1524
- */
1525
- extractDirectDependencies(lockfile) {
1526
- const directNames = /* @__PURE__ */ new Set();
1527
- const specifiers = lockfile.specifiers ?? lockfile.packages?.specifiers ?? {};
1528
- for (const [constraint, resolved] of Object.entries(specifiers)) {
1529
- if (resolved.startsWith("file:") || resolved.startsWith("https:") || resolved.startsWith("http:") || resolved.startsWith("data:")) {
1530
- continue;
1531
- }
1532
- let ecosystem = null;
1533
- if (constraint.startsWith("jsr:")) {
1534
- ecosystem = "jsr";
1535
- } else if (constraint.startsWith("npm:")) {
1536
- ecosystem = "npm";
1537
- }
1538
- if (!ecosystem) continue;
1539
- let resolvedKey = resolved;
1540
- if (resolved.startsWith("jsr:") || resolved.startsWith("npm:")) {
1541
- resolvedKey = resolved.replace(/^(jsr:|npm:)/, "");
1542
- const parsed = this.parsePackageKey(resolvedKey);
1543
- if (parsed) {
1544
- directNames.add(`${ecosystem}:${parsed.name}`);
1545
- }
1546
- } else {
1547
- const name = this.extractNameFromSpecifier(constraint);
1548
- if (name) {
1549
- directNames.add(`${ecosystem}:${name}`);
1550
- }
1551
- }
1552
- }
1553
- return directNames;
1554
- }
1555
- /**
1556
- * Parses a package key like "@std/assert@1.0.10" or "express@4.21.2"
1557
- * into { name, version }.
1558
- *
1559
- * Handles scoped packages where the name starts with @ (e.g., @std/assert).
1560
- * In that case the version separator is the LAST @ sign.
1561
- */
1562
- parsePackageKey(key) {
1563
- const lastAtIndex = key.lastIndexOf("@");
1564
- if (lastAtIndex <= 0) return null;
1565
- const name = key.slice(0, lastAtIndex);
1566
- const version = key.slice(lastAtIndex + 1);
1567
- if (!name || !version) return null;
1568
- return { name, version };
1569
- }
1570
- /**
1571
- * Extracts the package name from a Deno import specifier.
1572
- *
1573
- * Examples:
1574
- * "jsr:@std/assert@^1.0.0" → "@std/assert"
1575
- * "npm:express@^4.18.0" → "express"
1576
- * "npm:@hono/hono@^4.0.0" → "@hono/hono"
1577
- * "lodash" (bare) → "lodash"
1578
- */
1579
- extractNameFromSpecifier(specifier) {
1580
- const withoutPrefix = specifier.replace(/^(jsr:|npm:)/, "");
1581
- if (!withoutPrefix) return null;
1582
- if (withoutPrefix.startsWith("@")) {
1583
- const slashIndex = withoutPrefix.indexOf("/");
1584
- if (slashIndex === -1) return null;
1585
- const afterSlash = withoutPrefix.indexOf("@", slashIndex);
1586
- if (afterSlash === -1) return withoutPrefix;
1587
- return withoutPrefix.slice(0, afterSlash);
1588
- }
1589
- const atIndex = withoutPrefix.indexOf("@");
1590
- if (atIndex === -1) return withoutPrefix;
1591
- return withoutPrefix.slice(0, atIndex);
1592
- }
1593
- /**
1594
- * Builds a purl for a JSR package.
1595
- *
1596
- * JSR packages use the "jsr" purl type (non-standard but descriptive).
1597
- * For scoped packages, both @ and all / characters are percent-encoded.
1598
- * Example: `pkg:jsr/%40std%2Fassert@1.0.10`
1599
- */
1600
- buildJsrPurl(name, version) {
1601
- if (name.startsWith("@")) {
1602
- const encoded = "%40" + name.slice(1).replace(/\//g, "%2F");
1603
- return `pkg:jsr/${encoded}@${version}`;
1604
- }
1605
- return `pkg:jsr/${name}@${version}`;
1606
- }
1607
- /**
1608
- * Builds a purl for an npm package used via Deno.
1609
- *
1610
- * Uses the standard npm purl type since these are npm packages.
1611
- * Per npm purl spec, only @ is encoded, / remains as namespace separator.
1612
- * Example: `pkg:npm/%40std/assert@1.0.10` or `pkg:npm/express@4.21.2`
1613
- */
1614
- buildNpmPurl(name, version) {
1615
- if (name.startsWith("@")) {
1616
- return `pkg:npm/%40${name.slice(1)}@${version}`;
1617
- }
1618
- return `pkg:npm/${name}@${version}`;
1619
- }
1620
- };
1621
-
1622
- // src/scanners/poetry/poetry-scanner.ts
1623
- import { readFile as readFile12 } from "fs/promises";
1624
- import { existsSync as existsSync12 } from "fs";
1625
- import path12 from "path";
1626
- var PoetryScanner = class {
1627
- ecosystem = "poetry";
1628
- lockfileNames = ["poetry.lock"];
1629
- async detect(projectPath) {
1630
- const lockfilePath = path12.join(projectPath, "poetry.lock");
1631
- return existsSync12(lockfilePath) ? lockfilePath : null;
1632
- }
1633
- async scan(projectPath, lockfilePath) {
1634
- const [lockfileRaw, pyprojectRaw] = await Promise.all([
1635
- readFile12(lockfilePath, "utf-8"),
1636
- readFile12(path12.join(projectPath, "pyproject.toml"), "utf-8").catch(() => null)
1637
- ]);
1638
- const packages = this.parseLockfile(lockfileRaw, lockfilePath);
1639
- const directNames = pyprojectRaw ? this.parsePyprojectToml(pyprojectRaw) : /* @__PURE__ */ new Set();
1640
- const dependencies = [];
1641
- for (const pkg2 of packages) {
1642
- dependencies.push({
1643
- name: this.normalizePipName(pkg2.name),
1644
- version: pkg2.version,
1645
- direct: directNames.size > 0 ? directNames.has(this.normalizePipName(pkg2.name)) : true,
1646
- ecosystem: "poetry",
1647
- purl: this.buildPurl(pkg2.name, pkg2.version)
1648
- });
1649
- }
1650
- return {
1651
- projectPath,
1652
- ecosystem: "poetry",
1653
- dependencies,
1654
- lockfilePath,
1655
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
1656
- };
1657
- }
1658
- /**
1659
- * Parses poetry.lock by splitting on [[package]] blocks.
1660
- * Lightweight parser that handles the regular structure
1661
- * without needing a full TOML library.
1662
- */
1663
- parseLockfile(content, lockfilePath) {
1664
- const packages = [];
1665
- const blocks = content.split(/^\[\[package\]\]$/m);
1666
- for (const block of blocks) {
1667
- if (!block.trim()) continue;
1668
- const name = this.extractField(block, "name");
1669
- const version = this.extractField(block, "version");
1670
- if (name && version) {
1671
- packages.push({ name, version });
1672
- }
1673
- }
1674
- if (packages.length === 0 && content.includes("[[package]]")) {
1675
- throw new LockfileParseError(lockfilePath, "Failed to parse any packages from poetry.lock");
1676
- }
1677
- return packages;
1678
- }
1679
- /**
1680
- * Extracts a string field value from a TOML block.
1681
- * Handles: `name = "value"` format.
1682
- */
1683
- extractField(block, fieldName) {
1684
- const regex = new RegExp(`^${fieldName}\\s*=\\s*"([^"]*)"`, "m");
1685
- const match = block.match(regex);
1686
- return match ? match[1] : null;
1687
- }
1688
- /**
1689
- * Parses `pyproject.toml` to extract direct dependency names.
1690
- *
1691
- * Looks for:
1692
- * - `[tool.poetry.dependencies]` — main dependencies
1693
- * - `[tool.poetry.group.dev.dependencies]` — dev dependencies
1694
- * - `[tool.poetry.group.*.dependencies]` — other groups
1695
- *
1696
- * Supports formats:
1697
- * - `requests = "^2.31.0"`
1698
- * - `requests = { version = "^2.31.0", optional = true }`
1699
- * - `python = "^3.12"` — skipped (the Python interpreter itself)
1700
- */
1701
- parsePyprojectToml(content) {
1702
- const directNames = /* @__PURE__ */ new Set();
1703
- let inDepsSection = false;
1704
- for (const rawLine of content.split("\n")) {
1705
- const line = rawLine.trim();
1706
- if (line.startsWith("[")) {
1707
- inDepsSection = line === "[tool.poetry.dependencies]" || /^\[tool\.poetry\.group\.[^\]]+\.dependencies\]$/.test(line);
1708
- continue;
1709
- }
1710
- if (inDepsSection && line && !line.startsWith("#")) {
1711
- const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*=/);
1712
- if (match && match[1]) {
1713
- const name = this.normalizePipName(match[1]);
1714
- if (name !== "python") {
1715
- directNames.add(name);
1716
- }
1717
- }
1718
- }
1719
- }
1720
- return directNames;
1721
- }
1722
- /**
1723
- * Normalizes a pip package name per PEP 503.
1724
- * Converts to lowercase and replaces any run of [-_.] with a single hyphen.
1725
- */
1726
- normalizePipName(name) {
1727
- return name.toLowerCase().replace(/[-_.]+/g, "-");
1728
- }
1729
- /**
1730
- * Builds a purl for a PyPI package.
1731
- * Per purl spec, the type is "pypi" (not "poetry").
1732
- */
1733
- buildPurl(name, version) {
1734
- return `pkg:pypi/${this.normalizePipName(name)}@${version}`;
1735
- }
1736
- };
1737
-
1738
- // src/scanners/uv/uv-scanner.ts
1739
- import { readFile as readFile13 } from "fs/promises";
1740
- import { existsSync as existsSync13 } from "fs";
1741
- import path13 from "path";
1742
- var UvScanner = class {
1743
- ecosystem = "uv";
1744
- lockfileNames = ["uv.lock"];
1745
- async detect(projectPath) {
1746
- const lockfilePath = path13.join(projectPath, "uv.lock");
1747
- return existsSync13(lockfilePath) ? lockfilePath : null;
1748
- }
1749
- async scan(projectPath, lockfilePath) {
1750
- const [lockfileRaw, pyprojectRaw] = await Promise.all([
1751
- readFile13(lockfilePath, "utf-8"),
1752
- readFile13(path13.join(projectPath, "pyproject.toml"), "utf-8").catch(() => null)
1753
- ]);
1754
- const packages = this.parseLockfile(lockfileRaw, lockfilePath);
1755
- const projectName = pyprojectRaw ? this.extractProjectName(pyprojectRaw) : null;
1756
- const directNames = pyprojectRaw ? this.parsePyprojectDeps(pyprojectRaw) : /* @__PURE__ */ new Set();
1757
- const dependencies = [];
1758
- for (const pkg2 of packages) {
1759
- if (pkg2.isEditable) continue;
1760
- if (projectName && this.normalizePipName(pkg2.name) === this.normalizePipName(projectName)) {
1761
- continue;
1762
- }
1763
- dependencies.push({
1764
- name: this.normalizePipName(pkg2.name),
1765
- version: pkg2.version,
1766
- direct: directNames.size > 0 ? directNames.has(this.normalizePipName(pkg2.name)) : true,
1767
- ecosystem: "uv",
1768
- purl: this.buildPurl(pkg2.name, pkg2.version)
1769
- });
1770
- }
1771
- return {
1772
- projectPath,
1773
- ecosystem: "uv",
1774
- dependencies,
1775
- lockfilePath,
1776
- scannedAt: (/* @__PURE__ */ new Date()).toISOString()
1777
- };
1778
- }
1779
- /**
1780
- * Parses uv.lock by splitting on [[package]] blocks.
1781
- * Lightweight parser that handles the regular structure
1782
- * without needing a full TOML library.
1783
- */
1784
- parseLockfile(content, lockfilePath) {
1785
- const packages = [];
1786
- const blocks = content.split(/^\[\[package\]\]$/m);
1787
- for (const block of blocks) {
1788
- if (!block.trim()) continue;
1789
- const name = this.extractField(block, "name");
1790
- const version = this.extractField(block, "version");
1791
- if (name && version) {
1792
- const isEditable = /source\s*=\s*\{[^}]*editable\s*=/.test(block) || /source\s*=\s*\{[^}]*virtual\s*=/.test(block);
1793
- packages.push({ name, version, isEditable });
1794
- }
1795
- }
1796
- if (packages.length === 0 && content.includes("[[package]]")) {
1797
- throw new LockfileParseError(lockfilePath, "Failed to parse any packages from uv.lock");
1798
- }
1799
- return packages;
1800
- }
1801
- /**
1802
- * Extracts a string field value from a TOML block.
1803
- * Handles: `name = "value"` format.
1804
- */
1805
- extractField(block, fieldName) {
1806
- const regex = new RegExp(`^${fieldName}\\s*=\\s*"([^"]*)"`, "m");
1807
- const match = block.match(regex);
1808
- return match ? match[1] : null;
1809
- }
1810
- /**
1811
- * Extracts the project name from `pyproject.toml`.
1812
- * Looks for `name = "..."` under `[project]`.
1813
- */
1814
- extractProjectName(content) {
1815
- let inProjectSection = false;
1816
- for (const rawLine of content.split("\n")) {
1817
- const line = rawLine.trim();
1818
- if (line.startsWith("[")) {
1819
- inProjectSection = line === "[project]";
1820
- continue;
1821
- }
1822
- if (inProjectSection) {
1823
- const match = line.match(/^name\s*=\s*"([^"]*)"/);
1824
- if (match) return match[1];
1825
- }
1826
- }
1827
- return null;
1828
- }
1829
- /**
1830
- * Parses `pyproject.toml` to extract direct dependency names.
1831
- *
1832
- * Looks for:
1833
- * - `[project]` → `dependencies = [...]` (PEP 621)
1834
- * - `[project.optional-dependencies]` (extras)
1835
- * - `[dependency-groups]` (PEP 735, used by uv for dev deps)
1836
- *
1837
- * Dependency strings follow PEP 508:
1838
- * - `"requests>=2.31.0"`
1839
- * - `"flask[dotenv]>=3.0"`
1840
- * - `"black"` (bare name)
1841
- */
1842
- parsePyprojectDeps(content) {
1843
- const directNames = /* @__PURE__ */ new Set();
1844
- this.extractInlineArray(content, directNames);
1845
- this.extractDependencyGroups(content, directNames);
1846
- return directNames;
1847
- }
1848
- /**
1849
- * Extracts dependency names from PEP 621 `dependencies = [...]` arrays
1850
- * and `[project.optional-dependencies]` sections.
1851
- */
1852
- extractInlineArray(content, directNames) {
1853
- const arrayRegex = /(?:^dependencies|^[a-zA-Z0-9_-]+)\s*=\s*\[([^\]]*)\]/gm;
1854
- let match;
1855
- while ((match = arrayRegex.exec(content)) !== null) {
1856
- const arrayContent = match[1];
1857
- this.extractPepNames(arrayContent, directNames);
1858
- }
1859
- }
1860
- /**
1861
- * Extracts dependency names from [dependency-groups] sections.
1862
- * Format:
1863
- * ```toml
1864
- * [dependency-groups]
1865
- * dev = ["pytest>=7.0", "black"]
1866
- * ```
1867
- */
1868
- extractDependencyGroups(content, directNames) {
1869
- let inDepGroups = false;
1870
- for (const rawLine of content.split("\n")) {
1871
- const line = rawLine.trim();
1872
- if (line.startsWith("[")) {
1873
- inDepGroups = line === "[dependency-groups]";
1874
- continue;
1875
- }
1876
- if (inDepGroups && line && !line.startsWith("#")) {
1877
- const arrayMatch = line.match(/^[a-zA-Z0-9_-]+\s*=\s*\[([^\]]*)\]/);
1878
- if (arrayMatch) {
1879
- this.extractPepNames(arrayMatch[1], directNames);
1880
- }
1881
- }
1882
- }
1883
- }
1884
- /**
1885
- * Extracts PEP 508 package names from a comma-separated
1886
- * list of quoted dependency strings.
1887
- */
1888
- extractPepNames(content, directNames) {
1889
- const depStrings = content.match(/"([^"]*)"/g);
1890
- if (!depStrings) return;
1891
- for (const quoted of depStrings) {
1892
- const depStr = quoted.replace(/"/g, "").trim();
1893
- if (!depStr) continue;
1894
- const nameMatch = depStr.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)/);
1895
- if (nameMatch && nameMatch[1]) {
1896
- directNames.add(this.normalizePipName(nameMatch[1]));
1897
- }
1898
- }
1899
- }
1900
- /**
1901
- * Normalizes a pip package name per PEP 503.
1902
- * Converts to lowercase and replaces any run of [-_.] with a single hyphen.
1903
- */
1904
- normalizePipName(name) {
1905
- return name.toLowerCase().replace(/[-_.]+/g, "-");
1906
- }
1907
- /**
1908
- * Builds a purl for a PyPI package.
1909
- * Per purl spec, the type is "pypi" (not "uv").
1910
- */
1911
- buildPurl(name, version) {
1912
- return `pkg:pypi/${this.normalizePipName(name)}@${version}`;
1913
- }
1914
- };
1915
-
1916
- // src/scanners/registry.ts
1917
- var ScannerRegistry = class {
1918
- scanners;
1919
- constructor() {
1920
- this.scanners = [
1921
- new NpmScanner(),
1922
- new NugetScanner(),
1923
- new CargoScanner(),
1924
- new PipScanner(),
1925
- new PoetryScanner(),
1926
- new UvScanner(),
1927
- new MavenScanner(),
1928
- new GoScanner(),
1929
- new RubyScanner(),
1930
- new ComposerScanner(),
1931
- new YarnScanner(),
1932
- new PnpmScanner(),
1933
- new DenoScanner()
1934
- ];
1935
- }
1936
- /**
1937
- * Auto-detects the project's ecosystem and scans dependencies.
1938
- * Tries each registered scanner in order until one matches.
1939
- */
1940
- async detectAndScan(projectPath) {
1941
- for (const scanner of this.scanners) {
1942
- const lockfilePath = await scanner.detect(projectPath);
1943
- if (lockfilePath) {
1944
- return scanner.scan(projectPath, lockfilePath);
1945
- }
1946
- }
1947
- throw new NoLockfileError(projectPath);
1948
- }
1949
- /** Returns a specific scanner by ecosystem name */
1950
- getScanner(ecosystem) {
1951
- return this.scanners.find((s) => s.ecosystem === ecosystem);
1952
- }
1953
- /** Lists all registered ecosystems */
1954
- listEcosystems() {
1955
- return this.scanners.map((s) => s.ecosystem);
1956
- }
1957
- };
1958
-
1959
- // src/sbom/shared.ts
1960
- var VERIMU_TOOL_NAME = "verimu";
1961
- var VERIMU_TOOL_WEBSITE = "https://verimu.com";
1962
- var VERIMU_TOOL_DESCRIPTION = "Verimu CRA Compliance Scanner";
1963
- var DEFAULT_TOOL_VERSION = "0.1.0";
1964
- var DEFAULT_SWID_VERSION = "0.0.0";
1965
- var PURL_TYPE_MAP = {
1966
- npm: "npm",
1967
- nuget: "nuget",
1968
- cargo: "cargo",
1969
- maven: "maven",
1970
- pip: "pypi",
1971
- poetry: "pypi",
1972
- uv: "pypi",
1973
- go: "golang",
1974
- ruby: "gem",
1975
- composer: "composer",
1976
- deno: "deno"
1977
- };
1978
- function buildPurl(name, version, ecosystem) {
1979
- const type = PURL_TYPE_MAP[ecosystem] || ecosystem;
1980
- if (ecosystem === "npm" && name.startsWith("@")) {
1981
- return `pkg:${type}/%40${name.slice(1)}@${version}`;
1982
- }
1983
- return `pkg:${type}/${name}@${version}`;
1984
- }
1985
- function deriveSupplierName(packageName) {
1986
- if (packageName.startsWith("@")) {
1987
- return packageName.split("/")[0];
1988
- }
1989
- return packageName;
1990
- }
1991
- function extractProjectName(projectPath) {
1992
- const parts = projectPath.replace(/\\/g, "/").split("/");
1993
- return parts[parts.length - 1] || "unknown-project";
1994
- }
1995
- function normalizeDependencies(dependencies) {
1996
- return dependencies.map((dep) => ({
1997
- name: dep.name,
1998
- version: dep.version,
1999
- ecosystem: dep.ecosystem,
2000
- direct: dep.direct ?? true,
2001
- purl: dep.purl ?? buildPurl(dep.name, dep.version, dep.ecosystem),
2002
- supplierName: deriveSupplierName(dep.name)
2003
- }));
2004
- }
2005
-
2006
- // src/sbom/cyclonedx.ts
2007
- import { randomUUID } from "crypto";
2008
- var SCHEMA_URLS = {
2009
- "1.4": "http://cyclonedx.org/schema/bom-1.4.schema.json",
2010
- "1.5": "http://cyclonedx.org/schema/bom-1.5.schema.json",
2011
- "1.6": "http://cyclonedx.org/schema/bom-1.6.schema.json",
2012
- "1.7": "http://cyclonedx.org/schema/bom-1.7.schema.json"
2013
- };
2014
- var CycloneDxGenerator = class {
2015
- constructor(specVersion = "1.7") {
2016
- this.specVersion = specVersion;
2017
- }
2018
- format = "cyclonedx-json";
2019
- generate(scanResult, toolVersion = DEFAULT_TOOL_VERSION) {
2020
- const bom = this.buildBom(scanResult, toolVersion);
2021
- const content = JSON.stringify(bom, null, 2);
2022
- return {
2023
- format: "cyclonedx-json",
2024
- specVersion: this.specVersion,
2025
- content,
2026
- componentCount: scanResult.dependencies.length,
2027
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
2028
- };
2029
- }
2030
- buildBom(scanResult, toolVersion) {
2031
- const projectName = extractProjectName(scanResult.projectPath);
2032
- return {
2033
- $schema: SCHEMA_URLS[this.specVersion],
2034
- bomFormat: "CycloneDX",
2035
- specVersion: this.specVersion,
2036
- serialNumber: `urn:uuid:${randomUUID()}`,
2037
- version: 1,
2038
- metadata: {
2039
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2040
- tools: this.buildTools(toolVersion),
2041
- // NTIA: metadata.supplier — the org supplying the root software
2042
- supplier: {
2043
- name: projectName
2044
- },
2045
- component: {
2046
- type: "application",
2047
- name: projectName,
2048
- "bom-ref": "root-component",
2049
- supplier: { name: projectName }
2050
- }
2051
- },
2052
- components: scanResult.dependencies.map((dep) => this.toComponent(dep)),
2053
- dependencies: this.buildDependencyGraph(scanResult)
2054
- };
2055
- }
2056
- /** Converts a Verimu Dependency to a CycloneDX component */
2057
- toComponent(dep) {
2058
- return {
2059
- type: "library",
2060
- name: dep.name,
2061
- version: dep.version,
2062
- purl: dep.purl,
2063
- "bom-ref": dep.purl,
2064
- scope: dep.direct ? "required" : "optional",
2065
- // NTIA: component.supplier — derived from npm scope or package name
2066
- supplier: {
2067
- name: deriveSupplierName(dep.name)
2068
- }
2069
- };
2070
- }
2071
- /**
2072
- * Builds the dependency graph section of the SBOM.
2073
- *
2074
- * The root component depends on all dependencies (direct + transitive).
2075
- * This ensures a single root node in the graph, which NTIA validators expect.
2076
- *
2077
- * We include ALL deps under root (not just direct) because from a flat lockfile
2078
- * we can't reliably reconstruct which transitive dep belongs to which direct dep.
2079
- * This is still valid per the CycloneDX spec — it represents a complete but flat
2080
- * dependency relationship.
2081
- */
2082
- buildDependencyGraph(scanResult) {
2083
- const allDepPurls = scanResult.dependencies.map((d) => d.purl);
2084
- return [
2085
- {
2086
- ref: "root-component",
2087
- dependsOn: allDepPurls
2088
- }
2089
- ];
2090
- }
2091
- /**
2092
- * Builds the tools metadata section.
2093
- *
2094
- * CycloneDX 1.4: tools is a flat array of { vendor, name, version, ... }
2095
- * CycloneDX 1.5+: tools is an object { components: [...] }
2096
- */
2097
- buildTools(toolVersion) {
2098
- if (this.specVersion === "1.4") {
2099
- return [
2100
- {
2101
- vendor: "Verimu",
2102
- name: VERIMU_TOOL_NAME,
2103
- version: toolVersion,
2104
- externalReferences: [{ type: "website", url: VERIMU_TOOL_WEBSITE }]
2105
- }
2106
- ];
2107
- }
2108
- return {
2109
- components: [
2110
- {
2111
- type: "application",
2112
- name: VERIMU_TOOL_NAME,
2113
- version: toolVersion,
2114
- description: VERIMU_TOOL_DESCRIPTION,
2115
- supplier: { name: "Verimu" },
2116
- externalReferences: [{ type: "website", url: VERIMU_TOOL_WEBSITE }]
2117
- }
2118
- ]
2119
- };
2120
- }
2121
- };
2122
-
2123
- // src/sbom/spdx.ts
2124
- import { randomUUID as randomUUID2 } from "crypto";
2125
- var SPDX_VERSION = "2.3";
2126
- var SpdxJsonGenerator = class {
2127
- format = "spdx-json";
2128
- generate(scanResult, toolVersion = DEFAULT_TOOL_VERSION) {
2129
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2130
- const projectName = extractProjectName(scanResult.projectPath);
2131
- const rootPackageId = "SPDXRef-Package-root";
2132
- const dependencies = normalizeDependencies(scanResult.dependencies);
2133
- const document = {
2134
- spdxVersion: `SPDX-${SPDX_VERSION}`,
2135
- dataLicense: "CC0-1.0",
2136
- SPDXID: "SPDXRef-DOCUMENT",
2137
- name: `${projectName}-sbom`,
2138
- documentNamespace: `https://verimu.com/spdxdocs/${projectName}-${randomUUID2()}`,
2139
- creationInfo: {
2140
- created: timestamp,
2141
- creators: [`Tool: ${VERIMU_TOOL_NAME}@${toolVersion}`]
2142
- },
2143
- documentDescribes: [rootPackageId],
2144
- packages: [
2145
- {
2146
- name: projectName,
2147
- SPDXID: rootPackageId,
2148
- versionInfo: "NOASSERTION",
2149
- supplier: `Organization: ${projectName}`,
2150
- downloadLocation: "NOASSERTION",
2151
- filesAnalyzed: false,
2152
- licenseConcluded: "NOASSERTION",
2153
- licenseDeclared: "NOASSERTION",
2154
- primaryPackagePurpose: "APPLICATION"
2155
- },
2156
- ...dependencies.map((dep, index) => ({
2157
- name: dep.name,
2158
- SPDXID: `SPDXRef-Package-${index + 1}`,
2159
- versionInfo: dep.version,
2160
- supplier: `Organization: ${dep.supplierName}`,
2161
- downloadLocation: "NOASSERTION",
2162
- filesAnalyzed: false,
2163
- licenseConcluded: "NOASSERTION",
2164
- licenseDeclared: "NOASSERTION",
2165
- primaryPackagePurpose: "LIBRARY",
2166
- externalRefs: [
2167
- {
2168
- referenceCategory: "PACKAGE-MANAGER",
2169
- referenceType: "purl",
2170
- referenceLocator: dep.purl
2171
- }
2172
- ]
2173
- }))
2174
- ],
2175
- relationships: [
2176
- {
2177
- spdxElementId: "SPDXRef-DOCUMENT",
2178
- relationshipType: "DESCRIBES",
2179
- relatedSpdxElement: rootPackageId
2180
- },
2181
- ...dependencies.map((_dep, index) => ({
2182
- spdxElementId: rootPackageId,
2183
- relationshipType: "DEPENDS_ON",
2184
- relatedSpdxElement: `SPDXRef-Package-${index + 1}`
2185
- }))
2186
- ]
2187
- };
2188
- return {
2189
- format: "spdx-json",
2190
- specVersion: SPDX_VERSION,
2191
- content: JSON.stringify(document, null, 2),
2192
- componentCount: scanResult.dependencies.length,
2193
- generatedAt: timestamp
2194
- };
2195
- }
2196
- };
2197
-
2198
- // src/sbom/swid.ts
2199
- import { randomUUID as randomUUID3 } from "crypto";
2200
- var SWID_SPEC_VERSION = "ISO/IEC 19770-2:2015";
2201
- var SwidTagGenerator = class {
2202
- format = "swid-xml";
2203
- generate(scanResult, toolVersion = DEFAULT_TOOL_VERSION) {
2204
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2205
- const projectName = extractProjectName(scanResult.projectPath);
2206
- const tagId = `com.verimu:${sanitizeTagId(projectName)}:${DEFAULT_SWID_VERSION}:${randomUUID3()}`;
2207
- const content = [
2208
- '<?xml version="1.0" encoding="UTF-8"?>',
2209
- "<SoftwareIdentity",
2210
- ' xmlns="http://standards.iso.org/iso/19770/-2/2015/schema.xsd"',
2211
- ` name="${escapeXml(projectName)}"`,
2212
- ` tagId="${escapeXml(tagId)}"`,
2213
- ' tagVersion="1"',
2214
- ` version="${DEFAULT_SWID_VERSION}"`,
2215
- ' versionScheme="semver">',
2216
- ` <Entity name="${escapeXml(projectName)}" role="softwareCreator" />`,
2217
- ' <Entity name="Verimu" role="tagCreator" />',
2218
- ` <Meta product="${escapeXml(projectName)}" generator="${VERIMU_TOOL_NAME}" toolVersion="${toolVersion}" generated="${timestamp}" />`,
2219
- " <!-- TODO: Consider adding dependency/package evidence if we need richer SWID coverage. -->",
2220
- ' <Link rel="describedby" href="https://verimu.com" />',
2221
- "</SoftwareIdentity>"
2222
- ].join("\n");
2223
- return {
2224
- format: "swid-xml",
2225
- specVersion: SWID_SPEC_VERSION,
2226
- content,
2227
- componentCount: 1,
2228
- generatedAt: timestamp
2229
- };
2230
- }
2231
- };
2232
- function escapeXml(value) {
2233
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
2234
- }
2235
- function sanitizeTagId(value) {
2236
- return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
2237
- }
2238
-
2239
- // src/sbom/artifacts.ts
2240
- function generateSbomArtifacts(scanResult, toolVersion = DEFAULT_TOOL_VERSION, cyclonedxVersion = "1.7") {
2241
- return {
2242
- cyclonedx: new CycloneDxGenerator(cyclonedxVersion).generate(scanResult, toolVersion),
2243
- spdx: new SpdxJsonGenerator().generate(scanResult, toolVersion),
2244
- swid: new SwidTagGenerator().generate(scanResult, toolVersion)
2245
- };
2246
- }
2247
-
2248
- // src/cve/osv.ts
2249
- var OSV_API_BASE = "https://api.osv.dev/v1";
2250
- var BATCH_SIZE = 1e3;
2251
- var OsvSource = class {
2252
- sourceId = "osv";
2253
- name = "OSV.dev (Google Open Source Vulnerabilities)";
2254
- fetchFn;
2255
- constructor(fetchImpl) {
2256
- this.fetchFn = fetchImpl ?? globalThis.fetch;
2257
- }
2258
- async checkDependencies(dependencies) {
2259
- if (dependencies.length === 0) return [];
2260
- const allVulns = [];
2261
- for (let i = 0; i < dependencies.length; i += BATCH_SIZE) {
2262
- const batch = dependencies.slice(i, i + BATCH_SIZE);
2263
- const batchVulns = await this.queryBatch(batch);
2264
- allVulns.push(...batchVulns);
2265
- }
2266
- return allVulns;
2267
- }
2268
- /**
2269
- * Uses OSV's /querybatch endpoint to get vulnerability IDs,
2270
- * then fetches full details for each unique vulnerability.
2271
- */
2272
- async queryBatch(dependencies) {
2273
- const queries = dependencies.map((dep) => ({
2274
- version: dep.version,
2275
- package: {
2276
- name: dep.name,
2277
- ecosystem: this.mapEcosystem(dep.ecosystem)
2278
- }
2279
- }));
2280
- const response = await this.fetchFn(`${OSV_API_BASE}/querybatch`, {
2281
- method: "POST",
2282
- headers: { "Content-Type": "application/json" },
2283
- body: JSON.stringify({ queries })
2284
- });
2285
- if (!response.ok) {
2286
- throw new Error(`OSV API error: ${response.status} ${response.statusText}`);
2287
- }
2288
- const data = await response.json();
2289
- const vulnIdToDeps = /* @__PURE__ */ new Map();
2290
- for (let i = 0; i < data.results.length; i++) {
2291
- const result = data.results[i];
2292
- const dep = dependencies[i];
2293
- if (result.vulns && result.vulns.length > 0) {
2294
- for (const vuln of result.vulns) {
2295
- const existing = vulnIdToDeps.get(vuln.id);
2296
- if (existing) {
2297
- existing.push(dep);
2298
- } else {
2299
- vulnIdToDeps.set(vuln.id, [dep]);
2300
- }
2301
- }
2302
- }
2303
- }
2304
- if (vulnIdToDeps.size === 0) {
2305
- return [];
2306
- }
2307
- const vulnIds = Array.from(vulnIdToDeps.keys());
2308
- const fullVulns = await this.fetchVulnerabilityDetails(vulnIds);
2309
- const vulnerabilities = [];
2310
- for (const osvVuln of fullVulns) {
2311
- const affectedDeps = vulnIdToDeps.get(osvVuln.id) ?? [];
2312
- for (const dep of affectedDeps) {
2313
- vulnerabilities.push(this.mapVulnerability(osvVuln, dep));
2314
- }
2315
- }
2316
- return vulnerabilities;
2317
- }
2318
- /**
2319
- * Fetches full vulnerability details from /v1/vulns/{id} for each ID.
2320
- * Makes parallel requests for efficiency.
2321
- */
2322
- async fetchVulnerabilityDetails(vulnIds) {
2323
- const results = [];
2324
- const CONCURRENCY = 10;
2325
- for (let i = 0; i < vulnIds.length; i += CONCURRENCY) {
2326
- const batch = vulnIds.slice(i, i + CONCURRENCY);
2327
- const promises = batch.map(async (id) => {
2328
- try {
2329
- const response = await this.fetchFn(`${OSV_API_BASE}/vulns/${encodeURIComponent(id)}`, {
2330
- method: "GET",
2331
- headers: { "Content-Type": "application/json" }
2332
- });
2333
- if (!response.ok) {
2334
- console.warn(`Failed to fetch vulnerability ${id}: ${response.status}`);
2335
- return null;
2336
- }
2337
- return await response.json();
2338
- } catch (err) {
2339
- console.warn(`Error fetching vulnerability ${id}:`, err);
2340
- return null;
2341
- }
2342
- });
2343
- const batchResults = await Promise.all(promises);
2344
- results.push(...batchResults.filter((v) => v !== null));
2345
- }
2346
- return results;
2347
- }
2348
- /** Maps an OSV vulnerability record to our Vulnerability type */
2349
- mapVulnerability(osvVuln, dep) {
2350
- const cveId = this.extractCveId(osvVuln);
2351
- const severity = this.extractSeverity(osvVuln);
2352
- return {
2353
- id: cveId || osvVuln.id,
2354
- aliases: Array.from(/* @__PURE__ */ new Set([osvVuln.id, ...osvVuln.aliases ?? []])),
2355
- summary: osvVuln.summary ?? osvVuln.details?.slice(0, 200) ?? "No description available",
2356
- severity: severity.level,
2357
- cvssScore: severity.score,
2358
- packageName: dep.name,
2359
- ecosystem: dep.ecosystem,
2360
- affectedVersionRange: this.extractAffectedRange(osvVuln, dep.name),
2361
- fixedVersion: this.extractFixedVersion(osvVuln, dep.name),
2362
- exploitedInWild: false,
2363
- // OSV doesn't track this — CISA KEV does
2364
- source: "osv",
2365
- referenceUrl: `https://osv.dev/vulnerability/${osvVuln.id}`,
2366
- publishedAt: osvVuln.published
2367
- };
2368
- }
2369
- /** Extracts CVE ID from aliases (prefers CVE-xxxx over GHSA-xxxx) */
2370
- extractCveId(vuln) {
2371
- if (vuln.id.startsWith("CVE-")) return vuln.id;
2372
- if (vuln.aliases) {
2373
- const cve = vuln.aliases.find((a) => a.startsWith("CVE-"));
2374
- if (cve) return cve;
2375
- }
2376
- return null;
2377
- }
2378
- /** Extracts severity from CVSS scores in the OSV record */
2379
- extractSeverity(vuln) {
2380
- if (vuln.severity && vuln.severity.length > 0) {
2381
- for (const sev of vuln.severity) {
2382
- if (sev.type === "CVSS_V3") {
2383
- const score = this.parseCvssScore(sev.score);
2384
- if (score !== null) {
2385
- return { level: this.scoreToSeverity(score), score };
2386
- }
2387
- }
2388
- }
2389
- }
2390
- if (vuln.database_specific?.severity) {
2391
- const s = vuln.database_specific.severity.toUpperCase();
2392
- if (["CRITICAL", "HIGH", "MEDIUM", "LOW"].includes(s)) {
2393
- return { level: s };
2394
- }
2395
- }
2396
- return { level: "UNKNOWN" };
2397
- }
2398
- /** Parses CVSS v3 vector string to extract/calculate the base score */
2399
- parseCvssScore(vectorOrScore) {
2400
- const num = parseFloat(vectorOrScore);
2401
- if (!isNaN(num) && num >= 0 && num <= 10) return num;
2402
- if (vectorOrScore.startsWith("CVSS:3")) {
2403
- return this.calculateCvss3Score(vectorOrScore);
2404
- }
2405
- return null;
2406
- }
2407
- /** Calculate CVSS v3.x base score from vector string */
2408
- calculateCvss3Score(vector) {
2409
- const metricValues = {
2410
- AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 },
2411
- // Attack Vector
2412
- AC: { L: 0.77, H: 0.44 },
2413
- // Attack Complexity
2414
- PR: {
2415
- // Privileges Required (varies by Scope)
2416
- N_U: 0.85,
2417
- L_U: 0.62,
2418
- H_U: 0.27,
2419
- N_C: 0.85,
2420
- L_C: 0.68,
2421
- H_C: 0.5
2422
- },
2423
- UI: { N: 0.85, R: 0.62 },
2424
- // User Interaction
2425
- C: { H: 0.56, L: 0.22, N: 0 },
2426
- // Confidentiality Impact
2427
- I: { H: 0.56, L: 0.22, N: 0 },
2428
- // Integrity Impact
2429
- A: { H: 0.56, L: 0.22, N: 0 }
2430
- // Availability Impact
2431
- };
2432
- const parts = vector.split("/");
2433
- const metrics = {};
2434
- for (const part of parts) {
2435
- const [key, value] = part.split(":");
2436
- if (key && value) metrics[key] = value;
2437
- }
2438
- const av = metricValues.AV[metrics.AV];
2439
- const ac = metricValues.AC[metrics.AC];
2440
- const ui = metricValues.UI[metrics.UI];
2441
- const scope = metrics.S;
2442
- const c = metricValues.C[metrics.C];
2443
- const i = metricValues.I[metrics.I];
2444
- const a = metricValues.A[metrics.A];
2445
- const prKey = `${metrics.PR}_${scope}`;
2446
- const pr = metricValues.PR[prKey];
2447
- if ([av, ac, pr, ui, c, i, a].some((v) => v === void 0)) {
2448
- return null;
2449
- }
2450
- const iss = 1 - (1 - c) * (1 - i) * (1 - a);
2451
- let impact;
2452
- if (scope === "U") {
2453
- impact = 6.42 * iss;
2454
- } else {
2455
- impact = 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15);
2456
- }
2457
- const exploitability = 8.22 * av * ac * pr * ui;
2458
- if (impact <= 0) return 0;
2459
- let baseScore;
2460
- if (scope === "U") {
2461
- baseScore = Math.min(impact + exploitability, 10);
2462
- } else {
2463
- baseScore = Math.min(1.08 * (impact + exploitability), 10);
2464
- }
2465
- return Math.ceil(baseScore * 10) / 10;
2466
- }
2467
- /** Converts a CVSS score (0-10) to a severity level */
2468
- scoreToSeverity(score) {
2469
- if (score >= 9) return "CRITICAL";
2470
- if (score >= 7) return "HIGH";
2471
- if (score >= 4) return "MEDIUM";
2472
- if (score > 0) return "LOW";
2473
- return "UNKNOWN";
2474
- }
2475
- /** Extracts affected version range for a specific package */
2476
- extractAffectedRange(vuln, packageName) {
2477
- if (!vuln.affected) return void 0;
2478
- for (const affected of vuln.affected) {
2479
- if (affected.package?.name === packageName && affected.ranges) {
2480
- for (const range of affected.ranges) {
2481
- if (range.events) {
2482
- const introduced = range.events.find((e) => e.introduced)?.introduced;
2483
- const fixed = range.events.find((e) => e.fixed)?.fixed;
2484
- if (introduced && fixed) return `>=${introduced}, <${fixed}`;
2485
- if (introduced) return `>=${introduced}`;
2486
- }
2487
- }
2488
- }
2489
- }
2490
- return void 0;
2491
- }
2492
- /** Extracts the fixed version for a specific package */
2493
- extractFixedVersion(vuln, packageName) {
2494
- if (!vuln.affected) return void 0;
2495
- for (const affected of vuln.affected) {
2496
- if (affected.package?.name === packageName && affected.ranges) {
2497
- for (const range of affected.ranges) {
2498
- if (range.events) {
2499
- const fixed = range.events.find((e) => e.fixed)?.fixed;
2500
- if (fixed) return fixed;
2501
- }
2502
- }
2503
- }
2504
- }
2505
- return void 0;
2506
- }
2507
- /** Maps our ecosystem names to OSV ecosystem names */
2508
- mapEcosystem(ecosystem) {
2509
- const map = {
2510
- npm: "npm",
2511
- nuget: "NuGet",
2512
- cargo: "crates.io",
2513
- maven: "Maven",
2514
- pip: "PyPI",
2515
- poetry: "PyPI",
2516
- uv: "PyPI",
2517
- go: "Go",
2518
- ruby: "RubyGems",
2519
- composer: "Packagist",
2520
- deno: "JSR"
2521
- // JSR packages (Deno registry)
2522
- };
2523
- return map[ecosystem] ?? ecosystem;
2524
- }
2525
- };
2526
-
2527
- // src/cve/aggregator.ts
2528
- var CveAggregator = class {
2529
- sources;
2530
- constructor(sources) {
2531
- this.sources = sources ?? [
2532
- new OsvSource()
2533
- // Future: new NvdSource(), new EuvdSource(), new CisaKevSource()
2534
- ];
2535
- }
2536
- /**
2537
- * Checks dependencies against all registered CVE sources.
2538
- * Runs sources in parallel and merges/deduplicates results.
2539
- */
2540
- async check(dependencies) {
2541
- const startTime = Date.now();
2542
- const sourcesQueried = [];
2543
- const sourceErrors = [];
2544
- const allVulns = [];
2545
- const results = await Promise.allSettled(
2546
- this.sources.map(async (source) => {
2547
- const vulns = await source.checkDependencies(dependencies);
2548
- return { sourceId: source.sourceId, vulns };
2549
- })
2550
- );
2551
- for (const result of results) {
2552
- if (result.status === "fulfilled") {
2553
- sourcesQueried.push(result.value.sourceId);
2554
- allVulns.push(...result.value.vulns);
2555
- } else {
2556
- const sourceIndex = results.indexOf(result);
2557
- const sourceId = this.sources[sourceIndex].sourceId;
2558
- sourceErrors.push({
2559
- source: sourceId,
2560
- error: result.reason instanceof Error ? result.reason.message : String(result.reason)
2561
- });
2562
- }
2563
- }
2564
- const deduplicated = this.deduplicateVulnerabilities(allVulns);
2565
- return {
2566
- vulnerabilities: deduplicated,
2567
- sourcesQueried,
2568
- sourceErrors,
2569
- checkDurationMs: Date.now() - startTime
2570
- };
2571
- }
2572
- /**
2573
- * Deduplicates vulnerabilities by ID.
2574
- * When the same CVE appears from multiple sources,
2575
- * keeps the one with more complete data (has CVSS score, has fix version, etc.)
2576
- */
2577
- deduplicateVulnerabilities(vulns) {
2578
- const byKey = /* @__PURE__ */ new Map();
2579
- for (const vuln of vulns) {
2580
- const key = `${vuln.id}::${vuln.packageName}`;
2581
- const existing = byKey.get(key);
2582
- if (!existing) {
2583
- byKey.set(key, vuln);
2584
- } else {
2585
- byKey.set(key, this.pickBetterEntry(existing, vuln));
2586
- }
2587
- }
2588
- return Array.from(byKey.values());
2589
- }
2590
- /** Picks the vulnerability entry with more complete data */
2591
- pickBetterEntry(a, b) {
2592
- let scoreA = 0;
2593
- let scoreB = 0;
2594
- if (a.cvssScore !== void 0) scoreA++;
2595
- if (b.cvssScore !== void 0) scoreB++;
2596
- if (a.fixedVersion) scoreA++;
2597
- if (b.fixedVersion) scoreB++;
2598
- if (a.affectedVersionRange) scoreA++;
2599
- if (b.affectedVersionRange) scoreB++;
2600
- if (a.severity !== "UNKNOWN") scoreA++;
2601
- if (b.severity !== "UNKNOWN") scoreB++;
2602
- const strip = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0 && v !== null));
2603
- const winner = scoreB > scoreA ? { ...strip(a), ...strip(b) } : { ...strip(b), ...strip(a) };
2604
- const allAliases = /* @__PURE__ */ new Set([...a.aliases, ...b.aliases]);
2605
- winner.aliases = Array.from(allAliases);
2606
- winner.exploitedInWild = a.exploitedInWild || b.exploitedInWild;
2607
- return winner;
2608
- }
2609
- };
2610
-
2611
- // src/reporters/console.ts
2612
- var ConsoleReporter = class {
2613
- name = "console";
2614
- report(result) {
2615
- const lines = [];
2616
- lines.push("");
2617
- lines.push("\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
2618
- lines.push("\u2502 VERIMU CRA COMPLIANCE SCAN \u2502");
2619
- lines.push("\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518");
2620
- lines.push("");
2621
- lines.push(` Project: ${result.project.path}`);
2622
- lines.push(` Ecosystem: ${result.project.ecosystem}`);
2623
- lines.push(` Dependencies: ${result.project.dependencyCount}`);
2624
- lines.push(` Scanned at: ${result.generatedAt}`);
2625
- lines.push("");
2626
- lines.push(` \u2713 SBOM generated (${result.sbom.format}, ${result.sbom.specVersion})`);
2627
- lines.push(` Components: ${result.sbom.componentCount}`);
2628
- if (result.artifacts) {
2629
- lines.push(` Also wrote: ${result.artifacts.spdx.format}, ${result.artifacts.swid.format}`);
2630
- }
2631
- lines.push("");
2632
- const vulns = result.cveCheck.vulnerabilities;
2633
- if (vulns.length === 0) {
2634
- lines.push(" \u2713 No known vulnerabilities found");
2635
- } else {
2636
- lines.push(` \u26A0 ${vulns.length} vulnerabilit${vulns.length === 1 ? "y" : "ies"} found:`);
2637
- lines.push("");
2638
- const sorted = [...vulns].sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity));
2639
- for (const vuln of sorted) {
2640
- const badge = severityBadge(vuln.severity);
2641
- const fix = vuln.fixedVersion ? ` \u2192 fix: ${vuln.fixedVersion}` : "";
2642
- lines.push(` ${badge} ${vuln.id}`);
2643
- lines.push(` ${vuln.packageName}@${vuln.affectedVersionRange ?? "?"}${fix}`);
2644
- lines.push(` ${vuln.summary.slice(0, 100)}`);
2645
- if (vuln.exploitedInWild) {
2646
- lines.push(` \u{1F534} ACTIVELY EXPLOITED \u2014 24h CRA reporting required`);
2647
- }
2648
- lines.push("");
2649
- }
2650
- }
2651
- const sources = result.cveCheck.sourcesQueried.join(", ");
2652
- lines.push(` Sources queried: ${sources} (${result.cveCheck.checkDurationMs}ms)`);
2653
- if (result.cveCheck.sourceErrors.length > 0) {
2654
- for (const err of result.cveCheck.sourceErrors) {
2655
- lines.push(` \u26A0 ${err.source}: ${err.error}`);
2656
- }
2657
- }
2658
- lines.push("");
2659
- lines.push(" \u2500\u2500\u2500 Summary \u2500\u2500\u2500");
2660
- lines.push(` Total: ${result.summary.totalVulnerabilities} | Critical: ${result.summary.critical} | High: ${result.summary.high} | Medium: ${result.summary.medium} | Low: ${result.summary.low}`);
2661
- if (result.summary.exploitedInWild > 0) {
2662
- lines.push(` \u{1F534} ${result.summary.exploitedInWild} actively exploited \u2014 immediate action required`);
2663
- }
2664
- lines.push("");
2665
- return lines.join("\n");
2666
- }
2667
- };
2668
- function severityOrder(s) {
2669
- const order = {
2670
- CRITICAL: 0,
2671
- HIGH: 1,
2672
- MEDIUM: 2,
2673
- LOW: 3,
2674
- UNKNOWN: 4
2675
- };
2676
- return order[s] ?? 5;
2677
- }
2678
- function severityBadge(s) {
2679
- const badges = {
2680
- CRITICAL: "[CRIT]",
2681
- HIGH: "[HIGH]",
2682
- MEDIUM: "[MED] ",
2683
- LOW: "[LOW] ",
2684
- UNKNOWN: "[???] "
2685
- };
2686
- return badges[s] ?? "[???] ";
2687
- }
2688
-
2689
- // src/api/client.ts
2690
- var DEFAULT_API_BASE = "https://api.verimu.com";
2691
- var VerimuApiClient = class {
2692
- baseUrl;
2693
- apiKey;
2694
- constructor(apiKey, baseUrl) {
2695
- this.apiKey = apiKey;
2696
- this.baseUrl = (baseUrl ?? DEFAULT_API_BASE).replace(/\/+$/, "");
2697
- }
2698
- /**
2699
- * Upsert a project — finds by name or creates it.
2700
- * Used so `npx verimu` auto-registers projects without manual dashboard setup.
2701
- */
2702
- async upsertProject(opts) {
2703
- const res = await fetch(`${this.baseUrl}/api/projects/upsert`, {
2704
- method: "POST",
2705
- headers: this.headers(),
2706
- body: JSON.stringify({
2707
- name: opts.name,
2708
- ecosystem: this.mapEcosystem(opts.ecosystem),
2709
- repository_url: opts.repositoryUrl ?? null,
2710
- platform: opts.platform ?? null
2711
- })
2712
- });
2713
- if (!res.ok) {
2714
- const body = await res.text();
2715
- throw new Error(`Verimu API: upsert project failed (${res.status}): ${body}`);
2716
- }
2717
- return res.json();
2718
- }
2719
- /**
2720
- * Upload a software inventory artifact payload to a project and trigger CVE scanning.
2721
- *
2722
- * Backward-compatible:
2723
- * - string payloads are treated as legacy raw CycloneDX JSON
2724
- * - object payloads can include CycloneDX + SPDX + SWID together
2725
- */
2726
- async uploadSbom(projectId, payload) {
2727
- const body = typeof payload === "string" ? JSON.stringify(JSON.parse(payload)) : JSON.stringify(payload);
2728
- const res = await fetch(`${this.baseUrl}/api/projects/${projectId}/scan`, {
2729
- method: "POST",
2730
- headers: this.headers(),
2731
- body
2732
- });
2733
- if (!res.ok) {
2734
- const body2 = await res.text();
2735
- throw new Error(`Verimu API: upload SBOM failed (${res.status}): ${body2}`);
2736
- }
2737
- return res.json();
2738
- }
2739
- headers() {
2740
- return {
2741
- "Content-Type": "application/json",
2742
- "X-API-Key": this.apiKey
2743
- };
2744
- }
2745
- /**
2746
- * Maps internal ecosystem names to what the backend expects.
2747
- * Currently 1:1, but keeps the mapping explicit.
2748
- */
2749
- mapEcosystem(eco) {
2750
- const map = {
2751
- npm: "npm",
2752
- pip: "pip",
2753
- poetry: "poetry",
2754
- uv: "uv",
2755
- maven: "maven",
2756
- nuget: "nuget",
2757
- go: "gomod",
2758
- cargo: "cargo",
2759
- ruby: "bundler",
2760
- composer: "composer",
2761
- deno: "deno"
2762
- };
2763
- return map[eco] ?? eco;
2764
- }
2765
- };
2766
-
2767
- // src/scan.ts
2768
- async function scan(config) {
2769
- const {
2770
- projectPath,
2771
- sbomOutput = "./sbom.cdx.json",
2772
- skipCveCheck = false,
2773
- cyclonedxVersion = "1.7"
2774
- } = config;
2775
- const registry = new ScannerRegistry();
2776
- const scanResult = await registry.detectAndScan(projectPath);
2777
- const artifacts = generateSbomArtifacts(scanResult, void 0, cyclonedxVersion);
2778
- const sbom = artifacts.cyclonedx;
2779
- const outputPaths = deriveArtifactOutputPaths(sbomOutput);
2780
- await Promise.all([
2781
- writeFile(outputPaths.cyclonedx, artifacts.cyclonedx.content, "utf-8"),
2782
- writeFile(outputPaths.spdx, artifacts.spdx.content, "utf-8"),
2783
- writeFile(outputPaths.swid, artifacts.swid.content, "utf-8")
2784
- ]);
2785
- let cveCheck;
2786
- if (skipCveCheck) {
2787
- cveCheck = {
2788
- vulnerabilities: [],
2789
- sourcesQueried: [],
2790
- sourceErrors: [],
2791
- checkDurationMs: 0
2792
- };
2793
- } else {
2794
- const aggregator = new CveAggregator();
2795
- cveCheck = await aggregator.check(scanResult.dependencies);
2796
- }
2797
- const summary = {
2798
- totalDependencies: scanResult.dependencies.length,
2799
- totalVulnerabilities: cveCheck.vulnerabilities.length,
2800
- critical: cveCheck.vulnerabilities.filter((v) => v.severity === "CRITICAL").length,
2801
- high: cveCheck.vulnerabilities.filter((v) => v.severity === "HIGH").length,
2802
- medium: cveCheck.vulnerabilities.filter((v) => v.severity === "MEDIUM").length,
2803
- low: cveCheck.vulnerabilities.filter((v) => v.severity === "LOW").length,
2804
- exploitedInWild: cveCheck.vulnerabilities.filter((v) => v.exploitedInWild).length
2805
- };
2806
- const report = {
2807
- project: {
2808
- path: projectPath,
2809
- ecosystem: scanResult.ecosystem,
2810
- dependencyCount: scanResult.dependencies.length
2811
- },
2812
- sbom,
2813
- artifacts,
2814
- cveCheck,
2815
- summary,
2816
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
2817
- };
2818
- if (config.apiKey) {
2819
- try {
2820
- const uploadResult = await uploadToVerimu(report, config);
2821
- report.upload = uploadResult;
2822
- } catch {
2823
- }
2824
- }
2825
- return report;
2826
- }
2827
- async function uploadToVerimu(report, config) {
2828
- if (!config.apiKey) {
2829
- throw new Error("API key required for upload");
2830
- }
2831
- const client = new VerimuApiClient(config.apiKey, config.apiBaseUrl);
2832
- const projectName = basename(config.projectPath);
2833
- const upsertRes = await client.upsertProject({
2834
- name: projectName,
2835
- ecosystem: report.project.ecosystem
2836
- });
2837
- const projectId = upsertRes.project.id;
2838
- const scanRes = await client.uploadSbom(projectId, buildUploadPayload(report));
2839
- return {
2840
- projectId,
2841
- projectCreated: upsertRes.created,
2842
- totalDependencies: scanRes.summary.total_dependencies,
2843
- vulnerableDependencies: scanRes.summary.vulnerable_dependencies,
2844
- dashboardUrl: `https://app.verimu.com/dashboard/projects/${projectId}`,
2845
- scanResponse: scanRes
2846
- };
2847
- }
2848
- function shouldFailCi(report, threshold) {
2849
- const severityOrder3 = {
2850
- CRITICAL: 0,
2851
- HIGH: 1,
2852
- MEDIUM: 2,
2853
- LOW: 3,
2854
- UNKNOWN: 4
2855
- };
2856
- const thresholdLevel = severityOrder3[threshold] ?? 4;
2857
- return report.cveCheck.vulnerabilities.some(
2858
- (v) => severityOrder3[v.severity] <= thresholdLevel
2859
- );
2860
- }
2861
- function deriveArtifactOutputPaths(cycloneDxOutput) {
2862
- const parsed = parse(cycloneDxOutput);
2863
- let baseName = parsed.name;
2864
- if (parsed.ext === ".json" && baseName.endsWith(".cdx")) {
2865
- baseName = baseName.slice(0, -4);
2866
- }
2867
- return {
2868
- cyclonedx: cycloneDxOutput,
2869
- spdx: join(parsed.dir, `${baseName}.spdx.json`),
2870
- swid: join(parsed.dir, `${baseName}.swid.xml`)
2871
- };
2872
- }
2873
- function buildUploadPayload(report) {
2874
- if (!report.artifacts) {
2875
- return report.sbom.content;
2876
- }
2877
- return {
2878
- cyclonedx: JSON.parse(report.artifacts.cyclonedx.content),
2879
- spdx: JSON.parse(report.artifacts.spdx.content),
2880
- swid: report.artifacts.swid.content
2881
- };
2882
- }
2883
-
2884
- // src/reporters/platform.ts
2885
- function renderPlatformScan(projectPath, result) {
2886
- const lines = [];
2887
- const vulns = collectVulnerabilities(result);
2888
- const summary = summarizeBySeverity(vulns.map((vuln) => vuln.severity));
2889
- lines.push("");
2890
- lines.push("\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
2891
- lines.push("\u2502 VERIMU PLATFORM SCAN RESULTS \u2502");
2892
- lines.push("\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518");
2893
- lines.push("");
2894
- lines.push(` Project: ${projectPath}`);
2895
- lines.push(" Source: Verimu platform backend");
2896
- lines.push(` Dependencies: ${result.totalDependencies}`);
2897
- lines.push("");
2898
- if (vulns.length === 0) {
2899
- lines.push(" \u2713 No platform vulnerabilities found");
2900
- } else {
2901
- lines.push(` \u26A0 ${vulns.length} backend vulnerabilit${vulns.length === 1 ? "y" : "ies"} found:`);
2902
- lines.push("");
2903
- const sorted = [...vulns].sort((a, b) => severityOrder2(a.severity) - severityOrder2(b.severity));
2904
- for (const vuln of sorted) {
2905
- const fix = vuln.fixedVersion ? ` \u2192 fix: ${vuln.fixedVersion}` : "";
2906
- lines.push(` ${severityBadge2(vuln.severity)} ${vuln.cveId}`);
2907
- lines.push(` ${vuln.dependencyName}@${vuln.version}${fix}`);
2908
- lines.push(` ${(vuln.summary ?? "").slice(0, 100)}`);
2909
- lines.push("");
2910
- }
2911
- }
2912
- lines.push(" \u2500\u2500\u2500 Summary \u2500\u2500\u2500");
2913
- lines.push(` Total: ${vulns.length} | Critical: ${summary.CRITICAL} | High: ${summary.HIGH} | Medium: ${summary.MEDIUM} | Low: ${summary.LOW}`);
2914
- lines.push("");
2915
- return lines.join("\n");
2916
- }
2917
- function collectVulnerabilities(result) {
2918
- return (result.scanResponse.scan_results ?? []).flatMap(
2919
- (scanResult) => (scanResult.vulnerabilities ?? []).map((vuln) => ({
2920
- dependencyName: scanResult.dependency_name,
2921
- version: scanResult.version,
2922
- cveId: vuln.cve_id,
2923
- severity: normalizeSeverity(vuln.severity ?? "UNKNOWN"),
2924
- summary: pickSummary(vuln),
2925
- fixedVersion: pickFixedVersion(vuln)
2926
- }))
2927
- );
2928
- }
2929
- function pickSummary(vuln) {
2930
- const value = vuln.summary ?? vuln.description;
2931
- if (typeof value !== "string" || value.trim() === "") {
2932
- return "No description available";
2933
- }
2934
- return value;
2935
- }
2936
- function pickFixedVersion(vuln) {
2937
- if (typeof vuln.fixed_version === "string" && vuln.fixed_version.trim() !== "") {
2938
- return vuln.fixed_version;
2939
- }
2940
- for (const source of vuln.sources ?? []) {
2941
- const fixedVersion = source.data?.fixed_version;
2942
- if (typeof fixedVersion === "string" && fixedVersion.trim() !== "") {
2943
- return fixedVersion;
2944
- }
2945
- }
2946
- return null;
2947
- }
2948
- function normalizeSeverity(severity) {
2949
- const value = severity.trim().toUpperCase();
2950
- switch (value) {
2951
- case "CRITICAL":
2952
- case "HIGH":
2953
- case "MEDIUM":
2954
- case "LOW":
2955
- return value;
2956
- default:
2957
- return "UNKNOWN";
2958
- }
2959
- }
2960
- function summarizeBySeverity(severities) {
2961
- const summary = {
2962
- CRITICAL: 0,
2963
- HIGH: 0,
2964
- MEDIUM: 0,
2965
- LOW: 0,
2966
- UNKNOWN: 0
2967
- };
2968
- for (const severity of severities) {
2969
- summary[severity] += 1;
2970
- }
2971
- return summary;
2972
- }
2973
- function severityOrder2(severity) {
2974
- const order = {
2975
- CRITICAL: 0,
2976
- HIGH: 1,
2977
- MEDIUM: 2,
2978
- LOW: 3,
2979
- UNKNOWN: 4
2980
- };
2981
- return order[severity] ?? 5;
2982
- }
2983
- function severityBadge2(severity) {
2984
- const badges = {
2985
- CRITICAL: "[CRIT]",
2986
- HIGH: "[HIGH]",
2987
- MEDIUM: "[MED] ",
2988
- LOW: "[LOW] ",
2989
- UNKNOWN: "[???] "
2990
- };
2991
- return badges[severity] ?? "[???] ";
2992
- }
2993
-
2994
- // src/cli.ts
2995
- var require2 = createRequire(import.meta.url);
2996
- var pkg = require2("../package.json");
2997
- var VERSION = pkg.version ?? "0.0.0";
2998
- var BRAND = `
2999
- \u2566 \u2566\u250C\u2500\u2510\u252C\u2500\u2510\u252C\u250C\u252C\u2510\u252C \u252C
3000
- \u255A\u2557\u2554\u255D\u251C\u2524 \u251C\u252C\u2518\u2502\u2502\u2502\u2502\u2502 \u2502
3001
- \u255A\u255D \u2514\u2500\u2518\u2534\u2514\u2500\u2534\u2534 \u2534\u2514\u2500\u2518
3002
- CRA Compliance Scanner v${VERSION}
3003
- `;
3004
- function log(msg) {
3005
- console.log(` ${msg}`);
3006
- }
3007
- function logSuccess(msg) {
3008
- console.log(` \u2713 ${msg}`);
3009
- }
3010
- function logWarn(msg) {
3011
- console.log(` \u26A0 ${msg}`);
3012
- }
3013
- function logError(msg) {
3014
- console.error(` \u2717 ${msg}`);
3015
- }
3016
- function parseArgs(argv) {
3017
- const args = argv.slice(2);
3018
- const result = {
3019
- command: "scan",
3020
- projectPath: ".",
3021
- sbomOutput: "./sbom.cdx.json",
3022
- failOnSeverity: null,
3023
- skipCveCheck: false,
3024
- skipUpload: false,
3025
- cyclonedxVersion: "1.7"
3026
- };
3027
- let i = 0;
3028
- while (i < args.length) {
3029
- const arg = args[i];
3030
- if (arg === "scan") {
3031
- result.command = "scan";
3032
- } else if (arg === "generate-sbom" || arg === "sbom") {
3033
- result.command = "generate-sbom";
3034
- result.skipCveCheck = true;
3035
- } else if (arg === "help" || arg === "--help" || arg === "-h") {
3036
- result.command = "help";
3037
- } else if (arg === "version" || arg === "--version" || arg === "-v") {
3038
- result.command = "version";
3039
- } else if (arg === "--path" || arg === "-p") {
3040
- result.projectPath = args[++i] ?? ".";
3041
- } else if (arg === "--output" || arg === "-o") {
3042
- result.sbomOutput = args[++i] ?? "./sbom.cdx.json";
3043
- } else if (arg === "--fail-on") {
3044
- const val = (args[++i] ?? "").toUpperCase();
3045
- if (["CRITICAL", "HIGH", "MEDIUM", "LOW"].includes(val)) {
3046
- result.failOnSeverity = val;
3047
- }
3048
- } else if (arg === "--skip-cve") {
3049
- result.skipCveCheck = true;
3050
- } else if (arg === "--skip-upload" || arg === "--offline") {
3051
- result.skipUpload = true;
3052
- } else if (arg === "--cdx-version" || arg.startsWith("--cdx-version=")) {
3053
- const val = arg === "--cdx-version" ? args[++i] : arg.split("=")[1];
3054
- if (!val || val.startsWith("--")) {
3055
- throw new Error("--cdx-version requires a value");
3056
- }
3057
- if (!["1.4", "1.5", "1.6", "1.7"].includes(val)) {
3058
- throw new Error(`Invalid CycloneDX version: ${val}`);
3059
- }
3060
- result.cyclonedxVersion = val;
3061
- }
3062
- i++;
3063
- }
3064
- return result;
3065
- }
3066
- async function main() {
3067
- let args;
3068
- try {
3069
- args = parseArgs(process.argv);
3070
- } catch (err) {
3071
- const msg = err instanceof Error ? err.message : String(err);
3072
- logError(msg);
3073
- log("Run 'npx verimu --help' for usage information");
3074
- process.exit(2);
3075
- }
3076
- if (args.command === "version") {
3077
- console.log(`verimu ${VERSION}`);
3078
- return;
3079
- }
3080
- if (args.command === "help") {
3081
- printHelp();
3082
- return;
3083
- }
3084
- console.log(BRAND);
3085
- const apiKey = process.env.VERIMU_API_KEY;
3086
- const apiBaseUrl = process.env.VERIMU_API_URL;
3087
- log(`Scanning ${resolve(args.projectPath)}...`);
3088
- if (apiKey && !args.skipUpload) {
3089
- log("API key detected \u2014 results will sync to Verimu platform");
3090
- } else if (!apiKey) {
3091
- log("No VERIMU_API_KEY set \u2014 running in offline mode");
3092
- log("Get your API key at https://app.verimu.com/dashboard/api-keys");
3093
- }
3094
- console.log("");
3095
- const config = {
3096
- projectPath: resolve(args.projectPath),
3097
- sbomOutput: args.sbomOutput,
3098
- skipCveCheck: args.skipCveCheck,
3099
- cyclonedxVersion: args.cyclonedxVersion,
3100
- // Don't pass apiKey to scan() if --skip-upload — we'll handle upload separately for better logging
3101
- apiKey: apiKey && !args.skipUpload ? void 0 : void 0,
3102
- apiBaseUrl
3103
- };
3104
- let report;
3105
- try {
3106
- report = await scan(config);
3107
- } catch (err) {
3108
- const msg = err instanceof Error ? err.message : String(err);
3109
- logError(msg);
3110
- process.exit(2);
3111
- }
3112
- const reporter = new ConsoleReporter();
3113
- console.log(reporter.report(report));
3114
- if (apiKey && !args.skipUpload) {
3115
- console.log("");
3116
- log("Syncing to Verimu platform...");
3117
- try {
3118
- const uploadConfig = {
3119
- ...config,
3120
- apiKey,
3121
- apiBaseUrl
3122
- };
3123
- const result = await uploadToVerimu(report, uploadConfig);
3124
- if (result.projectCreated) {
3125
- logSuccess(`Project created: ${report.project.path}`);
3126
- }
3127
- logSuccess(`${result.totalDependencies} dependencies tracked`);
3128
- console.log(renderPlatformScan(report.project.path, result));
3129
- logSuccess(`Dashboard: ${result.dashboardUrl}`);
3130
- } catch (err) {
3131
- const msg = err instanceof Error ? err.message : String(err);
3132
- logWarn(`Platform sync failed: ${msg}`);
3133
- log("Your SBOM was still generated locally. You can upload it manually.");
3134
- }
3135
- }
3136
- console.log("");
3137
- log("Thanks for using Verimu \u2014 helping your team with CRA readiness");
3138
- console.log("");
3139
- if (args.failOnSeverity && shouldFailCi(report, args.failOnSeverity)) {
3140
- logError(`Vulnerabilities found at or above ${args.failOnSeverity} severity`);
3141
- process.exit(1);
3142
- }
3143
- }
3144
- function printHelp() {
3145
- console.log(`
3146
- Verimu \u2014 CRA Compliance Scanner
3147
-
3148
- Usage:
3149
- verimu Scan current directory
3150
- verimu scan [options] Full scan (SBOM + CVE check)
3151
- verimu generate-sbom [options] Generate SBOM only (no CVE check)
3152
- verimu help Show this help
3153
- verimu version Show version
3154
-
3155
- Options:
3156
- --path, -p <dir> Project directory to scan (default: .)
3157
- --output, -o <file> CycloneDX output path (SPDX/SWID are written alongside it)
3158
- --fail-on <severity> Exit 1 if vulns at or above: CRITICAL, HIGH, MEDIUM, LOW
3159
- --skip-cve Skip CVE vulnerability checking
3160
- --skip-upload Don't sync to Verimu platform (even if API key is set)
3161
- --cdx-version <ver> CycloneDX spec: 1.4, 1.5, 1.6, 1.7 (default: 1.7)
3162
-
3163
- Environment:
3164
- VERIMU_API_KEY API key for Verimu platform (from app.verimu.com)
3165
- VERIMU_API_URL Custom API URL (default: https://api.verimu.com)
3166
-
3167
- Examples:
3168
- npx verimu # Quick scan
3169
- VERIMU_API_KEY=vmu_xxx npx verimu # Scan + sync to platform
3170
- npx verimu scan --fail-on HIGH # Fail CI on HIGH+ vulns
3171
- npx verimu scan --cdx-version 1.5 # Specify CycloneDX version
3172
- npx verimu scan --path ./backend --output ./reports/sbom.json
3173
-
3174
- Supported ecosystems:
3175
- npm (package-lock.json) pip (requirements.txt)
3176
- Maven (pom.xml) NuGet (packages.lock.json)
3177
- Cargo (Cargo.lock) Go (go.sum)
3178
- Ruby (Gemfile.lock) Composer (composer.lock)
3179
-
3180
- Learn more: https://verimu.com
3181
- Dashboard: https://app.verimu.com
3182
- `);
3183
- }
3184
- main().catch((err) => {
3185
- console.error("Fatal:", err);
3186
- process.exit(2);
3187
- });
3188
- //# sourceMappingURL=cli.js.map