verimu 0.0.2 → 0.0.4
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 +30 -10
- package/dist/cli.mjs +1616 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.cjs +779 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +422 -4
- package/dist/index.d.ts +422 -4
- package/dist/index.mjs +768 -13
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -75,7 +75,8 @@ var PURL_TYPE_MAP = {
|
|
|
75
75
|
cargo: "cargo",
|
|
76
76
|
maven: "maven",
|
|
77
77
|
pip: "pypi",
|
|
78
|
-
go: "golang"
|
|
78
|
+
go: "golang",
|
|
79
|
+
ruby: "gem"
|
|
79
80
|
};
|
|
80
81
|
function buildPurl(name, version, ecosystem) {
|
|
81
82
|
const type = PURL_TYPE_MAP[ecosystem] || ecosystem;
|
|
@@ -93,6 +94,7 @@ function deriveSupplierName(packageName) {
|
|
|
93
94
|
|
|
94
95
|
// src/scan.ts
|
|
95
96
|
import { writeFile } from "fs/promises";
|
|
97
|
+
import { basename } from "path";
|
|
96
98
|
|
|
97
99
|
// src/scanners/npm/npm-scanner.ts
|
|
98
100
|
import { readFile } from "fs/promises";
|
|
@@ -110,7 +112,7 @@ var VerimuError = class extends Error {
|
|
|
110
112
|
var NoLockfileError = class extends VerimuError {
|
|
111
113
|
constructor(projectPath) {
|
|
112
114
|
super(
|
|
113
|
-
`No supported lockfile found in ${projectPath}. Supported: package-lock.json (npm), packages.lock.json (NuGet), Cargo.lock (Rust)`,
|
|
115
|
+
`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)`,
|
|
114
116
|
"NO_LOCKFILE"
|
|
115
117
|
);
|
|
116
118
|
this.name = "NoLockfileError";
|
|
@@ -246,26 +248,670 @@ var NpmScanner = class {
|
|
|
246
248
|
};
|
|
247
249
|
|
|
248
250
|
// src/scanners/nuget/nuget-scanner.ts
|
|
251
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
252
|
+
import { existsSync as existsSync2 } from "fs";
|
|
253
|
+
import path2 from "path";
|
|
249
254
|
var NugetScanner = class {
|
|
250
255
|
ecosystem = "nuget";
|
|
251
256
|
lockfileNames = ["packages.lock.json"];
|
|
252
|
-
async detect(
|
|
253
|
-
|
|
257
|
+
async detect(projectPath) {
|
|
258
|
+
const lockfilePath = path2.join(projectPath, "packages.lock.json");
|
|
259
|
+
return existsSync2(lockfilePath) ? lockfilePath : null;
|
|
260
|
+
}
|
|
261
|
+
async scan(projectPath, lockfilePath) {
|
|
262
|
+
const lockfileRaw = await readFile2(lockfilePath, "utf-8");
|
|
263
|
+
let lockfile;
|
|
264
|
+
try {
|
|
265
|
+
lockfile = JSON.parse(lockfileRaw);
|
|
266
|
+
} catch {
|
|
267
|
+
throw new LockfileParseError(lockfilePath, "Invalid JSON");
|
|
268
|
+
}
|
|
269
|
+
if (!lockfile.dependencies) {
|
|
270
|
+
throw new LockfileParseError(lockfilePath, 'Missing "dependencies" field');
|
|
271
|
+
}
|
|
272
|
+
const dependencies = this.parseLockfile(lockfile);
|
|
273
|
+
return {
|
|
274
|
+
projectPath,
|
|
275
|
+
ecosystem: "nuget",
|
|
276
|
+
dependencies,
|
|
277
|
+
lockfilePath,
|
|
278
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Parses packages.lock.json and extracts dependencies across all
|
|
283
|
+
* target frameworks. Deduplicates by package name (keeps highest version
|
|
284
|
+
* if the same package appears under multiple frameworks).
|
|
285
|
+
*/
|
|
286
|
+
parseLockfile(lockfile) {
|
|
287
|
+
const depMap = /* @__PURE__ */ new Map();
|
|
288
|
+
for (const [_framework, packages] of Object.entries(lockfile.dependencies)) {
|
|
289
|
+
for (const [name, info] of Object.entries(packages)) {
|
|
290
|
+
if (!info.resolved) continue;
|
|
291
|
+
const isDirect = info.type === "Direct";
|
|
292
|
+
const existing = depMap.get(name);
|
|
293
|
+
if (!existing) {
|
|
294
|
+
depMap.set(name, {
|
|
295
|
+
name,
|
|
296
|
+
version: info.resolved,
|
|
297
|
+
direct: isDirect,
|
|
298
|
+
ecosystem: "nuget",
|
|
299
|
+
purl: this.buildPurl(name, info.resolved)
|
|
300
|
+
});
|
|
301
|
+
} else if (isDirect && !existing.direct) {
|
|
302
|
+
existing.direct = true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return Array.from(depMap.values());
|
|
254
307
|
}
|
|
255
|
-
|
|
256
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Builds a purl for a NuGet package.
|
|
310
|
+
* NuGet purls are straightforward: pkg:nuget/Name@Version
|
|
311
|
+
*/
|
|
312
|
+
buildPurl(name, version) {
|
|
313
|
+
return `pkg:nuget/${name}@${version}`;
|
|
257
314
|
}
|
|
258
315
|
};
|
|
259
316
|
|
|
260
317
|
// src/scanners/cargo/cargo-scanner.ts
|
|
318
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
319
|
+
import { existsSync as existsSync3 } from "fs";
|
|
320
|
+
import path3 from "path";
|
|
261
321
|
var CargoScanner = class {
|
|
262
322
|
ecosystem = "cargo";
|
|
263
323
|
lockfileNames = ["Cargo.lock"];
|
|
264
|
-
async detect(
|
|
324
|
+
async detect(projectPath) {
|
|
325
|
+
const lockfilePath = path3.join(projectPath, "Cargo.lock");
|
|
326
|
+
return existsSync3(lockfilePath) ? lockfilePath : null;
|
|
327
|
+
}
|
|
328
|
+
async scan(projectPath, lockfilePath) {
|
|
329
|
+
const [lockfileRaw, cargoTomlRaw] = await Promise.all([
|
|
330
|
+
readFile3(lockfilePath, "utf-8"),
|
|
331
|
+
readFile3(path3.join(projectPath, "Cargo.toml"), "utf-8").catch(() => null)
|
|
332
|
+
]);
|
|
333
|
+
const packages = this.parseLockfile(lockfileRaw, lockfilePath);
|
|
334
|
+
const directNames = cargoTomlRaw ? this.parseCargoToml(cargoTomlRaw) : /* @__PURE__ */ new Set();
|
|
335
|
+
const rootName = packages.length > 0 ? packages[0].name : null;
|
|
336
|
+
const dependencies = [];
|
|
337
|
+
for (const pkg of packages) {
|
|
338
|
+
if (pkg.name === rootName && pkg.source === void 0) continue;
|
|
339
|
+
dependencies.push({
|
|
340
|
+
name: pkg.name,
|
|
341
|
+
version: pkg.version,
|
|
342
|
+
direct: directNames.has(pkg.name),
|
|
343
|
+
ecosystem: "cargo",
|
|
344
|
+
purl: this.buildPurl(pkg.name, pkg.version)
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
projectPath,
|
|
349
|
+
ecosystem: "cargo",
|
|
350
|
+
dependencies,
|
|
351
|
+
lockfilePath,
|
|
352
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Parses Cargo.lock by splitting on [[package]] blocks.
|
|
357
|
+
* This is a lightweight parser that handles the regular structure
|
|
358
|
+
* of Cargo.lock without needing a full TOML parser.
|
|
359
|
+
*/
|
|
360
|
+
parseLockfile(content, lockfilePath) {
|
|
361
|
+
const packages = [];
|
|
362
|
+
const blocks = content.split(/^\[\[package\]\]$/m);
|
|
363
|
+
for (const block of blocks) {
|
|
364
|
+
if (!block.trim()) continue;
|
|
365
|
+
const name = this.extractField(block, "name");
|
|
366
|
+
const version = this.extractField(block, "version");
|
|
367
|
+
const source = this.extractField(block, "source");
|
|
368
|
+
if (name && version) {
|
|
369
|
+
packages.push({ name, version, source: source || void 0 });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (packages.length === 0 && content.includes("[[package]]")) {
|
|
373
|
+
throw new LockfileParseError(lockfilePath, "Failed to parse any packages from Cargo.lock");
|
|
374
|
+
}
|
|
375
|
+
return packages;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Extracts a string field value from a TOML block.
|
|
379
|
+
* Handles: `name = "value"` format.
|
|
380
|
+
*/
|
|
381
|
+
extractField(block, fieldName) {
|
|
382
|
+
const regex = new RegExp(`^${fieldName}\\s*=\\s*"([^"]*)"`, "m");
|
|
383
|
+
const match = block.match(regex);
|
|
384
|
+
return match ? match[1] : null;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Parses Cargo.toml to extract direct dependency names.
|
|
388
|
+
* Looks for [dependencies] and [dev-dependencies] sections.
|
|
389
|
+
*/
|
|
390
|
+
parseCargoToml(content) {
|
|
391
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
392
|
+
let inDepsSection = false;
|
|
393
|
+
for (const rawLine of content.split("\n")) {
|
|
394
|
+
const line = rawLine.trim();
|
|
395
|
+
if (line.startsWith("[")) {
|
|
396
|
+
inDepsSection = line === "[dependencies]" || line === "[dev-dependencies]" || line === "[build-dependencies]";
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (inDepsSection && line && !line.startsWith("#")) {
|
|
400
|
+
const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/);
|
|
401
|
+
if (match) {
|
|
402
|
+
directNames.add(match[1]);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return directNames;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Builds a purl for a Cargo (crates.io) package.
|
|
410
|
+
*/
|
|
411
|
+
buildPurl(name, version) {
|
|
412
|
+
return `pkg:cargo/${name}@${version}`;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// src/scanners/pip/pip-scanner.ts
|
|
417
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
418
|
+
import { existsSync as existsSync4 } from "fs";
|
|
419
|
+
import path4 from "path";
|
|
420
|
+
var PipScanner = class {
|
|
421
|
+
ecosystem = "pip";
|
|
422
|
+
lockfileNames = ["requirements.txt", "Pipfile.lock"];
|
|
423
|
+
async detect(projectPath) {
|
|
424
|
+
for (const lockfile of this.lockfileNames) {
|
|
425
|
+
const fullPath = path4.join(projectPath, lockfile);
|
|
426
|
+
if (existsSync4(fullPath)) return fullPath;
|
|
427
|
+
}
|
|
265
428
|
return null;
|
|
266
429
|
}
|
|
267
|
-
async scan(
|
|
268
|
-
|
|
430
|
+
async scan(projectPath, lockfilePath) {
|
|
431
|
+
const raw = await readFile4(lockfilePath, "utf-8");
|
|
432
|
+
const filename = path4.basename(lockfilePath);
|
|
433
|
+
let dependencies;
|
|
434
|
+
if (filename === "Pipfile.lock") {
|
|
435
|
+
dependencies = this.parsePipfileLock(raw, lockfilePath);
|
|
436
|
+
} else {
|
|
437
|
+
dependencies = this.parseRequirementsTxt(raw, lockfilePath);
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
projectPath,
|
|
441
|
+
ecosystem: "pip",
|
|
442
|
+
dependencies,
|
|
443
|
+
lockfilePath,
|
|
444
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Parses `requirements.txt` format.
|
|
449
|
+
*
|
|
450
|
+
* Supports:
|
|
451
|
+
* - `package==1.2.3` (pinned)
|
|
452
|
+
* - `package>=1.2.0` (minimum — uses the specified version)
|
|
453
|
+
* - `package~=1.2.0` (compatible release)
|
|
454
|
+
* - Comments (`#`) and blank lines are skipped
|
|
455
|
+
* - `-r other-file.txt` (include directive) — skipped for now
|
|
456
|
+
* - `--index-url` and other pip flags — skipped
|
|
457
|
+
*/
|
|
458
|
+
parseRequirementsTxt(content, lockfilePath) {
|
|
459
|
+
const deps = [];
|
|
460
|
+
for (const rawLine of content.split("\n")) {
|
|
461
|
+
const line = rawLine.trim();
|
|
462
|
+
if (!line || line.startsWith("#") || line.startsWith("-") || line.startsWith("--")) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*(?:[~=!<>]=?)\s*(.+)$/);
|
|
466
|
+
if (match) {
|
|
467
|
+
const [, name, versionSpec] = match;
|
|
468
|
+
const version = this.extractVersion(versionSpec);
|
|
469
|
+
if (name && version) {
|
|
470
|
+
deps.push({
|
|
471
|
+
name: this.normalizePipName(name),
|
|
472
|
+
version,
|
|
473
|
+
direct: true,
|
|
474
|
+
// requirements.txt doesn't distinguish
|
|
475
|
+
ecosystem: "pip",
|
|
476
|
+
purl: this.buildPurl(name, version)
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return deps;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Parses `Pipfile.lock` (JSON format from Pipenv).
|
|
485
|
+
*
|
|
486
|
+
* Structure:
|
|
487
|
+
* ```json
|
|
488
|
+
* {
|
|
489
|
+
* "_meta": { ... },
|
|
490
|
+
* "default": {
|
|
491
|
+
* "requests": { "version": "==2.31.0", ... }
|
|
492
|
+
* },
|
|
493
|
+
* "develop": {
|
|
494
|
+
* "pytest": { "version": "==7.4.0", ... }
|
|
495
|
+
* }
|
|
496
|
+
* }
|
|
497
|
+
* ```
|
|
498
|
+
*/
|
|
499
|
+
parsePipfileLock(content, lockfilePath) {
|
|
500
|
+
let lockfile;
|
|
501
|
+
try {
|
|
502
|
+
lockfile = JSON.parse(content);
|
|
503
|
+
} catch {
|
|
504
|
+
throw new LockfileParseError(lockfilePath, "Invalid JSON in Pipfile.lock");
|
|
505
|
+
}
|
|
506
|
+
const deps = [];
|
|
507
|
+
if (lockfile.default) {
|
|
508
|
+
for (const [name, info] of Object.entries(lockfile.default)) {
|
|
509
|
+
const version = info.version?.replace(/^==/, "") ?? "";
|
|
510
|
+
if (version) {
|
|
511
|
+
deps.push({
|
|
512
|
+
name: this.normalizePipName(name),
|
|
513
|
+
version,
|
|
514
|
+
direct: true,
|
|
515
|
+
ecosystem: "pip",
|
|
516
|
+
purl: this.buildPurl(name, version)
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (lockfile.develop) {
|
|
522
|
+
for (const [name, info] of Object.entries(lockfile.develop)) {
|
|
523
|
+
const version = info.version?.replace(/^==/, "") ?? "";
|
|
524
|
+
if (version) {
|
|
525
|
+
deps.push({
|
|
526
|
+
name: this.normalizePipName(name),
|
|
527
|
+
version,
|
|
528
|
+
direct: true,
|
|
529
|
+
ecosystem: "pip",
|
|
530
|
+
purl: this.buildPurl(name, version)
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return deps;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Extracts the version number from a pip version specifier.
|
|
539
|
+
* "1.2.3" → "1.2.3"
|
|
540
|
+
* "1.2.3,<2.0" → "1.2.3"
|
|
541
|
+
*/
|
|
542
|
+
extractVersion(spec) {
|
|
543
|
+
const cleaned = spec.split(",")[0].trim();
|
|
544
|
+
return cleaned;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Normalizes a pip package name per PEP 503.
|
|
548
|
+
* Converts to lowercase and replaces any run of [-_.] with a single hyphen.
|
|
549
|
+
*/
|
|
550
|
+
normalizePipName(name) {
|
|
551
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Builds a purl for a PyPI package.
|
|
555
|
+
* Per purl spec, the type is "pypi" (not "pip").
|
|
556
|
+
*/
|
|
557
|
+
buildPurl(name, version) {
|
|
558
|
+
return `pkg:pypi/${this.normalizePipName(name)}@${version}`;
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// src/scanners/maven/maven-scanner.ts
|
|
563
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
564
|
+
import { existsSync as existsSync5 } from "fs";
|
|
565
|
+
import { execSync } from "child_process";
|
|
566
|
+
import path5 from "path";
|
|
567
|
+
var MavenScanner = class {
|
|
568
|
+
ecosystem = "maven";
|
|
569
|
+
lockfileNames = ["pom.xml"];
|
|
570
|
+
/** Allow injection for testing */
|
|
571
|
+
execSyncFn;
|
|
572
|
+
constructor(execSyncImpl) {
|
|
573
|
+
this.execSyncFn = execSyncImpl ?? execSync;
|
|
574
|
+
}
|
|
575
|
+
async detect(projectPath) {
|
|
576
|
+
const pomPath = path5.join(projectPath, "pom.xml");
|
|
577
|
+
return existsSync5(pomPath) ? pomPath : null;
|
|
578
|
+
}
|
|
579
|
+
async scan(projectPath, _lockfilePath) {
|
|
580
|
+
const depTreePath = path5.join(projectPath, "dependency-tree.txt");
|
|
581
|
+
if (existsSync5(depTreePath)) {
|
|
582
|
+
const content = await readFile5(depTreePath, "utf-8");
|
|
583
|
+
const dependencies = this.parseDependencyList(content, depTreePath);
|
|
584
|
+
return this.buildResult(projectPath, depTreePath, dependencies);
|
|
585
|
+
}
|
|
586
|
+
if (this.isMavenAvailable()) {
|
|
587
|
+
const output = this.runMavenDependencyList(projectPath);
|
|
588
|
+
const dependencies = this.parseDependencyList(output, "mvn dependency:list");
|
|
589
|
+
return this.buildResult(projectPath, path5.join(projectPath, "pom.xml"), dependencies);
|
|
590
|
+
}
|
|
591
|
+
throw new LockfileParseError(
|
|
592
|
+
path5.join(projectPath, "pom.xml"),
|
|
593
|
+
"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"
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Parses Maven `dependency:list` output.
|
|
598
|
+
*
|
|
599
|
+
* Each dependency line has the format:
|
|
600
|
+
* groupId:artifactId:type:version:scope
|
|
601
|
+
* groupId:artifactId:type:classifier:version:scope
|
|
602
|
+
*
|
|
603
|
+
* Lines are typically indented with leading whitespace.
|
|
604
|
+
*/
|
|
605
|
+
parseDependencyList(content, source) {
|
|
606
|
+
const deps = [];
|
|
607
|
+
const depPattern = /^\s*([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):([a-z]+):(?:([a-zA-Z0-9._-]+):)?([a-zA-Z0-9._-]+):([a-z]+)/;
|
|
608
|
+
for (const rawLine of content.split("\n")) {
|
|
609
|
+
const line = rawLine.trim();
|
|
610
|
+
if (!line) continue;
|
|
611
|
+
const match = line.match(depPattern);
|
|
612
|
+
if (match) {
|
|
613
|
+
const groupId = match[1];
|
|
614
|
+
const artifactId = match[2];
|
|
615
|
+
const version = match[4] && match[5] ? match[5] : match[4] ?? match[5];
|
|
616
|
+
const scope = match[4] && match[5] ? match[6] : match[5] && match[6] ? match[6] : match[5];
|
|
617
|
+
const parts = line.split(":");
|
|
618
|
+
if (parts.length >= 5) {
|
|
619
|
+
const gId = parts[0].trim();
|
|
620
|
+
const aId = parts[1];
|
|
621
|
+
const ver = parts.length === 6 ? parts[4] : parts[3];
|
|
622
|
+
const scp = parts.length === 6 ? parts[5] : parts[4];
|
|
623
|
+
if (gId && aId && ver) {
|
|
624
|
+
const name = `${gId}:${aId}`;
|
|
625
|
+
deps.push({
|
|
626
|
+
name,
|
|
627
|
+
version: ver,
|
|
628
|
+
direct: scp === "compile" || scp === "runtime" || scp === "provided",
|
|
629
|
+
ecosystem: "maven",
|
|
630
|
+
purl: this.buildPurl(gId, aId, ver)
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return deps;
|
|
637
|
+
}
|
|
638
|
+
/** Checks if `mvn` is available on PATH */
|
|
639
|
+
isMavenAvailable() {
|
|
640
|
+
try {
|
|
641
|
+
this.execSyncFn("mvn --version", { stdio: "pipe", timeout: 1e4 });
|
|
642
|
+
return true;
|
|
643
|
+
} catch {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Runs `mvn dependency:list` and returns the output.
|
|
649
|
+
*/
|
|
650
|
+
runMavenDependencyList(projectPath) {
|
|
651
|
+
try {
|
|
652
|
+
const output = this.execSyncFn(
|
|
653
|
+
"mvn dependency:list -DoutputType=text -DincludeScope=compile",
|
|
654
|
+
{
|
|
655
|
+
cwd: projectPath,
|
|
656
|
+
stdio: "pipe",
|
|
657
|
+
timeout: 12e4,
|
|
658
|
+
// 2 minute timeout
|
|
659
|
+
encoding: "utf-8"
|
|
660
|
+
}
|
|
661
|
+
);
|
|
662
|
+
return output.toString();
|
|
663
|
+
} catch (err) {
|
|
664
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
665
|
+
throw new LockfileParseError(
|
|
666
|
+
path5.join(projectPath, "pom.xml"),
|
|
667
|
+
`Failed to run 'mvn dependency:list': ${message}`
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Builds a purl for a Maven package.
|
|
673
|
+
* Format: pkg:maven/groupId/artifactId@version
|
|
674
|
+
*/
|
|
675
|
+
buildPurl(groupId, artifactId, version) {
|
|
676
|
+
return `pkg:maven/${groupId}/${artifactId}@${version}`;
|
|
677
|
+
}
|
|
678
|
+
buildResult(projectPath, lockfilePath, dependencies) {
|
|
679
|
+
return {
|
|
680
|
+
projectPath,
|
|
681
|
+
ecosystem: "maven",
|
|
682
|
+
dependencies,
|
|
683
|
+
lockfilePath,
|
|
684
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// src/scanners/go/go-scanner.ts
|
|
690
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
691
|
+
import { existsSync as existsSync6 } from "fs";
|
|
692
|
+
import path6 from "path";
|
|
693
|
+
var GoScanner = class {
|
|
694
|
+
ecosystem = "go";
|
|
695
|
+
lockfileNames = ["go.sum"];
|
|
696
|
+
async detect(projectPath) {
|
|
697
|
+
const goSumPath = path6.join(projectPath, "go.sum");
|
|
698
|
+
return existsSync6(goSumPath) ? goSumPath : null;
|
|
699
|
+
}
|
|
700
|
+
async scan(projectPath, lockfilePath) {
|
|
701
|
+
const [goSumRaw, goModRaw] = await Promise.all([
|
|
702
|
+
readFile6(lockfilePath, "utf-8"),
|
|
703
|
+
readFile6(path6.join(projectPath, "go.mod"), "utf-8").catch(() => null)
|
|
704
|
+
]);
|
|
705
|
+
const { directNames, indirectNames } = goModRaw ? this.parseGoMod(goModRaw) : { directNames: /* @__PURE__ */ new Set(), indirectNames: /* @__PURE__ */ new Set() };
|
|
706
|
+
const dependencies = this.parseGoSum(goSumRaw, lockfilePath, directNames, indirectNames);
|
|
707
|
+
return {
|
|
708
|
+
projectPath,
|
|
709
|
+
ecosystem: "go",
|
|
710
|
+
dependencies,
|
|
711
|
+
lockfilePath,
|
|
712
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Parses go.sum and extracts unique module dependencies.
|
|
717
|
+
*
|
|
718
|
+
* Each module may appear twice in go.sum (once for the source archive,
|
|
719
|
+
* once for go.mod). We deduplicate by module path + version, keeping
|
|
720
|
+
* only the `h1:` entry (not the `/go.mod` entry).
|
|
721
|
+
*/
|
|
722
|
+
parseGoSum(content, lockfilePath, directNames, indirectNames) {
|
|
723
|
+
const depMap = /* @__PURE__ */ new Map();
|
|
724
|
+
for (const rawLine of content.split("\n")) {
|
|
725
|
+
const line = rawLine.trim();
|
|
726
|
+
if (!line) continue;
|
|
727
|
+
const parts = line.split(/\s+/);
|
|
728
|
+
if (parts.length < 3) continue;
|
|
729
|
+
const modulePath = parts[0];
|
|
730
|
+
let version = parts[1];
|
|
731
|
+
if (version.endsWith("/go.mod")) continue;
|
|
732
|
+
version = version.replace(/\+incompatible$/, "");
|
|
733
|
+
const key = `${modulePath}@${version}`;
|
|
734
|
+
if (depMap.has(key)) continue;
|
|
735
|
+
const isDirect = directNames.size > 0 || indirectNames.size > 0 ? directNames.has(modulePath) || (!indirectNames.has(modulePath) && !directNames.has(modulePath) ? false : directNames.has(modulePath)) : true;
|
|
736
|
+
depMap.set(key, {
|
|
737
|
+
name: modulePath,
|
|
738
|
+
version,
|
|
739
|
+
direct: isDirect,
|
|
740
|
+
ecosystem: "go",
|
|
741
|
+
purl: this.buildPurl(modulePath, version)
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
return Array.from(depMap.values());
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Parses go.mod to extract direct and indirect dependency names.
|
|
748
|
+
*
|
|
749
|
+
* Handles both single-line and block `require` directives:
|
|
750
|
+
* ```
|
|
751
|
+
* require github.com/pkg/errors v0.9.1
|
|
752
|
+
*
|
|
753
|
+
* require (
|
|
754
|
+
* github.com/gin-gonic/gin v1.9.1
|
|
755
|
+
* golang.org/x/text v0.14.0 // indirect
|
|
756
|
+
* )
|
|
757
|
+
* ```
|
|
758
|
+
*/
|
|
759
|
+
parseGoMod(content) {
|
|
760
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
761
|
+
const indirectNames = /* @__PURE__ */ new Set();
|
|
762
|
+
let inRequireBlock = false;
|
|
763
|
+
for (const rawLine of content.split("\n")) {
|
|
764
|
+
const line = rawLine.trim();
|
|
765
|
+
if (line.startsWith("require ") && !line.includes("(")) {
|
|
766
|
+
const match = line.match(/^require\s+(\S+)\s+\S+(.*)$/);
|
|
767
|
+
if (match) {
|
|
768
|
+
const modulePath = match[1];
|
|
769
|
+
const rest = match[2];
|
|
770
|
+
if (rest.includes("// indirect")) {
|
|
771
|
+
indirectNames.add(modulePath);
|
|
772
|
+
} else {
|
|
773
|
+
directNames.add(modulePath);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (line === "require (" || line.startsWith("require (")) {
|
|
779
|
+
inRequireBlock = true;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (inRequireBlock && line === ")") {
|
|
783
|
+
inRequireBlock = false;
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
if (inRequireBlock && line && !line.startsWith("//")) {
|
|
787
|
+
const match = line.match(/^(\S+)\s+\S+(.*)$/);
|
|
788
|
+
if (match) {
|
|
789
|
+
const modulePath = match[1];
|
|
790
|
+
const rest = match[2];
|
|
791
|
+
if (rest.includes("// indirect")) {
|
|
792
|
+
indirectNames.add(modulePath);
|
|
793
|
+
} else {
|
|
794
|
+
directNames.add(modulePath);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return { directNames, indirectNames };
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Builds a purl for a Go module.
|
|
803
|
+
*
|
|
804
|
+
* Per purl spec, the type is "golang" and the module path
|
|
805
|
+
* uses `/` separators (no encoding needed for path segments).
|
|
806
|
+
*
|
|
807
|
+
* Example: `pkg:golang/github.com/gin-gonic/gin@v1.9.1`
|
|
808
|
+
*/
|
|
809
|
+
buildPurl(modulePath, version) {
|
|
810
|
+
return `pkg:golang/${modulePath}@${version}`;
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
// src/scanners/ruby/ruby-scanner.ts
|
|
815
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
816
|
+
import { existsSync as existsSync7 } from "fs";
|
|
817
|
+
import path7 from "path";
|
|
818
|
+
var RubyScanner = class {
|
|
819
|
+
ecosystem = "ruby";
|
|
820
|
+
lockfileNames = ["Gemfile.lock"];
|
|
821
|
+
async detect(projectPath) {
|
|
822
|
+
const lockfilePath = path7.join(projectPath, "Gemfile.lock");
|
|
823
|
+
return existsSync7(lockfilePath) ? lockfilePath : null;
|
|
824
|
+
}
|
|
825
|
+
async scan(projectPath, lockfilePath) {
|
|
826
|
+
const content = await readFile7(lockfilePath, "utf-8");
|
|
827
|
+
const specs = this.parseSpecs(content, lockfilePath);
|
|
828
|
+
const directNames = this.parseDependencies(content);
|
|
829
|
+
const dependencies = specs.map(({ name, version }) => ({
|
|
830
|
+
name,
|
|
831
|
+
version,
|
|
832
|
+
direct: directNames.has(name),
|
|
833
|
+
ecosystem: "ruby",
|
|
834
|
+
purl: `pkg:gem/${name}@${version}`
|
|
835
|
+
}));
|
|
836
|
+
return {
|
|
837
|
+
projectPath,
|
|
838
|
+
ecosystem: "ruby",
|
|
839
|
+
dependencies,
|
|
840
|
+
lockfilePath,
|
|
841
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Parses the GEM > specs section to extract all resolved gems.
|
|
846
|
+
*
|
|
847
|
+
* Gems at the top level of the specs section (indented 4 spaces) are
|
|
848
|
+
* resolved packages. Their sub-dependencies (indented 6+ spaces) are
|
|
849
|
+
* constraints, not separate entries — those sub-deps appear as their
|
|
850
|
+
* own top-level spec entries elsewhere.
|
|
851
|
+
*
|
|
852
|
+
* Format: ` gem-name (1.2.3)`
|
|
853
|
+
*/
|
|
854
|
+
parseSpecs(content, lockfilePath) {
|
|
855
|
+
const gems = [];
|
|
856
|
+
let inGemSection = false;
|
|
857
|
+
let inSpecs = false;
|
|
858
|
+
for (const rawLine of content.split("\n")) {
|
|
859
|
+
const line = rawLine;
|
|
860
|
+
if (line.length > 0 && line[0] !== " ") {
|
|
861
|
+
if (line.startsWith("GEM")) {
|
|
862
|
+
inGemSection = true;
|
|
863
|
+
inSpecs = false;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
inGemSection = false;
|
|
867
|
+
inSpecs = false;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (inGemSection && line.trimStart().startsWith("specs:")) {
|
|
871
|
+
inSpecs = true;
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (!inSpecs) continue;
|
|
875
|
+
const match = line.match(/^ {4}(\S+)\s+\(([^)]+)\)$/);
|
|
876
|
+
if (match) {
|
|
877
|
+
const [, name, version] = match;
|
|
878
|
+
gems.push({ name, version });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (gems.length === 0) {
|
|
882
|
+
throw new LockfileParseError(
|
|
883
|
+
lockfilePath,
|
|
884
|
+
"No gems found in GEM specs section"
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
return gems;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Parses the DEPENDENCIES section to get direct dependency names.
|
|
891
|
+
*
|
|
892
|
+
* Format: ` gem-name (>= 1.0)` or ` gem-name`
|
|
893
|
+
* The version constraint is optional and we only need the name.
|
|
894
|
+
*/
|
|
895
|
+
parseDependencies(content) {
|
|
896
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
897
|
+
let inDependencies = false;
|
|
898
|
+
for (const rawLine of content.split("\n")) {
|
|
899
|
+
const line = rawLine;
|
|
900
|
+
if (line.length > 0 && line[0] !== " ") {
|
|
901
|
+
if (line.startsWith("DEPENDENCIES")) {
|
|
902
|
+
inDependencies = true;
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
if (inDependencies) break;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (!inDependencies) continue;
|
|
909
|
+
const match = line.match(/^ {2}(\S+?)!?\s*(?:\(|$)/);
|
|
910
|
+
if (match) {
|
|
911
|
+
directNames.add(match[1]);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return directNames;
|
|
269
915
|
}
|
|
270
916
|
};
|
|
271
917
|
|
|
@@ -276,8 +922,11 @@ var ScannerRegistry = class {
|
|
|
276
922
|
this.scanners = [
|
|
277
923
|
new NpmScanner(),
|
|
278
924
|
new NugetScanner(),
|
|
279
|
-
new CargoScanner()
|
|
280
|
-
|
|
925
|
+
new CargoScanner(),
|
|
926
|
+
new PipScanner(),
|
|
927
|
+
new MavenScanner(),
|
|
928
|
+
new GoScanner(),
|
|
929
|
+
new RubyScanner()
|
|
281
930
|
];
|
|
282
931
|
}
|
|
283
932
|
/**
|
|
@@ -572,7 +1221,8 @@ var OsvSource = class {
|
|
|
572
1221
|
cargo: "crates.io",
|
|
573
1222
|
maven: "Maven",
|
|
574
1223
|
pip: "PyPI",
|
|
575
|
-
go: "Go"
|
|
1224
|
+
go: "Go",
|
|
1225
|
+
ruby: "RubyGems"
|
|
576
1226
|
};
|
|
577
1227
|
return map[ecosystem] ?? ecosystem;
|
|
578
1228
|
}
|
|
@@ -737,6 +1387,76 @@ function severityBadge(s) {
|
|
|
737
1387
|
return badges[s] ?? "[???] ";
|
|
738
1388
|
}
|
|
739
1389
|
|
|
1390
|
+
// src/api/client.ts
|
|
1391
|
+
var DEFAULT_API_BASE = "https://api.verimu.com";
|
|
1392
|
+
var VerimuApiClient = class {
|
|
1393
|
+
baseUrl;
|
|
1394
|
+
apiKey;
|
|
1395
|
+
constructor(apiKey, baseUrl) {
|
|
1396
|
+
this.apiKey = apiKey;
|
|
1397
|
+
this.baseUrl = (baseUrl ?? DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Upsert a project — finds by name or creates it.
|
|
1401
|
+
* Used so `npx verimu` auto-registers projects without manual dashboard setup.
|
|
1402
|
+
*/
|
|
1403
|
+
async upsertProject(opts) {
|
|
1404
|
+
const res = await fetch(`${this.baseUrl}/api/projects/upsert`, {
|
|
1405
|
+
method: "POST",
|
|
1406
|
+
headers: this.headers(),
|
|
1407
|
+
body: JSON.stringify({
|
|
1408
|
+
name: opts.name,
|
|
1409
|
+
ecosystem: this.mapEcosystem(opts.ecosystem),
|
|
1410
|
+
repository_url: opts.repositoryUrl ?? null,
|
|
1411
|
+
platform: opts.platform ?? null
|
|
1412
|
+
})
|
|
1413
|
+
});
|
|
1414
|
+
if (!res.ok) {
|
|
1415
|
+
const body = await res.text();
|
|
1416
|
+
throw new Error(`Verimu API: upsert project failed (${res.status}): ${body}`);
|
|
1417
|
+
}
|
|
1418
|
+
return res.json();
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Upload a CycloneDX SBOM to a project and trigger CVE scanning.
|
|
1422
|
+
*/
|
|
1423
|
+
async uploadSbom(projectId, sbomContent) {
|
|
1424
|
+
const sbomJson = JSON.parse(sbomContent);
|
|
1425
|
+
const res = await fetch(`${this.baseUrl}/api/projects/${projectId}/scan`, {
|
|
1426
|
+
method: "POST",
|
|
1427
|
+
headers: this.headers(),
|
|
1428
|
+
body: JSON.stringify(sbomJson)
|
|
1429
|
+
});
|
|
1430
|
+
if (!res.ok) {
|
|
1431
|
+
const body = await res.text();
|
|
1432
|
+
throw new Error(`Verimu API: upload SBOM failed (${res.status}): ${body}`);
|
|
1433
|
+
}
|
|
1434
|
+
return res.json();
|
|
1435
|
+
}
|
|
1436
|
+
headers() {
|
|
1437
|
+
return {
|
|
1438
|
+
"Content-Type": "application/json",
|
|
1439
|
+
"X-API-Key": this.apiKey
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Maps internal ecosystem names to what the backend expects.
|
|
1444
|
+
* Currently 1:1, but keeps the mapping explicit.
|
|
1445
|
+
*/
|
|
1446
|
+
mapEcosystem(eco) {
|
|
1447
|
+
const map = {
|
|
1448
|
+
npm: "npm",
|
|
1449
|
+
pip: "pip",
|
|
1450
|
+
maven: "maven",
|
|
1451
|
+
nuget: "nuget",
|
|
1452
|
+
go: "gomod",
|
|
1453
|
+
cargo: "cargo",
|
|
1454
|
+
ruby: "bundler"
|
|
1455
|
+
};
|
|
1456
|
+
return map[eco] ?? eco;
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
|
|
740
1460
|
// src/scan.ts
|
|
741
1461
|
async function scan(config) {
|
|
742
1462
|
const {
|
|
@@ -781,8 +1501,35 @@ async function scan(config) {
|
|
|
781
1501
|
summary,
|
|
782
1502
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
783
1503
|
};
|
|
1504
|
+
if (config.apiKey) {
|
|
1505
|
+
try {
|
|
1506
|
+
const uploadResult = await uploadToVerimu(report, config);
|
|
1507
|
+
report.upload = uploadResult;
|
|
1508
|
+
} catch {
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
784
1511
|
return report;
|
|
785
1512
|
}
|
|
1513
|
+
async function uploadToVerimu(report, config) {
|
|
1514
|
+
if (!config.apiKey) {
|
|
1515
|
+
throw new Error("API key required for upload");
|
|
1516
|
+
}
|
|
1517
|
+
const client = new VerimuApiClient(config.apiKey, config.apiBaseUrl);
|
|
1518
|
+
const projectName = basename(config.projectPath);
|
|
1519
|
+
const upsertRes = await client.upsertProject({
|
|
1520
|
+
name: projectName,
|
|
1521
|
+
ecosystem: report.project.ecosystem
|
|
1522
|
+
});
|
|
1523
|
+
const projectId = upsertRes.project.id;
|
|
1524
|
+
const scanRes = await client.uploadSbom(projectId, report.sbom.content);
|
|
1525
|
+
return {
|
|
1526
|
+
projectId,
|
|
1527
|
+
projectCreated: upsertRes.created,
|
|
1528
|
+
totalDependencies: scanRes.summary.total_dependencies,
|
|
1529
|
+
vulnerableDependencies: scanRes.summary.vulnerable_dependencies,
|
|
1530
|
+
dashboardUrl: `https://app.verimu.com/dashboard/projects/${projectId}`
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
786
1533
|
function shouldFailCi(report, threshold) {
|
|
787
1534
|
const severityOrder2 = {
|
|
788
1535
|
CRITICAL: 0,
|
|
@@ -802,19 +1549,27 @@ function printReport(report) {
|
|
|
802
1549
|
}
|
|
803
1550
|
export {
|
|
804
1551
|
ApiKeyRequiredError,
|
|
1552
|
+
CargoScanner,
|
|
805
1553
|
ConsoleReporter,
|
|
806
1554
|
CveAggregator,
|
|
807
1555
|
CveSourceError,
|
|
808
1556
|
CycloneDxGenerator,
|
|
1557
|
+
GoScanner,
|
|
809
1558
|
LockfileParseError,
|
|
1559
|
+
MavenScanner,
|
|
810
1560
|
NoLockfileError,
|
|
811
1561
|
NpmScanner,
|
|
1562
|
+
NugetScanner,
|
|
812
1563
|
OsvSource,
|
|
1564
|
+
PipScanner,
|
|
1565
|
+
RubyScanner,
|
|
813
1566
|
ScannerRegistry,
|
|
1567
|
+
VerimuApiClient,
|
|
814
1568
|
VerimuError,
|
|
815
1569
|
generateSbom,
|
|
816
1570
|
printReport,
|
|
817
1571
|
scan,
|
|
818
|
-
shouldFailCi
|
|
1572
|
+
shouldFailCi,
|
|
1573
|
+
uploadToVerimu
|
|
819
1574
|
};
|
|
820
1575
|
//# sourceMappingURL=index.mjs.map
|