verimu 0.0.4 → 0.0.6

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/README.md CHANGED
@@ -11,7 +11,7 @@ The NPM package for `verimu`, a tool for producing CRA-compliant SBOMs via CI /
11
11
  The core scanning pipeline is CI-agnostic — it works in any environment with Node.js 20+.
12
12
  Example CI configs are provided in the `ci-examples/` directory.
13
13
 
14
- - [x] GitHub Actions (`.github/workflows/test.yml`)
14
+ - [x] GitHub Actions (`.github/workflows/release.yml`)
15
15
  - [x] GitLab CI (`ci-examples/gitlab-ci.yml`)
16
16
  - [x] Bitbucket Pipelines (`ci-examples/bitbucket-pipelines.yml`)
17
17
 
@@ -33,6 +33,42 @@ To run the tests, use:
33
33
  npm test
34
34
  ```
35
35
 
36
+ ## Releasing to npm (Tag Pipelines)
37
+
38
+ `verimu` can publish from GitHub Actions, GitLab CI, and Bitbucket Pipelines when a semver tag is pushed.
39
+ Each pipeline validates:
40
+
41
+ - tag is semver (`v1.2.3` or `1.2.3`)
42
+ - tag version matches `package.json` version
43
+ - tagged commit exists on `main`
44
+
45
+ ### Publish credentials
46
+
47
+ - GitHub Actions (`.github/workflows/release.yml`): uses npm Trusted Publishing (OIDC), so no `NPM_TOKEN` secret is required.
48
+ - GitLab and Bitbucket pipelines in this repo still use `NPM_TOKEN` (`.gitlab-ci.yml`, `bitbucket-pipelines.yml`).
49
+
50
+ ### Recommended release flow
51
+
52
+ 1. Bump version on `main` with npm (this updates `package.json` and `package-lock.json`, then creates a git tag):
53
+
54
+ ```bash
55
+ npm version patch
56
+ ```
57
+
58
+ 2. Push commit and tag:
59
+
60
+ ```bash
61
+ git push origin main --follow-tags
62
+ ```
63
+
64
+ 3. Your CI provider runs the publish job on that tag and releases to npm.
65
+
66
+ ### Why this avoids version conflicts
67
+
68
+ The source of truth remains the version committed on `main`.
69
+ The tag is only a release trigger for that exact versioned commit.
70
+ You should not tag arbitrary commits with a new version string that is not already committed in `package.json`.
71
+
36
72
  ## Maven Scanner Notes
37
73
 
38
74
  The Maven scanner needs resolved dependencies. Since Maven has no lockfile, it uses two strategies:
@@ -42,4 +78,6 @@ The Maven scanner needs resolved dependencies. Since Maven has no lockfile, it u
42
78
 
43
79
  ## Three CI / CD Pipelines as Self Check on the `verimu` package itself
44
80
 
45
- There is a `bitbucket-pipelines.yml` and `.gitlab-ci.yml` in the root of the project, as well as a `.github/workflows/test.yml` file, all of which would run `verimu` against itself in each of the 3 frameworks we support (GitHub Actions, GitLab CI, Bitbucket Pipelines). The tests should pass in all 3 environments, confirming that `verimu` can successfully scan its own dependencies and produce a report.
81
+ There is a `bitbucket-pipelines.yml` and `.gitlab-ci.yml` in the root of the project, as well as a `.github/workflows/release.yml` file, all of which would run `verimu` against itself in each of the 3 frameworks we support (GitHub Actions, GitLab CI, Bitbucket Pipelines). The tests should pass in all 3 environments, confirming that `verimu` can successfully scan its own dependencies and produce a report.
82
+
83
+ The same three provider configs now also include tag-based npm release automation, so this repo is a working cross-provider reference for both scanning and publishing.
package/dist/cli.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { resolve } from "path";
5
+ import { createRequire } from "module";
5
6
 
6
7
  // src/scan.ts
7
8
  import { writeFile } from "fs/promises";
@@ -23,7 +24,7 @@ var VerimuError = class extends Error {
23
24
  var NoLockfileError = class extends VerimuError {
24
25
  constructor(projectPath) {
25
26
  super(
26
- `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)`,
27
+ `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), composer.lock (Composer)`,
27
28
  "NO_LOCKFILE"
28
29
  );
29
30
  this.name = "NoLockfileError";
@@ -58,11 +59,11 @@ var NpmScanner = class {
58
59
  const directNames = /* @__PURE__ */ new Set();
59
60
  if (packageJsonRaw) {
60
61
  try {
61
- const pkg = JSON.parse(packageJsonRaw);
62
- for (const name of Object.keys(pkg.dependencies ?? {})) {
62
+ const pkg2 = JSON.parse(packageJsonRaw);
63
+ for (const name of Object.keys(pkg2.dependencies ?? {})) {
63
64
  directNames.add(name);
64
65
  }
65
- for (const name of Object.keys(pkg.devDependencies ?? {})) {
66
+ for (const name of Object.keys(pkg2.devDependencies ?? {})) {
66
67
  directNames.add(name);
67
68
  }
68
69
  } catch {
@@ -230,14 +231,14 @@ var CargoScanner = class {
230
231
  const directNames = cargoTomlRaw ? this.parseCargoToml(cargoTomlRaw) : /* @__PURE__ */ new Set();
231
232
  const rootName = packages.length > 0 ? packages[0].name : null;
232
233
  const dependencies = [];
233
- for (const pkg of packages) {
234
- if (pkg.name === rootName && pkg.source === void 0) continue;
234
+ for (const pkg2 of packages) {
235
+ if (pkg2.name === rootName && pkg2.source === void 0) continue;
235
236
  dependencies.push({
236
- name: pkg.name,
237
- version: pkg.version,
238
- direct: directNames.has(pkg.name),
237
+ name: pkg2.name,
238
+ version: pkg2.version,
239
+ direct: directNames.has(pkg2.name),
239
240
  ecosystem: "cargo",
240
- purl: this.buildPurl(pkg.name, pkg.version)
241
+ purl: this.buildPurl(pkg2.name, pkg2.version)
241
242
  });
242
243
  }
243
244
  return {
@@ -315,7 +316,7 @@ import { existsSync as existsSync4 } from "fs";
315
316
  import path4 from "path";
316
317
  var PipScanner = class {
317
318
  ecosystem = "pip";
318
- lockfileNames = ["requirements.txt", "Pipfile.lock"];
319
+ lockfileNames = ["Pipfile.lock", "requirements.txt"];
319
320
  async detect(projectPath) {
320
321
  for (const lockfile of this.lockfileNames) {
321
322
  const fullPath = path4.join(projectPath, lockfile);
@@ -330,7 +331,7 @@ var PipScanner = class {
330
331
  if (filename === "Pipfile.lock") {
331
332
  dependencies = this.parsePipfileLock(raw, lockfilePath);
332
333
  } else {
333
- dependencies = this.parseRequirementsTxt(raw, lockfilePath);
334
+ dependencies = await this.parseRequirementsTxt(raw, lockfilePath);
334
335
  }
335
336
  return {
336
337
  projectPath,
@@ -344,24 +345,43 @@ var PipScanner = class {
344
345
  * Parses `requirements.txt` format.
345
346
  *
346
347
  * Supports:
347
- * - `package==1.2.3` (pinned)
348
- * - `package>=1.2.0` (minimum — uses the specified version)
349
- * - `package~=1.2.0` (compatible release)
348
+ * - `package==1.2.3` (pinned) — REQUIRED
350
349
  * - Comments (`#`) and blank lines are skipped
351
- * - `-r other-file.txt` (include directive) — skipped for now
350
+ * - `-r other-file.txt` (include directive) — recursively parsed
352
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.
353
355
  */
354
- parseRequirementsTxt(content, lockfilePath) {
356
+ async parseRequirementsTxt(content, lockfilePath, visited = /* @__PURE__ */ new Set()) {
355
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);
356
364
  for (const rawLine of content.split("\n")) {
357
365
  const line = rawLine.trim();
358
- if (!line || line.startsWith("#") || line.startsWith("-") || line.startsWith("--")) {
366
+ if (!line || line.startsWith("#")) {
359
367
  continue;
360
368
  }
361
- const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*(?:[~=!<>]=?)\s*(.+)$/);
362
- if (match) {
363
- const [, name, versionSpec] = match;
364
- const version = this.extractVersion(versionSpec);
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;
365
385
  if (name && version) {
366
386
  deps.push({
367
387
  name: this.normalizePipName(name),
@@ -372,6 +392,14 @@ var PipScanner = class {
372
392
  purl: this.buildPurl(name, version)
373
393
  });
374
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
+ );
375
403
  }
376
404
  }
377
405
  return deps;
@@ -430,15 +458,6 @@ var PipScanner = class {
430
458
  }
431
459
  return deps;
432
460
  }
433
- /**
434
- * Extracts the version number from a pip version specifier.
435
- * "1.2.3" → "1.2.3"
436
- * "1.2.3,<2.0" → "1.2.3"
437
- */
438
- extractVersion(spec) {
439
- const cleaned = spec.split(",")[0].trim();
440
- return cleaned;
441
- }
442
461
  /**
443
462
  * Normalizes a pip package name per PEP 503.
444
463
  * Converts to lowercase and replaces any run of [-_.] with a single hyphen.
@@ -473,61 +492,88 @@ var MavenScanner = class {
473
492
  return existsSync5(pomPath) ? pomPath : null;
474
493
  }
475
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();
476
498
  const depTreePath = path5.join(projectPath, "dependency-tree.txt");
477
499
  if (existsSync5(depTreePath)) {
478
500
  const content = await readFile5(depTreePath, "utf-8");
479
- const dependencies = this.parseDependencyList(content, depTreePath);
501
+ const dependencies = this.parseDependencyList(content, directDeps);
480
502
  return this.buildResult(projectPath, depTreePath, dependencies);
481
503
  }
482
504
  if (this.isMavenAvailable()) {
483
505
  const output = this.runMavenDependencyList(projectPath);
484
- const dependencies = this.parseDependencyList(output, "mvn dependency:list");
485
- return this.buildResult(projectPath, path5.join(projectPath, "pom.xml"), dependencies);
506
+ const dependencies = this.parseDependencyList(output, directDeps);
507
+ return this.buildResult(projectPath, pomPath, dependencies);
486
508
  }
487
509
  throw new LockfileParseError(
488
- path5.join(projectPath, "pom.xml"),
510
+ pomPath,
489
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"
490
512
  );
491
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
+ }
492
536
  /**
493
537
  * Parses Maven `dependency:list` output.
494
538
  *
495
539
  * Each dependency line has the format:
496
- * groupId:artifactId:type:version:scope
497
- * groupId:artifactId:type:classifier:version:scope
540
+ * groupId:artifactId:type:version:scope (5 parts)
541
+ * groupId:artifactId:type:classifier:version:scope (6 parts)
498
542
  *
499
543
  * Lines are typically indented with leading whitespace.
500
544
  */
501
- parseDependencyList(content, source) {
545
+ parseDependencyList(content, directDeps) {
502
546
  const deps = [];
503
- const depPattern = /^\s*([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):([a-z]+):(?:([a-zA-Z0-9._-]+):)?([a-zA-Z0-9._-]+):([a-z]+)/;
547
+ const seen = /* @__PURE__ */ new Set();
504
548
  for (const rawLine of content.split("\n")) {
505
549
  const line = rawLine.trim();
506
550
  if (!line) continue;
507
- const match = line.match(depPattern);
508
- if (match) {
509
- const groupId = match[1];
510
- const artifactId = match[2];
511
- const version = match[4] && match[5] ? match[5] : match[4] ?? match[5];
512
- const scope = match[4] && match[5] ? match[6] : match[5] && match[6] ? match[6] : match[5];
513
- const parts = line.split(":");
514
- if (parts.length >= 5) {
515
- const gId = parts[0].trim();
516
- const aId = parts[1];
517
- const ver = parts.length === 6 ? parts[4] : parts[3];
518
- const scp = parts.length === 6 ? parts[5] : parts[4];
519
- if (gId && aId && ver) {
520
- const name = `${gId}:${aId}`;
521
- deps.push({
522
- name,
523
- version: ver,
524
- direct: scp === "compile" || scp === "runtime" || scp === "provided",
525
- ecosystem: "maven",
526
- purl: this.buildPurl(gId, aId, ver)
527
- });
528
- }
529
- }
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];
530
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
+ });
531
577
  }
532
578
  return deps;
533
579
  }
@@ -811,6 +857,75 @@ var RubyScanner = class {
811
857
  }
812
858
  };
813
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
+
814
929
  // src/scanners/registry.ts
815
930
  var ScannerRegistry = class {
816
931
  scanners;
@@ -822,7 +937,8 @@ var ScannerRegistry = class {
822
937
  new PipScanner(),
823
938
  new MavenScanner(),
824
939
  new GoScanner(),
825
- new RubyScanner()
940
+ new RubyScanner(),
941
+ new ComposerScanner()
826
942
  ];
827
943
  }
828
944
  /**
@@ -983,7 +1099,10 @@ var OsvSource = class {
983
1099
  }
984
1100
  return allVulns;
985
1101
  }
986
- /** Uses OSV's /querybatch endpoint for efficient bulk lookups */
1102
+ /**
1103
+ * Uses OSV's /querybatch endpoint to get vulnerability IDs,
1104
+ * then fetches full details for each unique vulnerability.
1105
+ */
987
1106
  async queryBatch(dependencies) {
988
1107
  const queries = dependencies.map((dep) => ({
989
1108
  version: dep.version,
@@ -1001,18 +1120,65 @@ var OsvSource = class {
1001
1120
  throw new Error(`OSV API error: ${response.status} ${response.statusText}`);
1002
1121
  }
1003
1122
  const data = await response.json();
1004
- const vulnerabilities = [];
1123
+ const vulnIdToDeps = /* @__PURE__ */ new Map();
1005
1124
  for (let i = 0; i < data.results.length; i++) {
1006
1125
  const result = data.results[i];
1007
1126
  const dep = dependencies[i];
1008
1127
  if (result.vulns && result.vulns.length > 0) {
1009
1128
  for (const vuln of result.vulns) {
1010
- vulnerabilities.push(this.mapVulnerability(vuln, dep));
1129
+ const existing = vulnIdToDeps.get(vuln.id);
1130
+ if (existing) {
1131
+ existing.push(dep);
1132
+ } else {
1133
+ vulnIdToDeps.set(vuln.id, [dep]);
1134
+ }
1011
1135
  }
1012
1136
  }
1013
1137
  }
1138
+ if (vulnIdToDeps.size === 0) {
1139
+ return [];
1140
+ }
1141
+ const vulnIds = Array.from(vulnIdToDeps.keys());
1142
+ const fullVulns = await this.fetchVulnerabilityDetails(vulnIds);
1143
+ const vulnerabilities = [];
1144
+ for (const osvVuln of fullVulns) {
1145
+ const affectedDeps = vulnIdToDeps.get(osvVuln.id) ?? [];
1146
+ for (const dep of affectedDeps) {
1147
+ vulnerabilities.push(this.mapVulnerability(osvVuln, dep));
1148
+ }
1149
+ }
1014
1150
  return vulnerabilities;
1015
1151
  }
1152
+ /**
1153
+ * Fetches full vulnerability details from /v1/vulns/{id} for each ID.
1154
+ * Makes parallel requests for efficiency.
1155
+ */
1156
+ async fetchVulnerabilityDetails(vulnIds) {
1157
+ const results = [];
1158
+ const CONCURRENCY = 10;
1159
+ for (let i = 0; i < vulnIds.length; i += CONCURRENCY) {
1160
+ const batch = vulnIds.slice(i, i + CONCURRENCY);
1161
+ const promises = batch.map(async (id) => {
1162
+ try {
1163
+ const response = await this.fetchFn(`${OSV_API_BASE}/vulns/${encodeURIComponent(id)}`, {
1164
+ method: "GET",
1165
+ headers: { "Content-Type": "application/json" }
1166
+ });
1167
+ if (!response.ok) {
1168
+ console.warn(`Failed to fetch vulnerability ${id}: ${response.status}`);
1169
+ return null;
1170
+ }
1171
+ return await response.json();
1172
+ } catch (err) {
1173
+ console.warn(`Error fetching vulnerability ${id}:`, err);
1174
+ return null;
1175
+ }
1176
+ });
1177
+ const batchResults = await Promise.all(promises);
1178
+ results.push(...batchResults.filter((v) => v !== null));
1179
+ }
1180
+ return results;
1181
+ }
1016
1182
  /** Maps an OSV vulnerability record to our Vulnerability type */
1017
1183
  mapVulnerability(osvVuln, dep) {
1018
1184
  const cveId = this.extractCveId(osvVuln);
@@ -1063,12 +1229,75 @@ var OsvSource = class {
1063
1229
  }
1064
1230
  return { level: "UNKNOWN" };
1065
1231
  }
1066
- /** Parses CVSS v3 vector string to extract the base score */
1232
+ /** Parses CVSS v3 vector string to extract/calculate the base score */
1067
1233
  parseCvssScore(vectorOrScore) {
1068
1234
  const num = parseFloat(vectorOrScore);
1069
1235
  if (!isNaN(num) && num >= 0 && num <= 10) return num;
1236
+ if (vectorOrScore.startsWith("CVSS:3")) {
1237
+ return this.calculateCvss3Score(vectorOrScore);
1238
+ }
1070
1239
  return null;
1071
1240
  }
1241
+ /** Calculate CVSS v3.x base score from vector string */
1242
+ calculateCvss3Score(vector) {
1243
+ const metricValues = {
1244
+ AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 },
1245
+ // Attack Vector
1246
+ AC: { L: 0.77, H: 0.44 },
1247
+ // Attack Complexity
1248
+ PR: {
1249
+ // Privileges Required (varies by Scope)
1250
+ N_U: 0.85,
1251
+ L_U: 0.62,
1252
+ H_U: 0.27,
1253
+ N_C: 0.85,
1254
+ L_C: 0.68,
1255
+ H_C: 0.5
1256
+ },
1257
+ UI: { N: 0.85, R: 0.62 },
1258
+ // User Interaction
1259
+ C: { H: 0.56, L: 0.22, N: 0 },
1260
+ // Confidentiality Impact
1261
+ I: { H: 0.56, L: 0.22, N: 0 },
1262
+ // Integrity Impact
1263
+ A: { H: 0.56, L: 0.22, N: 0 }
1264
+ // Availability Impact
1265
+ };
1266
+ const parts = vector.split("/");
1267
+ const metrics = {};
1268
+ for (const part of parts) {
1269
+ const [key, value] = part.split(":");
1270
+ if (key && value) metrics[key] = value;
1271
+ }
1272
+ const av = metricValues.AV[metrics.AV];
1273
+ const ac = metricValues.AC[metrics.AC];
1274
+ const ui = metricValues.UI[metrics.UI];
1275
+ const scope = metrics.S;
1276
+ const c = metricValues.C[metrics.C];
1277
+ const i = metricValues.I[metrics.I];
1278
+ const a = metricValues.A[metrics.A];
1279
+ const prKey = `${metrics.PR}_${scope}`;
1280
+ const pr = metricValues.PR[prKey];
1281
+ if ([av, ac, pr, ui, c, i, a].some((v) => v === void 0)) {
1282
+ return null;
1283
+ }
1284
+ const iss = 1 - (1 - c) * (1 - i) * (1 - a);
1285
+ let impact;
1286
+ if (scope === "U") {
1287
+ impact = 6.42 * iss;
1288
+ } else {
1289
+ impact = 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15);
1290
+ }
1291
+ const exploitability = 8.22 * av * ac * pr * ui;
1292
+ if (impact <= 0) return 0;
1293
+ let baseScore;
1294
+ if (scope === "U") {
1295
+ baseScore = Math.min(impact + exploitability, 10);
1296
+ } else {
1297
+ baseScore = Math.min(1.08 * (impact + exploitability), 10);
1298
+ }
1299
+ return Math.ceil(baseScore * 10) / 10;
1300
+ }
1072
1301
  /** Converts a CVSS score (0-10) to a severity level */
1073
1302
  scoreToSeverity(score) {
1074
1303
  if (score >= 9) return "CRITICAL";
@@ -1118,7 +1347,8 @@ var OsvSource = class {
1118
1347
  maven: "Maven",
1119
1348
  pip: "PyPI",
1120
1349
  go: "Go",
1121
- ruby: "RubyGems"
1350
+ ruby: "RubyGems",
1351
+ composer: "Packagist"
1122
1352
  };
1123
1353
  return map[ecosystem] ?? ecosystem;
1124
1354
  }
@@ -1347,7 +1577,8 @@ var VerimuApiClient = class {
1347
1577
  nuget: "nuget",
1348
1578
  go: "gomod",
1349
1579
  cargo: "cargo",
1350
- ruby: "bundler"
1580
+ ruby: "bundler",
1581
+ composer: "composer"
1351
1582
  };
1352
1583
  return map[eco] ?? eco;
1353
1584
  }
@@ -1441,7 +1672,9 @@ function shouldFailCi(report, threshold) {
1441
1672
  }
1442
1673
 
1443
1674
  // src/cli.ts
1444
- var VERSION = "0.0.3";
1675
+ var require2 = createRequire(import.meta.url);
1676
+ var pkg = require2("../package.json");
1677
+ var VERSION = pkg.version ?? "0.0.0";
1445
1678
  var BRAND = `
1446
1679
  \u2566 \u2566\u250C\u2500\u2510\u252C\u2500\u2510\u252C\u250C\u252C\u2510\u252C \u252C
1447
1680
  \u255A\u2557\u2554\u255D\u251C\u2524 \u251C\u252C\u2518\u2502\u2502\u2502\u2502\u2502 \u2502
@@ -1603,7 +1836,7 @@ function printHelp() {
1603
1836
  npm (package-lock.json) pip (requirements.txt)
1604
1837
  Maven (pom.xml) NuGet (packages.lock.json)
1605
1838
  Cargo (Cargo.lock) Go (go.sum)
1606
- Ruby (Gemfile.lock)
1839
+ Ruby (Gemfile.lock) Composer (composer.lock)
1607
1840
 
1608
1841
  Learn more: https://verimu.com
1609
1842
  Dashboard: https://app.verimu.com