verimu 0.0.5 → 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
@@ -316,7 +316,7 @@ import { existsSync as existsSync4 } from "fs";
316
316
  import path4 from "path";
317
317
  var PipScanner = class {
318
318
  ecosystem = "pip";
319
- lockfileNames = ["requirements.txt", "Pipfile.lock"];
319
+ lockfileNames = ["Pipfile.lock", "requirements.txt"];
320
320
  async detect(projectPath) {
321
321
  for (const lockfile of this.lockfileNames) {
322
322
  const fullPath = path4.join(projectPath, lockfile);
@@ -331,7 +331,7 @@ var PipScanner = class {
331
331
  if (filename === "Pipfile.lock") {
332
332
  dependencies = this.parsePipfileLock(raw, lockfilePath);
333
333
  } else {
334
- dependencies = this.parseRequirementsTxt(raw, lockfilePath);
334
+ dependencies = await this.parseRequirementsTxt(raw, lockfilePath);
335
335
  }
336
336
  return {
337
337
  projectPath,
@@ -345,24 +345,43 @@ var PipScanner = class {
345
345
  * Parses `requirements.txt` format.
346
346
  *
347
347
  * Supports:
348
- * - `package==1.2.3` (pinned)
349
- * - `package>=1.2.0` (minimum — uses the specified version)
350
- * - `package~=1.2.0` (compatible release)
348
+ * - `package==1.2.3` (pinned) — REQUIRED
351
349
  * - Comments (`#`) and blank lines are skipped
352
- * - `-r other-file.txt` (include directive) — skipped for now
350
+ * - `-r other-file.txt` (include directive) — recursively parsed
353
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.
354
355
  */
355
- parseRequirementsTxt(content, lockfilePath) {
356
+ async parseRequirementsTxt(content, lockfilePath, visited = /* @__PURE__ */ new Set()) {
356
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);
357
364
  for (const rawLine of content.split("\n")) {
358
365
  const line = rawLine.trim();
359
- if (!line || line.startsWith("#") || line.startsWith("-") || line.startsWith("--")) {
366
+ if (!line || line.startsWith("#")) {
360
367
  continue;
361
368
  }
362
- const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*(?:[~=!<>]=?)\s*(.+)$/);
363
- if (match) {
364
- const [, name, versionSpec] = match;
365
- 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;
366
385
  if (name && version) {
367
386
  deps.push({
368
387
  name: this.normalizePipName(name),
@@ -373,6 +392,14 @@ var PipScanner = class {
373
392
  purl: this.buildPurl(name, version)
374
393
  });
375
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
+ );
376
403
  }
377
404
  }
378
405
  return deps;
@@ -431,15 +458,6 @@ var PipScanner = class {
431
458
  }
432
459
  return deps;
433
460
  }
434
- /**
435
- * Extracts the version number from a pip version specifier.
436
- * "1.2.3" → "1.2.3"
437
- * "1.2.3,<2.0" → "1.2.3"
438
- */
439
- extractVersion(spec) {
440
- const cleaned = spec.split(",")[0].trim();
441
- return cleaned;
442
- }
443
461
  /**
444
462
  * Normalizes a pip package name per PEP 503.
445
463
  * Converts to lowercase and replaces any run of [-_.] with a single hyphen.
@@ -474,61 +492,88 @@ var MavenScanner = class {
474
492
  return existsSync5(pomPath) ? pomPath : null;
475
493
  }
476
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();
477
498
  const depTreePath = path5.join(projectPath, "dependency-tree.txt");
478
499
  if (existsSync5(depTreePath)) {
479
500
  const content = await readFile5(depTreePath, "utf-8");
480
- const dependencies = this.parseDependencyList(content, depTreePath);
501
+ const dependencies = this.parseDependencyList(content, directDeps);
481
502
  return this.buildResult(projectPath, depTreePath, dependencies);
482
503
  }
483
504
  if (this.isMavenAvailable()) {
484
505
  const output = this.runMavenDependencyList(projectPath);
485
- const dependencies = this.parseDependencyList(output, "mvn dependency:list");
486
- return this.buildResult(projectPath, path5.join(projectPath, "pom.xml"), dependencies);
506
+ const dependencies = this.parseDependencyList(output, directDeps);
507
+ return this.buildResult(projectPath, pomPath, dependencies);
487
508
  }
488
509
  throw new LockfileParseError(
489
- path5.join(projectPath, "pom.xml"),
510
+ pomPath,
490
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"
491
512
  );
492
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
+ }
493
536
  /**
494
537
  * Parses Maven `dependency:list` output.
495
538
  *
496
539
  * Each dependency line has the format:
497
- * groupId:artifactId:type:version:scope
498
- * groupId:artifactId:type:classifier:version:scope
540
+ * groupId:artifactId:type:version:scope (5 parts)
541
+ * groupId:artifactId:type:classifier:version:scope (6 parts)
499
542
  *
500
543
  * Lines are typically indented with leading whitespace.
501
544
  */
502
- parseDependencyList(content, source) {
545
+ parseDependencyList(content, directDeps) {
503
546
  const deps = [];
504
- 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();
505
548
  for (const rawLine of content.split("\n")) {
506
549
  const line = rawLine.trim();
507
550
  if (!line) continue;
508
- const match = line.match(depPattern);
509
- if (match) {
510
- const groupId = match[1];
511
- const artifactId = match[2];
512
- const version = match[4] && match[5] ? match[5] : match[4] ?? match[5];
513
- const scope = match[4] && match[5] ? match[6] : match[5] && match[6] ? match[6] : match[5];
514
- const parts = line.split(":");
515
- if (parts.length >= 5) {
516
- const gId = parts[0].trim();
517
- const aId = parts[1];
518
- const ver = parts.length === 6 ? parts[4] : parts[3];
519
- const scp = parts.length === 6 ? parts[5] : parts[4];
520
- if (gId && aId && ver) {
521
- const name = `${gId}:${aId}`;
522
- deps.push({
523
- name,
524
- version: ver,
525
- direct: scp === "compile" || scp === "runtime" || scp === "provided",
526
- ecosystem: "maven",
527
- purl: this.buildPurl(gId, aId, ver)
528
- });
529
- }
530
- }
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];
531
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
+ });
532
577
  }
533
578
  return deps;
534
579
  }
@@ -1054,7 +1099,10 @@ var OsvSource = class {
1054
1099
  }
1055
1100
  return allVulns;
1056
1101
  }
1057
- /** 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
+ */
1058
1106
  async queryBatch(dependencies) {
1059
1107
  const queries = dependencies.map((dep) => ({
1060
1108
  version: dep.version,
@@ -1072,18 +1120,65 @@ var OsvSource = class {
1072
1120
  throw new Error(`OSV API error: ${response.status} ${response.statusText}`);
1073
1121
  }
1074
1122
  const data = await response.json();
1075
- const vulnerabilities = [];
1123
+ const vulnIdToDeps = /* @__PURE__ */ new Map();
1076
1124
  for (let i = 0; i < data.results.length; i++) {
1077
1125
  const result = data.results[i];
1078
1126
  const dep = dependencies[i];
1079
1127
  if (result.vulns && result.vulns.length > 0) {
1080
1128
  for (const vuln of result.vulns) {
1081
- 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
+ }
1082
1135
  }
1083
1136
  }
1084
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
+ }
1085
1150
  return vulnerabilities;
1086
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
+ }
1087
1182
  /** Maps an OSV vulnerability record to our Vulnerability type */
1088
1183
  mapVulnerability(osvVuln, dep) {
1089
1184
  const cveId = this.extractCveId(osvVuln);
@@ -1134,12 +1229,75 @@ var OsvSource = class {
1134
1229
  }
1135
1230
  return { level: "UNKNOWN" };
1136
1231
  }
1137
- /** Parses CVSS v3 vector string to extract the base score */
1232
+ /** Parses CVSS v3 vector string to extract/calculate the base score */
1138
1233
  parseCvssScore(vectorOrScore) {
1139
1234
  const num = parseFloat(vectorOrScore);
1140
1235
  if (!isNaN(num) && num >= 0 && num <= 10) return num;
1236
+ if (vectorOrScore.startsWith("CVSS:3")) {
1237
+ return this.calculateCvss3Score(vectorOrScore);
1238
+ }
1141
1239
  return null;
1142
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
+ }
1143
1301
  /** Converts a CVSS score (0-10) to a severity level */
1144
1302
  scoreToSeverity(score) {
1145
1303
  if (score >= 9) return "CRITICAL";