verimu 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,820 @@
1
+ // src/generate-sbom.ts
2
+ import { randomUUID } from "crypto";
3
+ function generateSbom(input) {
4
+ const {
5
+ projectName,
6
+ projectVersion = "0.0.0",
7
+ dependencies
8
+ } = input;
9
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
10
+ const resolvedDeps = dependencies.map((dep) => ({
11
+ ...dep,
12
+ direct: dep.direct ?? true,
13
+ purl: dep.purl ?? buildPurl(dep.name, dep.version, dep.ecosystem)
14
+ }));
15
+ const rootPurl = buildPurl(projectName, projectVersion, "npm");
16
+ const sbom = {
17
+ $schema: "http://cyclonedx.org/schema/bom-1.7.schema.json",
18
+ bomFormat: "CycloneDX",
19
+ specVersion: "1.7",
20
+ serialNumber: `urn:uuid:${randomUUID()}`,
21
+ version: 1,
22
+ metadata: {
23
+ timestamp,
24
+ tools: {
25
+ components: [
26
+ {
27
+ type: "application",
28
+ name: "verimu",
29
+ version: "0.0.1",
30
+ description: "Verimu CRA Compliance Scanner",
31
+ supplier: { name: "Verimu" },
32
+ externalReferences: [
33
+ { type: "website", url: "https://verimu.eu" }
34
+ ]
35
+ }
36
+ ]
37
+ },
38
+ supplier: { name: projectName },
39
+ component: {
40
+ type: "application",
41
+ name: projectName,
42
+ version: projectVersion,
43
+ "bom-ref": rootPurl,
44
+ supplier: { name: projectName }
45
+ }
46
+ },
47
+ components: resolvedDeps.map((dep) => ({
48
+ type: "library",
49
+ name: dep.name,
50
+ version: dep.version,
51
+ purl: dep.purl,
52
+ "bom-ref": dep.purl,
53
+ scope: dep.direct ? "required" : "optional",
54
+ supplier: { name: deriveSupplierName(dep.name) }
55
+ })),
56
+ dependencies: [
57
+ {
58
+ ref: rootPurl,
59
+ dependsOn: resolvedDeps.map((d) => d.purl)
60
+ }
61
+ ]
62
+ };
63
+ const content = JSON.stringify(sbom, null, 2);
64
+ return {
65
+ sbom,
66
+ content,
67
+ componentCount: resolvedDeps.length,
68
+ specVersion: "1.7",
69
+ generatedAt: timestamp
70
+ };
71
+ }
72
+ var PURL_TYPE_MAP = {
73
+ npm: "npm",
74
+ nuget: "nuget",
75
+ cargo: "cargo",
76
+ maven: "maven",
77
+ pip: "pypi",
78
+ go: "golang"
79
+ };
80
+ function buildPurl(name, version, ecosystem) {
81
+ const type = PURL_TYPE_MAP[ecosystem] || ecosystem;
82
+ if (ecosystem === "npm" && name.startsWith("@")) {
83
+ return `pkg:${type}/%40${name.slice(1)}@${version}`;
84
+ }
85
+ return `pkg:${type}/${name}@${version}`;
86
+ }
87
+ function deriveSupplierName(packageName) {
88
+ if (packageName.startsWith("@")) {
89
+ return packageName.split("/")[0];
90
+ }
91
+ return packageName;
92
+ }
93
+
94
+ // src/scan.ts
95
+ import { writeFile } from "fs/promises";
96
+
97
+ // src/scanners/npm/npm-scanner.ts
98
+ import { readFile } from "fs/promises";
99
+ import { existsSync } from "fs";
100
+ import path from "path";
101
+
102
+ // src/core/errors.ts
103
+ var VerimuError = class extends Error {
104
+ constructor(message, code) {
105
+ super(message);
106
+ this.code = code;
107
+ this.name = "VerimuError";
108
+ }
109
+ };
110
+ var NoLockfileError = class extends VerimuError {
111
+ constructor(projectPath) {
112
+ super(
113
+ `No supported lockfile found in ${projectPath}. Supported: package-lock.json (npm), packages.lock.json (NuGet), Cargo.lock (Rust)`,
114
+ "NO_LOCKFILE"
115
+ );
116
+ this.name = "NoLockfileError";
117
+ }
118
+ };
119
+ var LockfileParseError = class extends VerimuError {
120
+ constructor(lockfilePath, reason) {
121
+ super(`Failed to parse ${lockfilePath}: ${reason}`, "LOCKFILE_PARSE_ERROR");
122
+ this.name = "LockfileParseError";
123
+ }
124
+ };
125
+ var CveSourceError = class extends VerimuError {
126
+ constructor(source, reason) {
127
+ super(`CVE source "${source}" failed: ${reason}`, "CVE_SOURCE_ERROR");
128
+ this.name = "CveSourceError";
129
+ }
130
+ };
131
+ var ApiKeyRequiredError = class extends VerimuError {
132
+ constructor(feature) {
133
+ super(
134
+ `API key required for "${feature}". Get one at https://verimu.com/dashboard`,
135
+ "API_KEY_REQUIRED"
136
+ );
137
+ this.name = "ApiKeyRequiredError";
138
+ }
139
+ };
140
+
141
+ // src/scanners/npm/npm-scanner.ts
142
+ var NpmScanner = class {
143
+ ecosystem = "npm";
144
+ lockfileNames = ["package-lock.json"];
145
+ async detect(projectPath) {
146
+ const lockfilePath = path.join(projectPath, "package-lock.json");
147
+ return existsSync(lockfilePath) ? lockfilePath : null;
148
+ }
149
+ async scan(projectPath, lockfilePath) {
150
+ const [lockfileRaw, packageJsonRaw] = await Promise.all([
151
+ readFile(lockfilePath, "utf-8"),
152
+ readFile(path.join(projectPath, "package.json"), "utf-8").catch(() => null)
153
+ ]);
154
+ let lockfile;
155
+ try {
156
+ lockfile = JSON.parse(lockfileRaw);
157
+ } catch {
158
+ throw new LockfileParseError(lockfilePath, "Invalid JSON");
159
+ }
160
+ const directNames = /* @__PURE__ */ new Set();
161
+ if (packageJsonRaw) {
162
+ try {
163
+ const pkg = JSON.parse(packageJsonRaw);
164
+ for (const name of Object.keys(pkg.dependencies ?? {})) {
165
+ directNames.add(name);
166
+ }
167
+ for (const name of Object.keys(pkg.devDependencies ?? {})) {
168
+ directNames.add(name);
169
+ }
170
+ } catch {
171
+ }
172
+ }
173
+ const dependencies = this.parseLockfile(lockfile, directNames);
174
+ return {
175
+ projectPath,
176
+ ecosystem: "npm",
177
+ dependencies,
178
+ lockfilePath,
179
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString()
180
+ };
181
+ }
182
+ /**
183
+ * Parses package-lock.json and extracts dependencies.
184
+ * Supports lockfile v2 and v3 (uses the `packages` field).
185
+ * Falls back to `dependencies` field for lockfile v1.
186
+ */
187
+ parseLockfile(lockfile, directNames) {
188
+ const deps = [];
189
+ if (lockfile.packages) {
190
+ for (const [pkgPath, pkgInfo] of Object.entries(lockfile.packages)) {
191
+ if (pkgPath === "") continue;
192
+ const name = this.extractPackageName(pkgPath);
193
+ if (!name || !pkgInfo.version) continue;
194
+ if (pkgInfo.link) continue;
195
+ deps.push({
196
+ name,
197
+ version: pkgInfo.version,
198
+ direct: directNames.has(name),
199
+ ecosystem: "npm",
200
+ purl: this.buildPurl(name, pkgInfo.version)
201
+ });
202
+ }
203
+ } else if (lockfile.dependencies) {
204
+ this.parseDependenciesV1(lockfile.dependencies, directNames, deps);
205
+ }
206
+ return deps;
207
+ }
208
+ /**
209
+ * Builds a purl (Package URL) for an npm package.
210
+ *
211
+ * Per the purl spec (https://github.com/package-url/purl-spec/blob/main/types-doc/npm-definition.md):
212
+ * "The npm scope @ sign prefix is always percent encoded."
213
+ *
214
+ * So @types/node@20.11.5 → pkg:npm/%40types/node@20.11.5
215
+ * And express@4.18.2 → pkg:npm/express@4.18.2
216
+ */
217
+ buildPurl(name, version) {
218
+ if (name.startsWith("@")) {
219
+ return `pkg:npm/%40${name.slice(1)}@${version}`;
220
+ }
221
+ return `pkg:npm/${name}@${version}`;
222
+ }
223
+ /** Extracts the package name from a node_modules path */
224
+ extractPackageName(pkgPath) {
225
+ const parts = pkgPath.split("node_modules/");
226
+ const last = parts[parts.length - 1];
227
+ return last || null;
228
+ }
229
+ /** Recursively parses lockfile v1 `dependencies` tree */
230
+ parseDependenciesV1(depsObj, directNames, result) {
231
+ for (const [name, info] of Object.entries(depsObj)) {
232
+ if (info.version) {
233
+ result.push({
234
+ name,
235
+ version: info.version,
236
+ direct: directNames.has(name),
237
+ ecosystem: "npm",
238
+ purl: this.buildPurl(name, info.version)
239
+ });
240
+ }
241
+ if (info.dependencies) {
242
+ this.parseDependenciesV1(info.dependencies, directNames, result);
243
+ }
244
+ }
245
+ }
246
+ };
247
+
248
+ // src/scanners/nuget/nuget-scanner.ts
249
+ var NugetScanner = class {
250
+ ecosystem = "nuget";
251
+ lockfileNames = ["packages.lock.json"];
252
+ async detect(_projectPath) {
253
+ return null;
254
+ }
255
+ async scan(_projectPath, _lockfilePath) {
256
+ throw new Error("NuGet scanner not yet implemented. Coming soon.");
257
+ }
258
+ };
259
+
260
+ // src/scanners/cargo/cargo-scanner.ts
261
+ var CargoScanner = class {
262
+ ecosystem = "cargo";
263
+ lockfileNames = ["Cargo.lock"];
264
+ async detect(_projectPath) {
265
+ return null;
266
+ }
267
+ async scan(_projectPath, _lockfilePath) {
268
+ throw new Error("Cargo scanner not yet implemented. Coming soon.");
269
+ }
270
+ };
271
+
272
+ // src/scanners/registry.ts
273
+ var ScannerRegistry = class {
274
+ scanners;
275
+ constructor() {
276
+ this.scanners = [
277
+ new NpmScanner(),
278
+ new NugetScanner(),
279
+ new CargoScanner()
280
+ // Add new scanners here as they're implemented
281
+ ];
282
+ }
283
+ /**
284
+ * Auto-detects the project's ecosystem and scans dependencies.
285
+ * Tries each registered scanner in order until one matches.
286
+ */
287
+ async detectAndScan(projectPath) {
288
+ for (const scanner of this.scanners) {
289
+ const lockfilePath = await scanner.detect(projectPath);
290
+ if (lockfilePath) {
291
+ return scanner.scan(projectPath, lockfilePath);
292
+ }
293
+ }
294
+ throw new NoLockfileError(projectPath);
295
+ }
296
+ /** Returns a specific scanner by ecosystem name */
297
+ getScanner(ecosystem) {
298
+ return this.scanners.find((s) => s.ecosystem === ecosystem);
299
+ }
300
+ /** Lists all registered ecosystems */
301
+ listEcosystems() {
302
+ return this.scanners.map((s) => s.ecosystem);
303
+ }
304
+ };
305
+
306
+ // src/sbom/cyclonedx.ts
307
+ import { randomUUID as randomUUID2 } from "crypto";
308
+ var CycloneDxGenerator = class {
309
+ format = "cyclonedx-json";
310
+ generate(scanResult, toolVersion = "0.1.0") {
311
+ const bom = this.buildBom(scanResult, toolVersion);
312
+ const content = JSON.stringify(bom, null, 2);
313
+ return {
314
+ format: "cyclonedx-json",
315
+ specVersion: "1.7",
316
+ content,
317
+ componentCount: scanResult.dependencies.length,
318
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
319
+ };
320
+ }
321
+ buildBom(scanResult, toolVersion) {
322
+ const projectName = this.extractProjectName(scanResult.projectPath);
323
+ return {
324
+ $schema: "http://cyclonedx.org/schema/bom-1.7.schema.json",
325
+ bomFormat: "CycloneDX",
326
+ specVersion: "1.7",
327
+ serialNumber: `urn:uuid:${randomUUID2()}`,
328
+ version: 1,
329
+ metadata: {
330
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
331
+ tools: {
332
+ components: [
333
+ {
334
+ type: "application",
335
+ name: "verimu",
336
+ version: toolVersion,
337
+ description: "Verimu CRA Compliance Scanner",
338
+ supplier: { name: "Verimu" },
339
+ externalReferences: [
340
+ {
341
+ type: "website",
342
+ url: "https://verimu.com"
343
+ }
344
+ ]
345
+ }
346
+ ]
347
+ },
348
+ // NTIA: metadata.supplier — the org supplying the root software
349
+ supplier: {
350
+ name: projectName
351
+ },
352
+ component: {
353
+ type: "application",
354
+ name: projectName,
355
+ "bom-ref": "root-component",
356
+ supplier: { name: projectName }
357
+ }
358
+ },
359
+ components: scanResult.dependencies.map((dep) => this.toComponent(dep)),
360
+ dependencies: this.buildDependencyGraph(scanResult)
361
+ };
362
+ }
363
+ /** Converts a Verimu Dependency to a CycloneDX component */
364
+ toComponent(dep) {
365
+ return {
366
+ type: "library",
367
+ name: dep.name,
368
+ version: dep.version,
369
+ purl: dep.purl,
370
+ "bom-ref": dep.purl,
371
+ scope: dep.direct ? "required" : "optional",
372
+ // NTIA: component.supplier — derived from npm scope or package name
373
+ supplier: {
374
+ name: this.deriveSupplierName(dep.name)
375
+ }
376
+ };
377
+ }
378
+ /**
379
+ * Derives a supplier name from a package name.
380
+ *
381
+ * For scoped packages like "@vue/reactivity" → "@vue"
382
+ * For unscoped packages like "express" → "express"
383
+ *
384
+ * This is the same heuristic used by Syft, Trivy, and other SBOM tools
385
+ * when registry metadata (author/publisher) isn't available from the lockfile.
386
+ */
387
+ deriveSupplierName(packageName) {
388
+ if (packageName.startsWith("@")) {
389
+ const scope = packageName.split("/")[0];
390
+ return scope;
391
+ }
392
+ return packageName;
393
+ }
394
+ /**
395
+ * Builds the dependency graph section of the SBOM.
396
+ *
397
+ * The root component depends on all dependencies (direct + transitive).
398
+ * This ensures a single root node in the graph, which NTIA validators expect.
399
+ *
400
+ * We include ALL deps under root (not just direct) because from a flat lockfile
401
+ * we can't reliably reconstruct which transitive dep belongs to which direct dep.
402
+ * This is still valid per the CycloneDX spec — it represents a complete but flat
403
+ * dependency relationship.
404
+ */
405
+ buildDependencyGraph(scanResult) {
406
+ const allDepPurls = scanResult.dependencies.map((d) => d.purl);
407
+ return [
408
+ {
409
+ ref: "root-component",
410
+ dependsOn: allDepPurls
411
+ }
412
+ ];
413
+ }
414
+ /** Extracts project name from path */
415
+ extractProjectName(projectPath) {
416
+ const parts = projectPath.replace(/\\/g, "/").split("/");
417
+ return parts[parts.length - 1] || "unknown-project";
418
+ }
419
+ };
420
+
421
+ // src/cve/osv.ts
422
+ var OSV_API_BASE = "https://api.osv.dev/v1";
423
+ var BATCH_SIZE = 1e3;
424
+ var OsvSource = class {
425
+ sourceId = "osv";
426
+ name = "OSV.dev (Google Open Source Vulnerabilities)";
427
+ fetchFn;
428
+ constructor(fetchImpl) {
429
+ this.fetchFn = fetchImpl ?? globalThis.fetch;
430
+ }
431
+ async checkDependencies(dependencies) {
432
+ if (dependencies.length === 0) return [];
433
+ const allVulns = [];
434
+ for (let i = 0; i < dependencies.length; i += BATCH_SIZE) {
435
+ const batch = dependencies.slice(i, i + BATCH_SIZE);
436
+ const batchVulns = await this.queryBatch(batch);
437
+ allVulns.push(...batchVulns);
438
+ }
439
+ return allVulns;
440
+ }
441
+ /** Uses OSV's /querybatch endpoint for efficient bulk lookups */
442
+ async queryBatch(dependencies) {
443
+ const queries = dependencies.map((dep) => ({
444
+ version: dep.version,
445
+ package: {
446
+ name: dep.name,
447
+ ecosystem: this.mapEcosystem(dep.ecosystem)
448
+ }
449
+ }));
450
+ const response = await this.fetchFn(`${OSV_API_BASE}/querybatch`, {
451
+ method: "POST",
452
+ headers: { "Content-Type": "application/json" },
453
+ body: JSON.stringify({ queries })
454
+ });
455
+ if (!response.ok) {
456
+ throw new Error(`OSV API error: ${response.status} ${response.statusText}`);
457
+ }
458
+ const data = await response.json();
459
+ const vulnerabilities = [];
460
+ for (let i = 0; i < data.results.length; i++) {
461
+ const result = data.results[i];
462
+ const dep = dependencies[i];
463
+ if (result.vulns && result.vulns.length > 0) {
464
+ for (const vuln of result.vulns) {
465
+ vulnerabilities.push(this.mapVulnerability(vuln, dep));
466
+ }
467
+ }
468
+ }
469
+ return vulnerabilities;
470
+ }
471
+ /** Maps an OSV vulnerability record to our Vulnerability type */
472
+ mapVulnerability(osvVuln, dep) {
473
+ const cveId = this.extractCveId(osvVuln);
474
+ const severity = this.extractSeverity(osvVuln);
475
+ return {
476
+ id: cveId || osvVuln.id,
477
+ aliases: Array.from(/* @__PURE__ */ new Set([osvVuln.id, ...osvVuln.aliases ?? []])),
478
+ summary: osvVuln.summary ?? osvVuln.details?.slice(0, 200) ?? "No description available",
479
+ severity: severity.level,
480
+ cvssScore: severity.score,
481
+ packageName: dep.name,
482
+ ecosystem: dep.ecosystem,
483
+ affectedVersionRange: this.extractAffectedRange(osvVuln, dep.name),
484
+ fixedVersion: this.extractFixedVersion(osvVuln, dep.name),
485
+ exploitedInWild: false,
486
+ // OSV doesn't track this — CISA KEV does
487
+ source: "osv",
488
+ referenceUrl: `https://osv.dev/vulnerability/${osvVuln.id}`,
489
+ publishedAt: osvVuln.published
490
+ };
491
+ }
492
+ /** Extracts CVE ID from aliases (prefers CVE-xxxx over GHSA-xxxx) */
493
+ extractCveId(vuln) {
494
+ if (vuln.id.startsWith("CVE-")) return vuln.id;
495
+ if (vuln.aliases) {
496
+ const cve = vuln.aliases.find((a) => a.startsWith("CVE-"));
497
+ if (cve) return cve;
498
+ }
499
+ return null;
500
+ }
501
+ /** Extracts severity from CVSS scores in the OSV record */
502
+ extractSeverity(vuln) {
503
+ if (vuln.severity && vuln.severity.length > 0) {
504
+ for (const sev of vuln.severity) {
505
+ if (sev.type === "CVSS_V3") {
506
+ const score = this.parseCvssScore(sev.score);
507
+ if (score !== null) {
508
+ return { level: this.scoreToSeverity(score), score };
509
+ }
510
+ }
511
+ }
512
+ }
513
+ if (vuln.database_specific?.severity) {
514
+ const s = vuln.database_specific.severity.toUpperCase();
515
+ if (["CRITICAL", "HIGH", "MEDIUM", "LOW"].includes(s)) {
516
+ return { level: s };
517
+ }
518
+ }
519
+ return { level: "UNKNOWN" };
520
+ }
521
+ /** Parses CVSS v3 vector string to extract the base score */
522
+ parseCvssScore(vectorOrScore) {
523
+ const num = parseFloat(vectorOrScore);
524
+ if (!isNaN(num) && num >= 0 && num <= 10) return num;
525
+ return null;
526
+ }
527
+ /** Converts a CVSS score (0-10) to a severity level */
528
+ scoreToSeverity(score) {
529
+ if (score >= 9) return "CRITICAL";
530
+ if (score >= 7) return "HIGH";
531
+ if (score >= 4) return "MEDIUM";
532
+ if (score > 0) return "LOW";
533
+ return "UNKNOWN";
534
+ }
535
+ /** Extracts affected version range for a specific package */
536
+ extractAffectedRange(vuln, packageName) {
537
+ if (!vuln.affected) return void 0;
538
+ for (const affected of vuln.affected) {
539
+ if (affected.package?.name === packageName && affected.ranges) {
540
+ for (const range of affected.ranges) {
541
+ if (range.events) {
542
+ const introduced = range.events.find((e) => e.introduced)?.introduced;
543
+ const fixed = range.events.find((e) => e.fixed)?.fixed;
544
+ if (introduced && fixed) return `>=${introduced}, <${fixed}`;
545
+ if (introduced) return `>=${introduced}`;
546
+ }
547
+ }
548
+ }
549
+ }
550
+ return void 0;
551
+ }
552
+ /** Extracts the fixed version for a specific package */
553
+ extractFixedVersion(vuln, packageName) {
554
+ if (!vuln.affected) return void 0;
555
+ for (const affected of vuln.affected) {
556
+ if (affected.package?.name === packageName && affected.ranges) {
557
+ for (const range of affected.ranges) {
558
+ if (range.events) {
559
+ const fixed = range.events.find((e) => e.fixed)?.fixed;
560
+ if (fixed) return fixed;
561
+ }
562
+ }
563
+ }
564
+ }
565
+ return void 0;
566
+ }
567
+ /** Maps our ecosystem names to OSV ecosystem names */
568
+ mapEcosystem(ecosystem) {
569
+ const map = {
570
+ npm: "npm",
571
+ nuget: "NuGet",
572
+ cargo: "crates.io",
573
+ maven: "Maven",
574
+ pip: "PyPI",
575
+ go: "Go"
576
+ };
577
+ return map[ecosystem] ?? ecosystem;
578
+ }
579
+ };
580
+
581
+ // src/cve/aggregator.ts
582
+ var CveAggregator = class {
583
+ sources;
584
+ constructor(sources) {
585
+ this.sources = sources ?? [
586
+ new OsvSource()
587
+ // Future: new NvdSource(), new EuvdSource(), new CisaKevSource()
588
+ ];
589
+ }
590
+ /**
591
+ * Checks dependencies against all registered CVE sources.
592
+ * Runs sources in parallel and merges/deduplicates results.
593
+ */
594
+ async check(dependencies) {
595
+ const startTime = Date.now();
596
+ const sourcesQueried = [];
597
+ const sourceErrors = [];
598
+ const allVulns = [];
599
+ const results = await Promise.allSettled(
600
+ this.sources.map(async (source) => {
601
+ const vulns = await source.checkDependencies(dependencies);
602
+ return { sourceId: source.sourceId, vulns };
603
+ })
604
+ );
605
+ for (const result of results) {
606
+ if (result.status === "fulfilled") {
607
+ sourcesQueried.push(result.value.sourceId);
608
+ allVulns.push(...result.value.vulns);
609
+ } else {
610
+ const sourceIndex = results.indexOf(result);
611
+ const sourceId = this.sources[sourceIndex].sourceId;
612
+ sourceErrors.push({
613
+ source: sourceId,
614
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason)
615
+ });
616
+ }
617
+ }
618
+ const deduplicated = this.deduplicateVulnerabilities(allVulns);
619
+ return {
620
+ vulnerabilities: deduplicated,
621
+ sourcesQueried,
622
+ sourceErrors,
623
+ checkDurationMs: Date.now() - startTime
624
+ };
625
+ }
626
+ /**
627
+ * Deduplicates vulnerabilities by ID.
628
+ * When the same CVE appears from multiple sources,
629
+ * keeps the one with more complete data (has CVSS score, has fix version, etc.)
630
+ */
631
+ deduplicateVulnerabilities(vulns) {
632
+ const byKey = /* @__PURE__ */ new Map();
633
+ for (const vuln of vulns) {
634
+ const key = `${vuln.id}::${vuln.packageName}`;
635
+ const existing = byKey.get(key);
636
+ if (!existing) {
637
+ byKey.set(key, vuln);
638
+ } else {
639
+ byKey.set(key, this.pickBetterEntry(existing, vuln));
640
+ }
641
+ }
642
+ return Array.from(byKey.values());
643
+ }
644
+ /** Picks the vulnerability entry with more complete data */
645
+ pickBetterEntry(a, b) {
646
+ let scoreA = 0;
647
+ let scoreB = 0;
648
+ if (a.cvssScore !== void 0) scoreA++;
649
+ if (b.cvssScore !== void 0) scoreB++;
650
+ if (a.fixedVersion) scoreA++;
651
+ if (b.fixedVersion) scoreB++;
652
+ if (a.affectedVersionRange) scoreA++;
653
+ if (b.affectedVersionRange) scoreB++;
654
+ if (a.severity !== "UNKNOWN") scoreA++;
655
+ if (b.severity !== "UNKNOWN") scoreB++;
656
+ const strip = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0 && v !== null));
657
+ const winner = scoreB > scoreA ? { ...strip(a), ...strip(b) } : { ...strip(b), ...strip(a) };
658
+ const allAliases = /* @__PURE__ */ new Set([...a.aliases, ...b.aliases]);
659
+ winner.aliases = Array.from(allAliases);
660
+ winner.exploitedInWild = a.exploitedInWild || b.exploitedInWild;
661
+ return winner;
662
+ }
663
+ };
664
+
665
+ // src/reporters/console.ts
666
+ var ConsoleReporter = class {
667
+ name = "console";
668
+ report(result) {
669
+ const lines = [];
670
+ lines.push("");
671
+ 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");
672
+ lines.push("\u2502 VERIMU CRA COMPLIANCE SCAN \u2502");
673
+ 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");
674
+ lines.push("");
675
+ lines.push(` Project: ${result.project.path}`);
676
+ lines.push(` Ecosystem: ${result.project.ecosystem}`);
677
+ lines.push(` Dependencies: ${result.project.dependencyCount}`);
678
+ lines.push(` Scanned at: ${result.generatedAt}`);
679
+ lines.push("");
680
+ lines.push(` \u2713 SBOM generated (${result.sbom.format}, ${result.sbom.specVersion})`);
681
+ lines.push(` Components: ${result.sbom.componentCount}`);
682
+ lines.push("");
683
+ const vulns = result.cveCheck.vulnerabilities;
684
+ if (vulns.length === 0) {
685
+ lines.push(" \u2713 No known vulnerabilities found");
686
+ } else {
687
+ lines.push(` \u26A0 ${vulns.length} vulnerabilit${vulns.length === 1 ? "y" : "ies"} found:`);
688
+ lines.push("");
689
+ const sorted = [...vulns].sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity));
690
+ for (const vuln of sorted) {
691
+ const badge = severityBadge(vuln.severity);
692
+ const fix = vuln.fixedVersion ? ` \u2192 fix: ${vuln.fixedVersion}` : "";
693
+ lines.push(` ${badge} ${vuln.id}`);
694
+ lines.push(` ${vuln.packageName}@${vuln.affectedVersionRange ?? "?"}${fix}`);
695
+ lines.push(` ${vuln.summary.slice(0, 100)}`);
696
+ if (vuln.exploitedInWild) {
697
+ lines.push(` \u{1F534} ACTIVELY EXPLOITED \u2014 24h CRA reporting required`);
698
+ }
699
+ lines.push("");
700
+ }
701
+ }
702
+ const sources = result.cveCheck.sourcesQueried.join(", ");
703
+ lines.push(` Sources queried: ${sources} (${result.cveCheck.checkDurationMs}ms)`);
704
+ if (result.cveCheck.sourceErrors.length > 0) {
705
+ for (const err of result.cveCheck.sourceErrors) {
706
+ lines.push(` \u26A0 ${err.source}: ${err.error}`);
707
+ }
708
+ }
709
+ lines.push("");
710
+ lines.push(" \u2500\u2500\u2500 Summary \u2500\u2500\u2500");
711
+ lines.push(` Total: ${result.summary.totalVulnerabilities} | Critical: ${result.summary.critical} | High: ${result.summary.high} | Medium: ${result.summary.medium} | Low: ${result.summary.low}`);
712
+ if (result.summary.exploitedInWild > 0) {
713
+ lines.push(` \u{1F534} ${result.summary.exploitedInWild} actively exploited \u2014 immediate action required`);
714
+ }
715
+ lines.push("");
716
+ return lines.join("\n");
717
+ }
718
+ };
719
+ function severityOrder(s) {
720
+ const order = {
721
+ CRITICAL: 0,
722
+ HIGH: 1,
723
+ MEDIUM: 2,
724
+ LOW: 3,
725
+ UNKNOWN: 4
726
+ };
727
+ return order[s] ?? 5;
728
+ }
729
+ function severityBadge(s) {
730
+ const badges = {
731
+ CRITICAL: "[CRIT]",
732
+ HIGH: "[HIGH]",
733
+ MEDIUM: "[MED] ",
734
+ LOW: "[LOW] ",
735
+ UNKNOWN: "[???] "
736
+ };
737
+ return badges[s] ?? "[???] ";
738
+ }
739
+
740
+ // src/scan.ts
741
+ async function scan(config) {
742
+ const {
743
+ projectPath,
744
+ sbomOutput = "./sbom.cdx.json",
745
+ skipCveCheck = false
746
+ } = config;
747
+ const registry = new ScannerRegistry();
748
+ const scanResult = await registry.detectAndScan(projectPath);
749
+ const sbomGenerator = new CycloneDxGenerator();
750
+ const sbom = sbomGenerator.generate(scanResult);
751
+ await writeFile(sbomOutput, sbom.content, "utf-8");
752
+ let cveCheck;
753
+ if (skipCveCheck) {
754
+ cveCheck = {
755
+ vulnerabilities: [],
756
+ sourcesQueried: [],
757
+ sourceErrors: [],
758
+ checkDurationMs: 0
759
+ };
760
+ } else {
761
+ const aggregator = new CveAggregator();
762
+ cveCheck = await aggregator.check(scanResult.dependencies);
763
+ }
764
+ const summary = {
765
+ totalDependencies: scanResult.dependencies.length,
766
+ totalVulnerabilities: cveCheck.vulnerabilities.length,
767
+ critical: cveCheck.vulnerabilities.filter((v) => v.severity === "CRITICAL").length,
768
+ high: cveCheck.vulnerabilities.filter((v) => v.severity === "HIGH").length,
769
+ medium: cveCheck.vulnerabilities.filter((v) => v.severity === "MEDIUM").length,
770
+ low: cveCheck.vulnerabilities.filter((v) => v.severity === "LOW").length,
771
+ exploitedInWild: cveCheck.vulnerabilities.filter((v) => v.exploitedInWild).length
772
+ };
773
+ const report = {
774
+ project: {
775
+ path: projectPath,
776
+ ecosystem: scanResult.ecosystem,
777
+ dependencyCount: scanResult.dependencies.length
778
+ },
779
+ sbom,
780
+ cveCheck,
781
+ summary,
782
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
783
+ };
784
+ return report;
785
+ }
786
+ function shouldFailCi(report, threshold) {
787
+ const severityOrder2 = {
788
+ CRITICAL: 0,
789
+ HIGH: 1,
790
+ MEDIUM: 2,
791
+ LOW: 3,
792
+ UNKNOWN: 4
793
+ };
794
+ const thresholdLevel = severityOrder2[threshold] ?? 4;
795
+ return report.cveCheck.vulnerabilities.some(
796
+ (v) => severityOrder2[v.severity] <= thresholdLevel
797
+ );
798
+ }
799
+ function printReport(report) {
800
+ const reporter = new ConsoleReporter();
801
+ console.log(reporter.report(report));
802
+ }
803
+ export {
804
+ ApiKeyRequiredError,
805
+ ConsoleReporter,
806
+ CveAggregator,
807
+ CveSourceError,
808
+ CycloneDxGenerator,
809
+ LockfileParseError,
810
+ NoLockfileError,
811
+ NpmScanner,
812
+ OsvSource,
813
+ ScannerRegistry,
814
+ VerimuError,
815
+ generateSbom,
816
+ printReport,
817
+ scan,
818
+ shouldFailCi
819
+ };
820
+ //# sourceMappingURL=index.mjs.map