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 +40 -2
- package/dist/cli.mjs +304 -71
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +298 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +47 -16
- package/dist/index.d.ts +47 -16
- package/dist/index.mjs +293 -61
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -2
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/
|
|
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/
|
|
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
|
|
62
|
-
for (const name of Object.keys(
|
|
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(
|
|
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
|
|
234
|
-
if (
|
|
234
|
+
for (const pkg2 of packages) {
|
|
235
|
+
if (pkg2.name === rootName && pkg2.source === void 0) continue;
|
|
235
236
|
dependencies.push({
|
|
236
|
-
name:
|
|
237
|
-
version:
|
|
238
|
-
direct: directNames.has(
|
|
237
|
+
name: pkg2.name,
|
|
238
|
+
version: pkg2.version,
|
|
239
|
+
direct: directNames.has(pkg2.name),
|
|
239
240
|
ecosystem: "cargo",
|
|
240
|
-
purl: this.buildPurl(
|
|
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 = ["
|
|
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) —
|
|
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("#")
|
|
366
|
+
if (!line || line.startsWith("#")) {
|
|
359
367
|
continue;
|
|
360
368
|
}
|
|
361
|
-
const
|
|
362
|
-
if (
|
|
363
|
-
const
|
|
364
|
-
|
|
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,
|
|
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,
|
|
485
|
-
return this.buildResult(projectPath,
|
|
506
|
+
const dependencies = this.parseDependencyList(output, directDeps);
|
|
507
|
+
return this.buildResult(projectPath, pomPath, dependencies);
|
|
486
508
|
}
|
|
487
509
|
throw new LockfileParseError(
|
|
488
|
-
|
|
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,
|
|
545
|
+
parseDependencyList(content, directDeps) {
|
|
502
546
|
const deps = [];
|
|
503
|
-
const
|
|
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
|
|
508
|
-
if (
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|