verimu 0.0.2 → 0.0.3

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.cjs CHANGED
@@ -31,14 +31,20 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  ApiKeyRequiredError: () => ApiKeyRequiredError,
34
+ CargoScanner: () => CargoScanner,
34
35
  ConsoleReporter: () => ConsoleReporter,
35
36
  CveAggregator: () => CveAggregator,
36
37
  CveSourceError: () => CveSourceError,
37
38
  CycloneDxGenerator: () => CycloneDxGenerator,
39
+ GoScanner: () => GoScanner,
38
40
  LockfileParseError: () => LockfileParseError,
41
+ MavenScanner: () => MavenScanner,
39
42
  NoLockfileError: () => NoLockfileError,
40
43
  NpmScanner: () => NpmScanner,
44
+ NugetScanner: () => NugetScanner,
41
45
  OsvSource: () => OsvSource,
46
+ PipScanner: () => PipScanner,
47
+ RubyScanner: () => RubyScanner,
42
48
  ScannerRegistry: () => ScannerRegistry,
43
49
  VerimuError: () => VerimuError,
44
50
  generateSbom: () => generateSbom,
@@ -125,7 +131,8 @@ var PURL_TYPE_MAP = {
125
131
  cargo: "cargo",
126
132
  maven: "maven",
127
133
  pip: "pypi",
128
- go: "golang"
134
+ go: "golang",
135
+ ruby: "gem"
129
136
  };
130
137
  function buildPurl(name, version, ecosystem) {
131
138
  const type = PURL_TYPE_MAP[ecosystem] || ecosystem;
@@ -142,7 +149,7 @@ function deriveSupplierName(packageName) {
142
149
  }
143
150
 
144
151
  // src/scan.ts
145
- var import_promises2 = require("fs/promises");
152
+ var import_promises8 = require("fs/promises");
146
153
 
147
154
  // src/scanners/npm/npm-scanner.ts
148
155
  var import_promises = require("fs/promises");
@@ -160,7 +167,7 @@ var VerimuError = class extends Error {
160
167
  var NoLockfileError = class extends VerimuError {
161
168
  constructor(projectPath) {
162
169
  super(
163
- `No supported lockfile found in ${projectPath}. Supported: package-lock.json (npm), packages.lock.json (NuGet), Cargo.lock (Rust)`,
170
+ `No supported lockfile found in ${projectPath}. Supported: package-lock.json (npm), packages.lock.json (NuGet), Cargo.lock (Rust), requirements.txt / Pipfile.lock (Python), pom.xml (Maven), go.sum (Go), Gemfile.lock (Ruby)`,
164
171
  "NO_LOCKFILE"
165
172
  );
166
173
  this.name = "NoLockfileError";
@@ -296,26 +303,670 @@ var NpmScanner = class {
296
303
  };
297
304
 
298
305
  // src/scanners/nuget/nuget-scanner.ts
306
+ var import_promises2 = require("fs/promises");
307
+ var import_fs2 = require("fs");
308
+ var import_path2 = __toESM(require("path"), 1);
299
309
  var NugetScanner = class {
300
310
  ecosystem = "nuget";
301
311
  lockfileNames = ["packages.lock.json"];
302
- async detect(_projectPath) {
303
- return null;
312
+ async detect(projectPath) {
313
+ const lockfilePath = import_path2.default.join(projectPath, "packages.lock.json");
314
+ return (0, import_fs2.existsSync)(lockfilePath) ? lockfilePath : null;
315
+ }
316
+ async scan(projectPath, lockfilePath) {
317
+ const lockfileRaw = await (0, import_promises2.readFile)(lockfilePath, "utf-8");
318
+ let lockfile;
319
+ try {
320
+ lockfile = JSON.parse(lockfileRaw);
321
+ } catch {
322
+ throw new LockfileParseError(lockfilePath, "Invalid JSON");
323
+ }
324
+ if (!lockfile.dependencies) {
325
+ throw new LockfileParseError(lockfilePath, 'Missing "dependencies" field');
326
+ }
327
+ const dependencies = this.parseLockfile(lockfile);
328
+ return {
329
+ projectPath,
330
+ ecosystem: "nuget",
331
+ dependencies,
332
+ lockfilePath,
333
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString()
334
+ };
335
+ }
336
+ /**
337
+ * Parses packages.lock.json and extracts dependencies across all
338
+ * target frameworks. Deduplicates by package name (keeps highest version
339
+ * if the same package appears under multiple frameworks).
340
+ */
341
+ parseLockfile(lockfile) {
342
+ const depMap = /* @__PURE__ */ new Map();
343
+ for (const [_framework, packages] of Object.entries(lockfile.dependencies)) {
344
+ for (const [name, info] of Object.entries(packages)) {
345
+ if (!info.resolved) continue;
346
+ const isDirect = info.type === "Direct";
347
+ const existing = depMap.get(name);
348
+ if (!existing) {
349
+ depMap.set(name, {
350
+ name,
351
+ version: info.resolved,
352
+ direct: isDirect,
353
+ ecosystem: "nuget",
354
+ purl: this.buildPurl(name, info.resolved)
355
+ });
356
+ } else if (isDirect && !existing.direct) {
357
+ existing.direct = true;
358
+ }
359
+ }
360
+ }
361
+ return Array.from(depMap.values());
304
362
  }
305
- async scan(_projectPath, _lockfilePath) {
306
- throw new Error("NuGet scanner not yet implemented. Coming soon.");
363
+ /**
364
+ * Builds a purl for a NuGet package.
365
+ * NuGet purls are straightforward: pkg:nuget/Name@Version
366
+ */
367
+ buildPurl(name, version) {
368
+ return `pkg:nuget/${name}@${version}`;
307
369
  }
308
370
  };
309
371
 
310
372
  // src/scanners/cargo/cargo-scanner.ts
373
+ var import_promises3 = require("fs/promises");
374
+ var import_fs3 = require("fs");
375
+ var import_path3 = __toESM(require("path"), 1);
311
376
  var CargoScanner = class {
312
377
  ecosystem = "cargo";
313
378
  lockfileNames = ["Cargo.lock"];
314
- async detect(_projectPath) {
379
+ async detect(projectPath) {
380
+ const lockfilePath = import_path3.default.join(projectPath, "Cargo.lock");
381
+ return (0, import_fs3.existsSync)(lockfilePath) ? lockfilePath : null;
382
+ }
383
+ async scan(projectPath, lockfilePath) {
384
+ const [lockfileRaw, cargoTomlRaw] = await Promise.all([
385
+ (0, import_promises3.readFile)(lockfilePath, "utf-8"),
386
+ (0, import_promises3.readFile)(import_path3.default.join(projectPath, "Cargo.toml"), "utf-8").catch(() => null)
387
+ ]);
388
+ const packages = this.parseLockfile(lockfileRaw, lockfilePath);
389
+ const directNames = cargoTomlRaw ? this.parseCargoToml(cargoTomlRaw) : /* @__PURE__ */ new Set();
390
+ const rootName = packages.length > 0 ? packages[0].name : null;
391
+ const dependencies = [];
392
+ for (const pkg of packages) {
393
+ if (pkg.name === rootName && pkg.source === void 0) continue;
394
+ dependencies.push({
395
+ name: pkg.name,
396
+ version: pkg.version,
397
+ direct: directNames.has(pkg.name),
398
+ ecosystem: "cargo",
399
+ purl: this.buildPurl(pkg.name, pkg.version)
400
+ });
401
+ }
402
+ return {
403
+ projectPath,
404
+ ecosystem: "cargo",
405
+ dependencies,
406
+ lockfilePath,
407
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString()
408
+ };
409
+ }
410
+ /**
411
+ * Parses Cargo.lock by splitting on [[package]] blocks.
412
+ * This is a lightweight parser that handles the regular structure
413
+ * of Cargo.lock without needing a full TOML parser.
414
+ */
415
+ parseLockfile(content, lockfilePath) {
416
+ const packages = [];
417
+ const blocks = content.split(/^\[\[package\]\]$/m);
418
+ for (const block of blocks) {
419
+ if (!block.trim()) continue;
420
+ const name = this.extractField(block, "name");
421
+ const version = this.extractField(block, "version");
422
+ const source = this.extractField(block, "source");
423
+ if (name && version) {
424
+ packages.push({ name, version, source: source || void 0 });
425
+ }
426
+ }
427
+ if (packages.length === 0 && content.includes("[[package]]")) {
428
+ throw new LockfileParseError(lockfilePath, "Failed to parse any packages from Cargo.lock");
429
+ }
430
+ return packages;
431
+ }
432
+ /**
433
+ * Extracts a string field value from a TOML block.
434
+ * Handles: `name = "value"` format.
435
+ */
436
+ extractField(block, fieldName) {
437
+ const regex = new RegExp(`^${fieldName}\\s*=\\s*"([^"]*)"`, "m");
438
+ const match = block.match(regex);
439
+ return match ? match[1] : null;
440
+ }
441
+ /**
442
+ * Parses Cargo.toml to extract direct dependency names.
443
+ * Looks for [dependencies] and [dev-dependencies] sections.
444
+ */
445
+ parseCargoToml(content) {
446
+ const directNames = /* @__PURE__ */ new Set();
447
+ let inDepsSection = false;
448
+ for (const rawLine of content.split("\n")) {
449
+ const line = rawLine.trim();
450
+ if (line.startsWith("[")) {
451
+ inDepsSection = line === "[dependencies]" || line === "[dev-dependencies]" || line === "[build-dependencies]";
452
+ continue;
453
+ }
454
+ if (inDepsSection && line && !line.startsWith("#")) {
455
+ const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/);
456
+ if (match) {
457
+ directNames.add(match[1]);
458
+ }
459
+ }
460
+ }
461
+ return directNames;
462
+ }
463
+ /**
464
+ * Builds a purl for a Cargo (crates.io) package.
465
+ */
466
+ buildPurl(name, version) {
467
+ return `pkg:cargo/${name}@${version}`;
468
+ }
469
+ };
470
+
471
+ // src/scanners/pip/pip-scanner.ts
472
+ var import_promises4 = require("fs/promises");
473
+ var import_fs4 = require("fs");
474
+ var import_path4 = __toESM(require("path"), 1);
475
+ var PipScanner = class {
476
+ ecosystem = "pip";
477
+ lockfileNames = ["requirements.txt", "Pipfile.lock"];
478
+ async detect(projectPath) {
479
+ for (const lockfile of this.lockfileNames) {
480
+ const fullPath = import_path4.default.join(projectPath, lockfile);
481
+ if ((0, import_fs4.existsSync)(fullPath)) return fullPath;
482
+ }
315
483
  return null;
316
484
  }
317
- async scan(_projectPath, _lockfilePath) {
318
- throw new Error("Cargo scanner not yet implemented. Coming soon.");
485
+ async scan(projectPath, lockfilePath) {
486
+ const raw = await (0, import_promises4.readFile)(lockfilePath, "utf-8");
487
+ const filename = import_path4.default.basename(lockfilePath);
488
+ let dependencies;
489
+ if (filename === "Pipfile.lock") {
490
+ dependencies = this.parsePipfileLock(raw, lockfilePath);
491
+ } else {
492
+ dependencies = this.parseRequirementsTxt(raw, lockfilePath);
493
+ }
494
+ return {
495
+ projectPath,
496
+ ecosystem: "pip",
497
+ dependencies,
498
+ lockfilePath,
499
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString()
500
+ };
501
+ }
502
+ /**
503
+ * Parses `requirements.txt` format.
504
+ *
505
+ * Supports:
506
+ * - `package==1.2.3` (pinned)
507
+ * - `package>=1.2.0` (minimum — uses the specified version)
508
+ * - `package~=1.2.0` (compatible release)
509
+ * - Comments (`#`) and blank lines are skipped
510
+ * - `-r other-file.txt` (include directive) — skipped for now
511
+ * - `--index-url` and other pip flags — skipped
512
+ */
513
+ parseRequirementsTxt(content, lockfilePath) {
514
+ const deps = [];
515
+ for (const rawLine of content.split("\n")) {
516
+ const line = rawLine.trim();
517
+ if (!line || line.startsWith("#") || line.startsWith("-") || line.startsWith("--")) {
518
+ continue;
519
+ }
520
+ const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*(?:[~=!<>]=?)\s*(.+)$/);
521
+ if (match) {
522
+ const [, name, versionSpec] = match;
523
+ const version = this.extractVersion(versionSpec);
524
+ if (name && version) {
525
+ deps.push({
526
+ name: this.normalizePipName(name),
527
+ version,
528
+ direct: true,
529
+ // requirements.txt doesn't distinguish
530
+ ecosystem: "pip",
531
+ purl: this.buildPurl(name, version)
532
+ });
533
+ }
534
+ }
535
+ }
536
+ return deps;
537
+ }
538
+ /**
539
+ * Parses `Pipfile.lock` (JSON format from Pipenv).
540
+ *
541
+ * Structure:
542
+ * ```json
543
+ * {
544
+ * "_meta": { ... },
545
+ * "default": {
546
+ * "requests": { "version": "==2.31.0", ... }
547
+ * },
548
+ * "develop": {
549
+ * "pytest": { "version": "==7.4.0", ... }
550
+ * }
551
+ * }
552
+ * ```
553
+ */
554
+ parsePipfileLock(content, lockfilePath) {
555
+ let lockfile;
556
+ try {
557
+ lockfile = JSON.parse(content);
558
+ } catch {
559
+ throw new LockfileParseError(lockfilePath, "Invalid JSON in Pipfile.lock");
560
+ }
561
+ const deps = [];
562
+ if (lockfile.default) {
563
+ for (const [name, info] of Object.entries(lockfile.default)) {
564
+ const version = info.version?.replace(/^==/, "") ?? "";
565
+ if (version) {
566
+ deps.push({
567
+ name: this.normalizePipName(name),
568
+ version,
569
+ direct: true,
570
+ ecosystem: "pip",
571
+ purl: this.buildPurl(name, version)
572
+ });
573
+ }
574
+ }
575
+ }
576
+ if (lockfile.develop) {
577
+ for (const [name, info] of Object.entries(lockfile.develop)) {
578
+ const version = info.version?.replace(/^==/, "") ?? "";
579
+ if (version) {
580
+ deps.push({
581
+ name: this.normalizePipName(name),
582
+ version,
583
+ direct: true,
584
+ ecosystem: "pip",
585
+ purl: this.buildPurl(name, version)
586
+ });
587
+ }
588
+ }
589
+ }
590
+ return deps;
591
+ }
592
+ /**
593
+ * Extracts the version number from a pip version specifier.
594
+ * "1.2.3" → "1.2.3"
595
+ * "1.2.3,<2.0" → "1.2.3"
596
+ */
597
+ extractVersion(spec) {
598
+ const cleaned = spec.split(",")[0].trim();
599
+ return cleaned;
600
+ }
601
+ /**
602
+ * Normalizes a pip package name per PEP 503.
603
+ * Converts to lowercase and replaces any run of [-_.] with a single hyphen.
604
+ */
605
+ normalizePipName(name) {
606
+ return name.toLowerCase().replace(/[-_.]+/g, "-");
607
+ }
608
+ /**
609
+ * Builds a purl for a PyPI package.
610
+ * Per purl spec, the type is "pypi" (not "pip").
611
+ */
612
+ buildPurl(name, version) {
613
+ return `pkg:pypi/${this.normalizePipName(name)}@${version}`;
614
+ }
615
+ };
616
+
617
+ // src/scanners/maven/maven-scanner.ts
618
+ var import_promises5 = require("fs/promises");
619
+ var import_fs5 = require("fs");
620
+ var import_child_process = require("child_process");
621
+ var import_path5 = __toESM(require("path"), 1);
622
+ var MavenScanner = class {
623
+ ecosystem = "maven";
624
+ lockfileNames = ["pom.xml"];
625
+ /** Allow injection for testing */
626
+ execSyncFn;
627
+ constructor(execSyncImpl) {
628
+ this.execSyncFn = execSyncImpl ?? import_child_process.execSync;
629
+ }
630
+ async detect(projectPath) {
631
+ const pomPath = import_path5.default.join(projectPath, "pom.xml");
632
+ return (0, import_fs5.existsSync)(pomPath) ? pomPath : null;
633
+ }
634
+ async scan(projectPath, _lockfilePath) {
635
+ const depTreePath = import_path5.default.join(projectPath, "dependency-tree.txt");
636
+ if ((0, import_fs5.existsSync)(depTreePath)) {
637
+ const content = await (0, import_promises5.readFile)(depTreePath, "utf-8");
638
+ const dependencies = this.parseDependencyList(content, depTreePath);
639
+ return this.buildResult(projectPath, depTreePath, dependencies);
640
+ }
641
+ if (this.isMavenAvailable()) {
642
+ const output = this.runMavenDependencyList(projectPath);
643
+ const dependencies = this.parseDependencyList(output, "mvn dependency:list");
644
+ return this.buildResult(projectPath, import_path5.default.join(projectPath, "pom.xml"), dependencies);
645
+ }
646
+ throw new LockfileParseError(
647
+ import_path5.default.join(projectPath, "pom.xml"),
648
+ "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"
649
+ );
650
+ }
651
+ /**
652
+ * Parses Maven `dependency:list` output.
653
+ *
654
+ * Each dependency line has the format:
655
+ * groupId:artifactId:type:version:scope
656
+ * groupId:artifactId:type:classifier:version:scope
657
+ *
658
+ * Lines are typically indented with leading whitespace.
659
+ */
660
+ parseDependencyList(content, source) {
661
+ const deps = [];
662
+ const depPattern = /^\s*([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):([a-z]+):(?:([a-zA-Z0-9._-]+):)?([a-zA-Z0-9._-]+):([a-z]+)/;
663
+ for (const rawLine of content.split("\n")) {
664
+ const line = rawLine.trim();
665
+ if (!line) continue;
666
+ const match = line.match(depPattern);
667
+ if (match) {
668
+ const groupId = match[1];
669
+ const artifactId = match[2];
670
+ const version = match[4] && match[5] ? match[5] : match[4] ?? match[5];
671
+ const scope = match[4] && match[5] ? match[6] : match[5] && match[6] ? match[6] : match[5];
672
+ const parts = line.split(":");
673
+ if (parts.length >= 5) {
674
+ const gId = parts[0].trim();
675
+ const aId = parts[1];
676
+ const ver = parts.length === 6 ? parts[4] : parts[3];
677
+ const scp = parts.length === 6 ? parts[5] : parts[4];
678
+ if (gId && aId && ver) {
679
+ const name = `${gId}:${aId}`;
680
+ deps.push({
681
+ name,
682
+ version: ver,
683
+ direct: scp === "compile" || scp === "runtime" || scp === "provided",
684
+ ecosystem: "maven",
685
+ purl: this.buildPurl(gId, aId, ver)
686
+ });
687
+ }
688
+ }
689
+ }
690
+ }
691
+ return deps;
692
+ }
693
+ /** Checks if `mvn` is available on PATH */
694
+ isMavenAvailable() {
695
+ try {
696
+ this.execSyncFn("mvn --version", { stdio: "pipe", timeout: 1e4 });
697
+ return true;
698
+ } catch {
699
+ return false;
700
+ }
701
+ }
702
+ /**
703
+ * Runs `mvn dependency:list` and returns the output.
704
+ */
705
+ runMavenDependencyList(projectPath) {
706
+ try {
707
+ const output = this.execSyncFn(
708
+ "mvn dependency:list -DoutputType=text -DincludeScope=compile",
709
+ {
710
+ cwd: projectPath,
711
+ stdio: "pipe",
712
+ timeout: 12e4,
713
+ // 2 minute timeout
714
+ encoding: "utf-8"
715
+ }
716
+ );
717
+ return output.toString();
718
+ } catch (err) {
719
+ const message = err instanceof Error ? err.message : String(err);
720
+ throw new LockfileParseError(
721
+ import_path5.default.join(projectPath, "pom.xml"),
722
+ `Failed to run 'mvn dependency:list': ${message}`
723
+ );
724
+ }
725
+ }
726
+ /**
727
+ * Builds a purl for a Maven package.
728
+ * Format: pkg:maven/groupId/artifactId@version
729
+ */
730
+ buildPurl(groupId, artifactId, version) {
731
+ return `pkg:maven/${groupId}/${artifactId}@${version}`;
732
+ }
733
+ buildResult(projectPath, lockfilePath, dependencies) {
734
+ return {
735
+ projectPath,
736
+ ecosystem: "maven",
737
+ dependencies,
738
+ lockfilePath,
739
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString()
740
+ };
741
+ }
742
+ };
743
+
744
+ // src/scanners/go/go-scanner.ts
745
+ var import_promises6 = require("fs/promises");
746
+ var import_fs6 = require("fs");
747
+ var import_path6 = __toESM(require("path"), 1);
748
+ var GoScanner = class {
749
+ ecosystem = "go";
750
+ lockfileNames = ["go.sum"];
751
+ async detect(projectPath) {
752
+ const goSumPath = import_path6.default.join(projectPath, "go.sum");
753
+ return (0, import_fs6.existsSync)(goSumPath) ? goSumPath : null;
754
+ }
755
+ async scan(projectPath, lockfilePath) {
756
+ const [goSumRaw, goModRaw] = await Promise.all([
757
+ (0, import_promises6.readFile)(lockfilePath, "utf-8"),
758
+ (0, import_promises6.readFile)(import_path6.default.join(projectPath, "go.mod"), "utf-8").catch(() => null)
759
+ ]);
760
+ const { directNames, indirectNames } = goModRaw ? this.parseGoMod(goModRaw) : { directNames: /* @__PURE__ */ new Set(), indirectNames: /* @__PURE__ */ new Set() };
761
+ const dependencies = this.parseGoSum(goSumRaw, lockfilePath, directNames, indirectNames);
762
+ return {
763
+ projectPath,
764
+ ecosystem: "go",
765
+ dependencies,
766
+ lockfilePath,
767
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString()
768
+ };
769
+ }
770
+ /**
771
+ * Parses go.sum and extracts unique module dependencies.
772
+ *
773
+ * Each module may appear twice in go.sum (once for the source archive,
774
+ * once for go.mod). We deduplicate by module path + version, keeping
775
+ * only the `h1:` entry (not the `/go.mod` entry).
776
+ */
777
+ parseGoSum(content, lockfilePath, directNames, indirectNames) {
778
+ const depMap = /* @__PURE__ */ new Map();
779
+ for (const rawLine of content.split("\n")) {
780
+ const line = rawLine.trim();
781
+ if (!line) continue;
782
+ const parts = line.split(/\s+/);
783
+ if (parts.length < 3) continue;
784
+ const modulePath = parts[0];
785
+ let version = parts[1];
786
+ if (version.endsWith("/go.mod")) continue;
787
+ version = version.replace(/\+incompatible$/, "");
788
+ const key = `${modulePath}@${version}`;
789
+ if (depMap.has(key)) continue;
790
+ const isDirect = directNames.size > 0 || indirectNames.size > 0 ? directNames.has(modulePath) || (!indirectNames.has(modulePath) && !directNames.has(modulePath) ? false : directNames.has(modulePath)) : true;
791
+ depMap.set(key, {
792
+ name: modulePath,
793
+ version,
794
+ direct: isDirect,
795
+ ecosystem: "go",
796
+ purl: this.buildPurl(modulePath, version)
797
+ });
798
+ }
799
+ return Array.from(depMap.values());
800
+ }
801
+ /**
802
+ * Parses go.mod to extract direct and indirect dependency names.
803
+ *
804
+ * Handles both single-line and block `require` directives:
805
+ * ```
806
+ * require github.com/pkg/errors v0.9.1
807
+ *
808
+ * require (
809
+ * github.com/gin-gonic/gin v1.9.1
810
+ * golang.org/x/text v0.14.0 // indirect
811
+ * )
812
+ * ```
813
+ */
814
+ parseGoMod(content) {
815
+ const directNames = /* @__PURE__ */ new Set();
816
+ const indirectNames = /* @__PURE__ */ new Set();
817
+ let inRequireBlock = false;
818
+ for (const rawLine of content.split("\n")) {
819
+ const line = rawLine.trim();
820
+ if (line.startsWith("require ") && !line.includes("(")) {
821
+ const match = line.match(/^require\s+(\S+)\s+\S+(.*)$/);
822
+ if (match) {
823
+ const modulePath = match[1];
824
+ const rest = match[2];
825
+ if (rest.includes("// indirect")) {
826
+ indirectNames.add(modulePath);
827
+ } else {
828
+ directNames.add(modulePath);
829
+ }
830
+ }
831
+ continue;
832
+ }
833
+ if (line === "require (" || line.startsWith("require (")) {
834
+ inRequireBlock = true;
835
+ continue;
836
+ }
837
+ if (inRequireBlock && line === ")") {
838
+ inRequireBlock = false;
839
+ continue;
840
+ }
841
+ if (inRequireBlock && line && !line.startsWith("//")) {
842
+ const match = line.match(/^(\S+)\s+\S+(.*)$/);
843
+ if (match) {
844
+ const modulePath = match[1];
845
+ const rest = match[2];
846
+ if (rest.includes("// indirect")) {
847
+ indirectNames.add(modulePath);
848
+ } else {
849
+ directNames.add(modulePath);
850
+ }
851
+ }
852
+ }
853
+ }
854
+ return { directNames, indirectNames };
855
+ }
856
+ /**
857
+ * Builds a purl for a Go module.
858
+ *
859
+ * Per purl spec, the type is "golang" and the module path
860
+ * uses `/` separators (no encoding needed for path segments).
861
+ *
862
+ * Example: `pkg:golang/github.com/gin-gonic/gin@v1.9.1`
863
+ */
864
+ buildPurl(modulePath, version) {
865
+ return `pkg:golang/${modulePath}@${version}`;
866
+ }
867
+ };
868
+
869
+ // src/scanners/ruby/ruby-scanner.ts
870
+ var import_promises7 = require("fs/promises");
871
+ var import_fs7 = require("fs");
872
+ var import_path7 = __toESM(require("path"), 1);
873
+ var RubyScanner = class {
874
+ ecosystem = "ruby";
875
+ lockfileNames = ["Gemfile.lock"];
876
+ async detect(projectPath) {
877
+ const lockfilePath = import_path7.default.join(projectPath, "Gemfile.lock");
878
+ return (0, import_fs7.existsSync)(lockfilePath) ? lockfilePath : null;
879
+ }
880
+ async scan(projectPath, lockfilePath) {
881
+ const content = await (0, import_promises7.readFile)(lockfilePath, "utf-8");
882
+ const specs = this.parseSpecs(content, lockfilePath);
883
+ const directNames = this.parseDependencies(content);
884
+ const dependencies = specs.map(({ name, version }) => ({
885
+ name,
886
+ version,
887
+ direct: directNames.has(name),
888
+ ecosystem: "ruby",
889
+ purl: `pkg:gem/${name}@${version}`
890
+ }));
891
+ return {
892
+ projectPath,
893
+ ecosystem: "ruby",
894
+ dependencies,
895
+ lockfilePath,
896
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString()
897
+ };
898
+ }
899
+ /**
900
+ * Parses the GEM > specs section to extract all resolved gems.
901
+ *
902
+ * Gems at the top level of the specs section (indented 4 spaces) are
903
+ * resolved packages. Their sub-dependencies (indented 6+ spaces) are
904
+ * constraints, not separate entries — those sub-deps appear as their
905
+ * own top-level spec entries elsewhere.
906
+ *
907
+ * Format: ` gem-name (1.2.3)`
908
+ */
909
+ parseSpecs(content, lockfilePath) {
910
+ const gems = [];
911
+ let inGemSection = false;
912
+ let inSpecs = false;
913
+ for (const rawLine of content.split("\n")) {
914
+ const line = rawLine;
915
+ if (line.length > 0 && line[0] !== " ") {
916
+ if (line.startsWith("GEM")) {
917
+ inGemSection = true;
918
+ inSpecs = false;
919
+ continue;
920
+ }
921
+ inGemSection = false;
922
+ inSpecs = false;
923
+ continue;
924
+ }
925
+ if (inGemSection && line.trimStart().startsWith("specs:")) {
926
+ inSpecs = true;
927
+ continue;
928
+ }
929
+ if (!inSpecs) continue;
930
+ const match = line.match(/^ {4}(\S+)\s+\(([^)]+)\)$/);
931
+ if (match) {
932
+ const [, name, version] = match;
933
+ gems.push({ name, version });
934
+ }
935
+ }
936
+ if (gems.length === 0) {
937
+ throw new LockfileParseError(
938
+ lockfilePath,
939
+ "No gems found in GEM specs section"
940
+ );
941
+ }
942
+ return gems;
943
+ }
944
+ /**
945
+ * Parses the DEPENDENCIES section to get direct dependency names.
946
+ *
947
+ * Format: ` gem-name (>= 1.0)` or ` gem-name`
948
+ * The version constraint is optional and we only need the name.
949
+ */
950
+ parseDependencies(content) {
951
+ const directNames = /* @__PURE__ */ new Set();
952
+ let inDependencies = false;
953
+ for (const rawLine of content.split("\n")) {
954
+ const line = rawLine;
955
+ if (line.length > 0 && line[0] !== " ") {
956
+ if (line.startsWith("DEPENDENCIES")) {
957
+ inDependencies = true;
958
+ continue;
959
+ }
960
+ if (inDependencies) break;
961
+ continue;
962
+ }
963
+ if (!inDependencies) continue;
964
+ const match = line.match(/^ {2}(\S+?)!?\s*(?:\(|$)/);
965
+ if (match) {
966
+ directNames.add(match[1]);
967
+ }
968
+ }
969
+ return directNames;
319
970
  }
320
971
  };
321
972
 
@@ -326,8 +977,11 @@ var ScannerRegistry = class {
326
977
  this.scanners = [
327
978
  new NpmScanner(),
328
979
  new NugetScanner(),
329
- new CargoScanner()
330
- // Add new scanners here as they're implemented
980
+ new CargoScanner(),
981
+ new PipScanner(),
982
+ new MavenScanner(),
983
+ new GoScanner(),
984
+ new RubyScanner()
331
985
  ];
332
986
  }
333
987
  /**
@@ -622,7 +1276,8 @@ var OsvSource = class {
622
1276
  cargo: "crates.io",
623
1277
  maven: "Maven",
624
1278
  pip: "PyPI",
625
- go: "Go"
1279
+ go: "Go",
1280
+ ruby: "RubyGems"
626
1281
  };
627
1282
  return map[ecosystem] ?? ecosystem;
628
1283
  }
@@ -798,7 +1453,7 @@ async function scan(config) {
798
1453
  const scanResult = await registry.detectAndScan(projectPath);
799
1454
  const sbomGenerator = new CycloneDxGenerator();
800
1455
  const sbom = sbomGenerator.generate(scanResult);
801
- await (0, import_promises2.writeFile)(sbomOutput, sbom.content, "utf-8");
1456
+ await (0, import_promises8.writeFile)(sbomOutput, sbom.content, "utf-8");
802
1457
  let cveCheck;
803
1458
  if (skipCveCheck) {
804
1459
  cveCheck = {
@@ -853,14 +1508,20 @@ function printReport(report) {
853
1508
  // Annotate the CommonJS export names for ESM import in node:
854
1509
  0 && (module.exports = {
855
1510
  ApiKeyRequiredError,
1511
+ CargoScanner,
856
1512
  ConsoleReporter,
857
1513
  CveAggregator,
858
1514
  CveSourceError,
859
1515
  CycloneDxGenerator,
1516
+ GoScanner,
860
1517
  LockfileParseError,
1518
+ MavenScanner,
861
1519
  NoLockfileError,
862
1520
  NpmScanner,
1521
+ NugetScanner,
863
1522
  OsvSource,
1523
+ PipScanner,
1524
+ RubyScanner,
864
1525
  ScannerRegistry,
865
1526
  VerimuError,
866
1527
  generateSbom,