verimu 0.0.20 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +1663 -2
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1657 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +498 -1
- package/dist/index.d.ts +498 -1
- package/dist/index.mjs +1651 -35
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
package/dist/cli.mjs
CHANGED
|
@@ -16979,6 +16979,8 @@ function buildSnippet(params) {
|
|
|
16979
16979
|
const startLine = Math.max(1, centerLine - numContextLines);
|
|
16980
16980
|
const endLine = Math.min(lines.length || 1, centerLine + numContextLines);
|
|
16981
16981
|
const code = lines.slice(startLine - 1, endLine).join("\n");
|
|
16982
|
+
const highlightOffset = centerLine - startLine;
|
|
16983
|
+
const highlight = [highlightOffset, highlightOffset];
|
|
16982
16984
|
return {
|
|
16983
16985
|
filePath: relative(projectPath, filePath).split(sep).join("/"),
|
|
16984
16986
|
startLine,
|
|
@@ -16986,7 +16988,8 @@ function buildSnippet(params) {
|
|
|
16986
16988
|
code,
|
|
16987
16989
|
matchKind,
|
|
16988
16990
|
calledSymbol,
|
|
16989
|
-
confidence
|
|
16991
|
+
confidence,
|
|
16992
|
+
highlight
|
|
16990
16993
|
};
|
|
16991
16994
|
}
|
|
16992
16995
|
function dedupeSnippets(snippets) {
|
|
@@ -18742,10 +18745,12 @@ async function uploadToVerimu(report, config) {
|
|
|
18742
18745
|
throw new Error("API key required for upload");
|
|
18743
18746
|
}
|
|
18744
18747
|
const client = new VerimuApiClient(config.apiKey, config.apiBaseUrl);
|
|
18745
|
-
const projectName = basename(config.projectPath);
|
|
18748
|
+
const projectName = config.uploadProjectName ?? basename(config.projectPath);
|
|
18746
18749
|
const upsertRes = await client.upsertProject({
|
|
18747
18750
|
name: projectName,
|
|
18748
18751
|
ecosystem: report.project.ecosystem,
|
|
18752
|
+
repositoryUrl: config.repositoryUrl,
|
|
18753
|
+
platform: config.platform,
|
|
18749
18754
|
groupName: config.groupName
|
|
18750
18755
|
});
|
|
18751
18756
|
const projectId = upsertRes.project.id;
|
|
@@ -19267,6 +19272,1428 @@ Discovering projects in ${config.projectPath}...`);
|
|
|
19267
19272
|
}
|
|
19268
19273
|
};
|
|
19269
19274
|
|
|
19275
|
+
// src/gitlab/client.ts
|
|
19276
|
+
import { execSync as execSync2 } from "child_process";
|
|
19277
|
+
import { mkdtempSync, rmSync, existsSync as existsSync15 } from "fs";
|
|
19278
|
+
import { join as join4 } from "path";
|
|
19279
|
+
import { tmpdir } from "os";
|
|
19280
|
+
var GitLabClient = class {
|
|
19281
|
+
baseUrl;
|
|
19282
|
+
token;
|
|
19283
|
+
apiUrl;
|
|
19284
|
+
constructor(baseUrl, token) {
|
|
19285
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
19286
|
+
this.token = token;
|
|
19287
|
+
this.apiUrl = `${this.baseUrl}/api/v4`;
|
|
19288
|
+
}
|
|
19289
|
+
// ─── Project Listing ────────────────────────────────────────
|
|
19290
|
+
/**
|
|
19291
|
+
* Lists all accessible projects, paginated.
|
|
19292
|
+
* Returns all pages concatenated.
|
|
19293
|
+
*/
|
|
19294
|
+
async listAllProjects(options) {
|
|
19295
|
+
const perPage = options?.perPage ?? 100;
|
|
19296
|
+
const maxPages = options?.maxPages ?? 100;
|
|
19297
|
+
const allProjects = [];
|
|
19298
|
+
let page = 1;
|
|
19299
|
+
while (page <= maxPages) {
|
|
19300
|
+
const params = new URLSearchParams({
|
|
19301
|
+
per_page: String(perPage),
|
|
19302
|
+
page: String(page),
|
|
19303
|
+
order_by: "last_activity_at",
|
|
19304
|
+
sort: "desc",
|
|
19305
|
+
simple: "false"
|
|
19306
|
+
});
|
|
19307
|
+
if (options?.archived !== void 0) {
|
|
19308
|
+
params.set("archived", String(options.archived));
|
|
19309
|
+
}
|
|
19310
|
+
const url = `${this.apiUrl}/projects?${params.toString()}`;
|
|
19311
|
+
const projects = await this.fetch(url);
|
|
19312
|
+
if (projects.length === 0) break;
|
|
19313
|
+
allProjects.push(...projects);
|
|
19314
|
+
page++;
|
|
19315
|
+
if (projects.length < perPage) break;
|
|
19316
|
+
}
|
|
19317
|
+
return allProjects;
|
|
19318
|
+
}
|
|
19319
|
+
/**
|
|
19320
|
+
* Lists projects within a specific group (and its subgroups).
|
|
19321
|
+
*/
|
|
19322
|
+
async listGroupProjects(groupPath, options) {
|
|
19323
|
+
const perPage = options?.perPage ?? 100;
|
|
19324
|
+
const includeSubgroups = options?.includeSubgroups ?? true;
|
|
19325
|
+
const allProjects = [];
|
|
19326
|
+
let page = 1;
|
|
19327
|
+
while (true) {
|
|
19328
|
+
const params = new URLSearchParams({
|
|
19329
|
+
per_page: String(perPage),
|
|
19330
|
+
page: String(page),
|
|
19331
|
+
include_subgroups: String(includeSubgroups),
|
|
19332
|
+
order_by: "last_activity_at",
|
|
19333
|
+
sort: "desc"
|
|
19334
|
+
});
|
|
19335
|
+
const encoded = encodeURIComponent(groupPath);
|
|
19336
|
+
const url = `${this.apiUrl}/groups/${encoded}/projects?${params.toString()}`;
|
|
19337
|
+
const projects = await this.fetch(url);
|
|
19338
|
+
if (projects.length === 0) break;
|
|
19339
|
+
allProjects.push(...projects);
|
|
19340
|
+
page++;
|
|
19341
|
+
if (projects.length < perPage) break;
|
|
19342
|
+
}
|
|
19343
|
+
return allProjects;
|
|
19344
|
+
}
|
|
19345
|
+
/**
|
|
19346
|
+
* Lists all groups accessible to the token.
|
|
19347
|
+
*/
|
|
19348
|
+
async listGroups() {
|
|
19349
|
+
const allGroups = [];
|
|
19350
|
+
let page = 1;
|
|
19351
|
+
while (true) {
|
|
19352
|
+
const params = new URLSearchParams({
|
|
19353
|
+
per_page: "100",
|
|
19354
|
+
page: String(page)
|
|
19355
|
+
});
|
|
19356
|
+
const url = `${this.apiUrl}/groups?${params.toString()}`;
|
|
19357
|
+
const groups = await this.fetch(url);
|
|
19358
|
+
if (groups.length === 0) break;
|
|
19359
|
+
allGroups.push(...groups);
|
|
19360
|
+
page++;
|
|
19361
|
+
if (groups.length < 100) break;
|
|
19362
|
+
}
|
|
19363
|
+
return allGroups;
|
|
19364
|
+
}
|
|
19365
|
+
// ─── Cloning ────────────────────────────────────────────────
|
|
19366
|
+
/**
|
|
19367
|
+
* Shallow-clones a repo into a temporary directory.
|
|
19368
|
+
* Returns the path to the cloned repo.
|
|
19369
|
+
*
|
|
19370
|
+
* Uses HTTPS with token auth embedded in the URL
|
|
19371
|
+
* (works for self-hosted GitLab with private-token).
|
|
19372
|
+
*/
|
|
19373
|
+
cloneToTemp(project, branch) {
|
|
19374
|
+
const tempDir = mkdtempSync(join4(tmpdir(), `verimu-gl-${project.id}-`));
|
|
19375
|
+
const cloneUrl = this.buildAuthUrl(project.http_url_to_repo);
|
|
19376
|
+
const targetBranch = branch ?? project.default_branch;
|
|
19377
|
+
try {
|
|
19378
|
+
execSync2(
|
|
19379
|
+
`git clone --depth 1 --branch "${targetBranch}" --single-branch "${cloneUrl}" "${tempDir}"`,
|
|
19380
|
+
{
|
|
19381
|
+
stdio: "pipe",
|
|
19382
|
+
timeout: 12e4,
|
|
19383
|
+
// 2 minute timeout per clone
|
|
19384
|
+
env: {
|
|
19385
|
+
...process.env,
|
|
19386
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
19387
|
+
// Never prompt for auth
|
|
19388
|
+
}
|
|
19389
|
+
}
|
|
19390
|
+
);
|
|
19391
|
+
} catch (err) {
|
|
19392
|
+
this.cleanupTemp(tempDir);
|
|
19393
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
19394
|
+
throw new Error(`Clone failed for ${project.path_with_namespace}: ${msg}`);
|
|
19395
|
+
}
|
|
19396
|
+
return tempDir;
|
|
19397
|
+
}
|
|
19398
|
+
/**
|
|
19399
|
+
* Removes a temporary clone directory.
|
|
19400
|
+
*/
|
|
19401
|
+
cleanupTemp(tempDir) {
|
|
19402
|
+
if (existsSync15(tempDir)) {
|
|
19403
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
19404
|
+
}
|
|
19405
|
+
}
|
|
19406
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
19407
|
+
/**
|
|
19408
|
+
* Builds an authenticated HTTPS URL for git clone.
|
|
19409
|
+
* Embeds the token as oauth2 password.
|
|
19410
|
+
*/
|
|
19411
|
+
buildAuthUrl(httpUrl) {
|
|
19412
|
+
const url = new URL(httpUrl);
|
|
19413
|
+
url.username = "oauth2";
|
|
19414
|
+
url.password = this.token;
|
|
19415
|
+
return url.toString();
|
|
19416
|
+
}
|
|
19417
|
+
/**
|
|
19418
|
+
* Makes an authenticated GET request to the GitLab API.
|
|
19419
|
+
*/
|
|
19420
|
+
async fetch(url) {
|
|
19421
|
+
const response = await globalThis.fetch(url, {
|
|
19422
|
+
headers: {
|
|
19423
|
+
"PRIVATE-TOKEN": this.token,
|
|
19424
|
+
"Accept": "application/json"
|
|
19425
|
+
}
|
|
19426
|
+
});
|
|
19427
|
+
if (!response.ok) {
|
|
19428
|
+
const body = await response.text().catch(() => "no body");
|
|
19429
|
+
throw new Error(
|
|
19430
|
+
`GitLab API error: ${response.status} ${response.statusText} \u2014 ${url}
|
|
19431
|
+
${body}`
|
|
19432
|
+
);
|
|
19433
|
+
}
|
|
19434
|
+
return response.json();
|
|
19435
|
+
}
|
|
19436
|
+
};
|
|
19437
|
+
|
|
19438
|
+
// src/gitlab/orchestrator.ts
|
|
19439
|
+
var GitLabOrchestrator = class {
|
|
19440
|
+
discovery = new LockfileDiscovery();
|
|
19441
|
+
/**
|
|
19442
|
+
* Scans all accessible repos on a GitLab instance.
|
|
19443
|
+
*/
|
|
19444
|
+
async scanInstance(config) {
|
|
19445
|
+
const startTime = Date.now();
|
|
19446
|
+
const client = new GitLabClient(config.url, config.token);
|
|
19447
|
+
console.log(`
|
|
19448
|
+
Connecting to ${config.url}...`);
|
|
19449
|
+
let projects;
|
|
19450
|
+
if (config.groups && config.groups.length > 0) {
|
|
19451
|
+
const grouped = [];
|
|
19452
|
+
for (const group of config.groups) {
|
|
19453
|
+
console.log(` Listing projects in group: ${group}`);
|
|
19454
|
+
const groupProjects = await client.listGroupProjects(group);
|
|
19455
|
+
grouped.push(...groupProjects);
|
|
19456
|
+
}
|
|
19457
|
+
const seen = /* @__PURE__ */ new Set();
|
|
19458
|
+
projects = grouped.filter((p) => {
|
|
19459
|
+
if (seen.has(p.id)) return false;
|
|
19460
|
+
seen.add(p.id);
|
|
19461
|
+
return true;
|
|
19462
|
+
});
|
|
19463
|
+
} else {
|
|
19464
|
+
console.log(" Listing all accessible projects...");
|
|
19465
|
+
projects = await client.listAllProjects({
|
|
19466
|
+
archived: config.excludeArchived !== false ? false : void 0
|
|
19467
|
+
});
|
|
19468
|
+
}
|
|
19469
|
+
console.log(` Found ${projects.length} projects
|
|
19470
|
+
`);
|
|
19471
|
+
const { toScan, skipped } = this.filterProjects(projects, config);
|
|
19472
|
+
console.log(` Scanning ${toScan.length} repos (${skipped.length} skipped)
|
|
19473
|
+
`);
|
|
19474
|
+
const scannedRepos = [];
|
|
19475
|
+
const failedRepos = [];
|
|
19476
|
+
for (let i = 0; i < toScan.length; i++) {
|
|
19477
|
+
const project = toScan[i];
|
|
19478
|
+
const label = `[${i + 1}/${toScan.length}]`;
|
|
19479
|
+
console.log(` ${label} ${project.path_with_namespace}`);
|
|
19480
|
+
const repoStart = Date.now();
|
|
19481
|
+
let tempDir = null;
|
|
19482
|
+
try {
|
|
19483
|
+
process.stdout.write(" Cloning... ");
|
|
19484
|
+
tempDir = client.cloneToTemp(project, config.branch);
|
|
19485
|
+
console.log("done");
|
|
19486
|
+
const discovered = await this.discovery.discover({
|
|
19487
|
+
rootPath: tempDir
|
|
19488
|
+
});
|
|
19489
|
+
if (discovered.length === 0) {
|
|
19490
|
+
console.log(" no lockfile found");
|
|
19491
|
+
scannedRepos.push({
|
|
19492
|
+
project,
|
|
19493
|
+
reports: [],
|
|
19494
|
+
hasLockfile: false,
|
|
19495
|
+
durationMs: Date.now() - repoStart
|
|
19496
|
+
});
|
|
19497
|
+
continue;
|
|
19498
|
+
}
|
|
19499
|
+
console.log(` Found ${discovered.length} project(s)`);
|
|
19500
|
+
const reports = [];
|
|
19501
|
+
for (const disc of discovered) {
|
|
19502
|
+
const subLabel = discovered.length > 1 ? ` (${disc.relativePath})` : "";
|
|
19503
|
+
const uploadProjectName = discovered.length > 1 && disc.relativePath !== "." ? `${project.path_with_namespace}/${disc.relativePath}` : project.path_with_namespace;
|
|
19504
|
+
const safeArtifactSuffix = uploadProjectName.replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, "-").toLowerCase();
|
|
19505
|
+
const sbomOutput = `${tempDir}/sbom.${safeArtifactSuffix}.cdx.json`;
|
|
19506
|
+
process.stdout.write(` Scanning${subLabel}... `);
|
|
19507
|
+
try {
|
|
19508
|
+
const report = await scan({
|
|
19509
|
+
projectPath: disc.projectPath,
|
|
19510
|
+
sbomOutput,
|
|
19511
|
+
skipCveCheck: config.skipCveCheck ?? false,
|
|
19512
|
+
apiKey: config.apiKey,
|
|
19513
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
19514
|
+
groupName: config.groupName,
|
|
19515
|
+
uploadProjectName,
|
|
19516
|
+
repositoryUrl: project.web_url,
|
|
19517
|
+
platform: "gitlab"
|
|
19518
|
+
});
|
|
19519
|
+
const vulnCount = report.summary.totalVulnerabilities;
|
|
19520
|
+
const depCount = report.summary.totalDependencies;
|
|
19521
|
+
if (vulnCount > 0) {
|
|
19522
|
+
console.log(
|
|
19523
|
+
`${depCount} deps, ${vulnCount} vulns (C:${report.summary.critical} H:${report.summary.high} M:${report.summary.medium} L:${report.summary.low})`
|
|
19524
|
+
);
|
|
19525
|
+
} else {
|
|
19526
|
+
console.log(`${depCount} deps, clean`);
|
|
19527
|
+
}
|
|
19528
|
+
reports.push(report);
|
|
19529
|
+
} catch (scanErr) {
|
|
19530
|
+
const msg = scanErr instanceof Error ? scanErr.message : String(scanErr);
|
|
19531
|
+
console.log(`FAILED: ${msg.slice(0, 80)}`);
|
|
19532
|
+
}
|
|
19533
|
+
}
|
|
19534
|
+
const durationMs = Date.now() - repoStart;
|
|
19535
|
+
scannedRepos.push({
|
|
19536
|
+
project,
|
|
19537
|
+
reports,
|
|
19538
|
+
hasLockfile: true,
|
|
19539
|
+
durationMs
|
|
19540
|
+
});
|
|
19541
|
+
} catch (err) {
|
|
19542
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
19543
|
+
const durationMs = Date.now() - repoStart;
|
|
19544
|
+
if (msg.includes("No supported lockfile") || msg.includes("NoLockfileError")) {
|
|
19545
|
+
console.log(" no lockfile found");
|
|
19546
|
+
scannedRepos.push({
|
|
19547
|
+
project,
|
|
19548
|
+
reports: [],
|
|
19549
|
+
hasLockfile: false,
|
|
19550
|
+
durationMs
|
|
19551
|
+
});
|
|
19552
|
+
} else {
|
|
19553
|
+
console.log(` FAILED: ${msg.slice(0, 100)}`);
|
|
19554
|
+
failedRepos.push({ project, error: msg });
|
|
19555
|
+
}
|
|
19556
|
+
} finally {
|
|
19557
|
+
if (tempDir) {
|
|
19558
|
+
client.cleanupTemp(tempDir);
|
|
19559
|
+
}
|
|
19560
|
+
}
|
|
19561
|
+
console.log("");
|
|
19562
|
+
}
|
|
19563
|
+
const result = this.aggregate(
|
|
19564
|
+
config.url,
|
|
19565
|
+
projects.length,
|
|
19566
|
+
scannedRepos,
|
|
19567
|
+
skipped,
|
|
19568
|
+
failedRepos,
|
|
19569
|
+
Date.now() - startTime
|
|
19570
|
+
);
|
|
19571
|
+
this.printSummary(result);
|
|
19572
|
+
return result;
|
|
19573
|
+
}
|
|
19574
|
+
// ─── Filtering ──────────────────────────────────────────────
|
|
19575
|
+
filterProjects(projects, config) {
|
|
19576
|
+
const toScan = [];
|
|
19577
|
+
const skipped = [];
|
|
19578
|
+
for (const project of projects) {
|
|
19579
|
+
if (config.excludeArchived !== false && project.archived) {
|
|
19580
|
+
skipped.push({ project, reason: "archived" });
|
|
19581
|
+
continue;
|
|
19582
|
+
}
|
|
19583
|
+
if (config.excludeEmpty !== false && project.empty_repo) {
|
|
19584
|
+
skipped.push({ project, reason: "empty repository" });
|
|
19585
|
+
continue;
|
|
19586
|
+
}
|
|
19587
|
+
if (config.excludePatterns?.length) {
|
|
19588
|
+
const matched = config.excludePatterns.some(
|
|
19589
|
+
(pattern) => this.matchGlob(project.path_with_namespace, pattern)
|
|
19590
|
+
);
|
|
19591
|
+
if (matched) {
|
|
19592
|
+
skipped.push({ project, reason: "matched exclude pattern" });
|
|
19593
|
+
continue;
|
|
19594
|
+
}
|
|
19595
|
+
}
|
|
19596
|
+
toScan.push(project);
|
|
19597
|
+
}
|
|
19598
|
+
if (config.maxRepos && toScan.length > config.maxRepos) {
|
|
19599
|
+
const trimmed = toScan.splice(config.maxRepos);
|
|
19600
|
+
for (const p of trimmed) {
|
|
19601
|
+
skipped.push({ project: p, reason: "exceeded --max-repos limit" });
|
|
19602
|
+
}
|
|
19603
|
+
}
|
|
19604
|
+
return { toScan, skipped };
|
|
19605
|
+
}
|
|
19606
|
+
matchGlob(str, pattern) {
|
|
19607
|
+
const regex = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/{{GLOBSTAR}}/g, ".*").replace(/\?/g, ".");
|
|
19608
|
+
return new RegExp(`^${regex}$`).test(str);
|
|
19609
|
+
}
|
|
19610
|
+
// ─── Aggregation ────────────────────────────────────────────
|
|
19611
|
+
aggregate(instanceUrl, totalDiscovered, scannedRepos, skippedRepos, failedRepos, durationMs) {
|
|
19612
|
+
const reposWithData = scannedRepos.filter((r) => r.reports.length > 0);
|
|
19613
|
+
const ecosystemBreakdown = {};
|
|
19614
|
+
let totalDeps = 0;
|
|
19615
|
+
let totalVulns = 0;
|
|
19616
|
+
let critical = 0;
|
|
19617
|
+
let high = 0;
|
|
19618
|
+
let medium = 0;
|
|
19619
|
+
let low = 0;
|
|
19620
|
+
let exploitedInWild = 0;
|
|
19621
|
+
let reposWithVulns = 0;
|
|
19622
|
+
for (const { reports } of reposWithData) {
|
|
19623
|
+
let repoHasVulns = false;
|
|
19624
|
+
for (const report of reports) {
|
|
19625
|
+
totalDeps += report.summary.totalDependencies;
|
|
19626
|
+
totalVulns += report.summary.totalVulnerabilities;
|
|
19627
|
+
critical += report.summary.critical;
|
|
19628
|
+
high += report.summary.high;
|
|
19629
|
+
medium += report.summary.medium;
|
|
19630
|
+
low += report.summary.low;
|
|
19631
|
+
exploitedInWild += report.summary.exploitedInWild;
|
|
19632
|
+
if (report.summary.totalVulnerabilities > 0) {
|
|
19633
|
+
repoHasVulns = true;
|
|
19634
|
+
}
|
|
19635
|
+
const eco = report.project.ecosystem;
|
|
19636
|
+
ecosystemBreakdown[eco] = (ecosystemBreakdown[eco] ?? 0) + 1;
|
|
19637
|
+
}
|
|
19638
|
+
if (repoHasVulns) reposWithVulns++;
|
|
19639
|
+
}
|
|
19640
|
+
const vulnMap = /* @__PURE__ */ new Map();
|
|
19641
|
+
for (const { project, reports } of reposWithData) {
|
|
19642
|
+
for (const report of reports) {
|
|
19643
|
+
for (const vuln of report.cveCheck.vulnerabilities) {
|
|
19644
|
+
const existing = vulnMap.get(vuln.id);
|
|
19645
|
+
if (existing) {
|
|
19646
|
+
if (!existing.affectedRepos.includes(project.path_with_namespace)) {
|
|
19647
|
+
existing.affectedRepos.push(project.path_with_namespace);
|
|
19648
|
+
}
|
|
19649
|
+
} else {
|
|
19650
|
+
vulnMap.set(vuln.id, {
|
|
19651
|
+
id: vuln.id,
|
|
19652
|
+
severity: vuln.severity,
|
|
19653
|
+
summary: vuln.summary,
|
|
19654
|
+
affectedRepos: [project.path_with_namespace],
|
|
19655
|
+
fixedVersion: vuln.fixedVersion,
|
|
19656
|
+
exploitedInWild: vuln.exploitedInWild
|
|
19657
|
+
});
|
|
19658
|
+
}
|
|
19659
|
+
}
|
|
19660
|
+
}
|
|
19661
|
+
}
|
|
19662
|
+
const severityOrder3 = {
|
|
19663
|
+
CRITICAL: 0,
|
|
19664
|
+
HIGH: 1,
|
|
19665
|
+
MEDIUM: 2,
|
|
19666
|
+
LOW: 3,
|
|
19667
|
+
UNKNOWN: 4
|
|
19668
|
+
};
|
|
19669
|
+
const topVulnerabilities = Array.from(vulnMap.values()).sort((a, b) => {
|
|
19670
|
+
const sevDiff = severityOrder3[a.severity] - severityOrder3[b.severity];
|
|
19671
|
+
if (sevDiff !== 0) return sevDiff;
|
|
19672
|
+
return b.affectedRepos.length - a.affectedRepos.length;
|
|
19673
|
+
});
|
|
19674
|
+
return {
|
|
19675
|
+
instanceUrl,
|
|
19676
|
+
totalReposDiscovered: totalDiscovered,
|
|
19677
|
+
scannedRepos,
|
|
19678
|
+
skippedRepos,
|
|
19679
|
+
failedRepos,
|
|
19680
|
+
summary: {
|
|
19681
|
+
totalRepos: reposWithData.length,
|
|
19682
|
+
reposWithVulnerabilities: reposWithVulns,
|
|
19683
|
+
totalDependencies: totalDeps,
|
|
19684
|
+
totalVulnerabilities: totalVulns,
|
|
19685
|
+
critical,
|
|
19686
|
+
high,
|
|
19687
|
+
medium,
|
|
19688
|
+
low,
|
|
19689
|
+
exploitedInWild,
|
|
19690
|
+
ecosystemBreakdown
|
|
19691
|
+
},
|
|
19692
|
+
topVulnerabilities,
|
|
19693
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
19694
|
+
durationMs
|
|
19695
|
+
};
|
|
19696
|
+
}
|
|
19697
|
+
// ─── Summary Printing ───────────────────────────────────────
|
|
19698
|
+
printSummary(result) {
|
|
19699
|
+
console.log("\n" + "\u2550".repeat(60));
|
|
19700
|
+
console.log(" VERIMU GITLAB INSTANCE SCAN \u2014 COMPLETE");
|
|
19701
|
+
console.log("\u2550".repeat(60));
|
|
19702
|
+
console.log(`
|
|
19703
|
+
Instance: ${result.instanceUrl}`);
|
|
19704
|
+
console.log(` Repos found: ${result.totalReposDiscovered}`);
|
|
19705
|
+
console.log(` Repos scanned: ${result.summary.totalRepos}`);
|
|
19706
|
+
console.log(` Repos with vulns: ${result.summary.reposWithVulnerabilities}`);
|
|
19707
|
+
console.log(` Skipped: ${result.skippedRepos.length}`);
|
|
19708
|
+
console.log(` Failed: ${result.failedRepos.length}`);
|
|
19709
|
+
console.log("");
|
|
19710
|
+
console.log(` Total dependencies: ${result.summary.totalDependencies}`);
|
|
19711
|
+
console.log(` Total vulnerabilities: ${result.summary.totalVulnerabilities}`);
|
|
19712
|
+
console.log(` Critical: ${result.summary.critical}`);
|
|
19713
|
+
console.log(` High: ${result.summary.high}`);
|
|
19714
|
+
console.log(` Medium: ${result.summary.medium}`);
|
|
19715
|
+
console.log(` Low: ${result.summary.low}`);
|
|
19716
|
+
if (result.summary.exploitedInWild > 0) {
|
|
19717
|
+
console.log(`
|
|
19718
|
+
\u{1F534} ${result.summary.exploitedInWild} actively exploited \u2014 CRA 24h reporting required`);
|
|
19719
|
+
}
|
|
19720
|
+
if (Object.keys(result.summary.ecosystemBreakdown).length > 0) {
|
|
19721
|
+
console.log("\n Ecosystems:");
|
|
19722
|
+
for (const [eco, count] of Object.entries(result.summary.ecosystemBreakdown)) {
|
|
19723
|
+
console.log(` ${eco}: ${count} project(s)`);
|
|
19724
|
+
}
|
|
19725
|
+
}
|
|
19726
|
+
console.log(`
|
|
19727
|
+
Completed in ${(result.durationMs / 1e3).toFixed(1)}s`);
|
|
19728
|
+
console.log("");
|
|
19729
|
+
}
|
|
19730
|
+
};
|
|
19731
|
+
|
|
19732
|
+
// src/github/client.ts
|
|
19733
|
+
import { execSync as execSync3 } from "child_process";
|
|
19734
|
+
import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, existsSync as existsSync16 } from "fs";
|
|
19735
|
+
import { join as join5 } from "path";
|
|
19736
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
19737
|
+
function parseProfile(input, baseUrl) {
|
|
19738
|
+
const trimmed = input.trim();
|
|
19739
|
+
try {
|
|
19740
|
+
const url = new URL(trimmed);
|
|
19741
|
+
const pathSegments = url.pathname.split("/").filter(Boolean);
|
|
19742
|
+
if (pathSegments.length >= 1) {
|
|
19743
|
+
return { login: pathSegments[0] };
|
|
19744
|
+
}
|
|
19745
|
+
} catch {
|
|
19746
|
+
}
|
|
19747
|
+
if (trimmed.includes("/") && !trimmed.startsWith("http")) {
|
|
19748
|
+
try {
|
|
19749
|
+
const url = new URL(`https://${trimmed}`);
|
|
19750
|
+
const pathSegments = url.pathname.split("/").filter(Boolean);
|
|
19751
|
+
if (pathSegments.length >= 1) {
|
|
19752
|
+
return { login: pathSegments[0] };
|
|
19753
|
+
}
|
|
19754
|
+
} catch {
|
|
19755
|
+
}
|
|
19756
|
+
}
|
|
19757
|
+
if (!trimmed || trimmed.includes(" ")) {
|
|
19758
|
+
throw new Error(`Invalid GitHub profile: "${input}". Provide an org/user handle or URL.`);
|
|
19759
|
+
}
|
|
19760
|
+
return { login: trimmed };
|
|
19761
|
+
}
|
|
19762
|
+
var GitHubClient = class {
|
|
19763
|
+
baseUrl;
|
|
19764
|
+
apiUrl;
|
|
19765
|
+
token;
|
|
19766
|
+
lastRateLimit;
|
|
19767
|
+
constructor(baseUrl, token) {
|
|
19768
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
19769
|
+
this.token = token;
|
|
19770
|
+
if (this.baseUrl === "https://github.com" || this.baseUrl === "http://github.com") {
|
|
19771
|
+
this.apiUrl = "https://api.github.com";
|
|
19772
|
+
} else {
|
|
19773
|
+
this.apiUrl = `${this.baseUrl}/api/v3`;
|
|
19774
|
+
}
|
|
19775
|
+
}
|
|
19776
|
+
// ─── Owner Type Detection ──────────────────────────────────
|
|
19777
|
+
/**
|
|
19778
|
+
* Determines whether a login is a User or Organization by
|
|
19779
|
+
* calling GET /users/{username} and reading the `type` field.
|
|
19780
|
+
*/
|
|
19781
|
+
async detectOwnerType(login) {
|
|
19782
|
+
const data = await this.fetch(`${this.apiUrl}/users/${encodeURIComponent(login)}`);
|
|
19783
|
+
if (data.type === "Organization") return "org";
|
|
19784
|
+
return "user";
|
|
19785
|
+
}
|
|
19786
|
+
// ─── Repo Listing ──────────────────────────────────────────
|
|
19787
|
+
/**
|
|
19788
|
+
* Lists repositories for an organization.
|
|
19789
|
+
* Uses GET /orgs/{org}/repos with pagination.
|
|
19790
|
+
*/
|
|
19791
|
+
async listOrgRepos(org) {
|
|
19792
|
+
return this.paginate(
|
|
19793
|
+
`${this.apiUrl}/orgs/${encodeURIComponent(org)}/repos`,
|
|
19794
|
+
{ type: "all", sort: "pushed", direction: "desc" }
|
|
19795
|
+
);
|
|
19796
|
+
}
|
|
19797
|
+
/**
|
|
19798
|
+
* Lists repositories for a user.
|
|
19799
|
+
*
|
|
19800
|
+
* - Without token: GET /users/{username}/repos
|
|
19801
|
+
* - default: type=all
|
|
19802
|
+
* - ownerOnly: type=owner
|
|
19803
|
+
* - With token for own user: GET /user/repos filtered by owner login
|
|
19804
|
+
* (includes private repos the token can see)
|
|
19805
|
+
* - With token for other user: GET /users/{username}/repos
|
|
19806
|
+
* (only public repos visible)
|
|
19807
|
+
*/
|
|
19808
|
+
async listUserRepos(login, ownerOnly = false) {
|
|
19809
|
+
if (this.token) {
|
|
19810
|
+
try {
|
|
19811
|
+
const authedUser = await this.fetch(`${this.apiUrl}/user`);
|
|
19812
|
+
if (authedUser.login.toLowerCase() === login.toLowerCase()) {
|
|
19813
|
+
const allRepos = await this.paginate(
|
|
19814
|
+
`${this.apiUrl}/user/repos`,
|
|
19815
|
+
{ sort: "pushed", direction: "desc", affiliation: "owner" }
|
|
19816
|
+
);
|
|
19817
|
+
return allRepos;
|
|
19818
|
+
}
|
|
19819
|
+
} catch {
|
|
19820
|
+
}
|
|
19821
|
+
}
|
|
19822
|
+
return this.paginate(
|
|
19823
|
+
`${this.apiUrl}/users/${encodeURIComponent(login)}/repos`,
|
|
19824
|
+
{ type: ownerOnly ? "owner" : "all", sort: "pushed", direction: "desc" }
|
|
19825
|
+
);
|
|
19826
|
+
}
|
|
19827
|
+
/**
|
|
19828
|
+
* Lists repos based on resolved owner type.
|
|
19829
|
+
*/
|
|
19830
|
+
async listRepos(login, ownerType, options) {
|
|
19831
|
+
if (ownerType === "org") {
|
|
19832
|
+
return this.listOrgRepos(login);
|
|
19833
|
+
}
|
|
19834
|
+
return this.listUserRepos(login, options?.ownerOnly ?? false);
|
|
19835
|
+
}
|
|
19836
|
+
// ─── Cloning ───────────────────────────────────────────────
|
|
19837
|
+
/**
|
|
19838
|
+
* Shallow-clones a repo into a temporary directory.
|
|
19839
|
+
* Returns the path to the cloned repo.
|
|
19840
|
+
*
|
|
19841
|
+
* Uses HTTPS with token auth embedded in the URL (when token is available).
|
|
19842
|
+
*/
|
|
19843
|
+
cloneToTemp(repo, branch) {
|
|
19844
|
+
const tempDir = mkdtempSync2(join5(tmpdir2(), `verimu-gh-${repo.id}-`));
|
|
19845
|
+
const cloneUrl = this.token ? this.buildAuthUrl(repo.clone_url) : repo.clone_url;
|
|
19846
|
+
const targetBranch = branch ?? repo.default_branch;
|
|
19847
|
+
try {
|
|
19848
|
+
execSync3(
|
|
19849
|
+
`git clone --depth 1 --branch "${targetBranch}" --single-branch "${cloneUrl}" "${tempDir}"`,
|
|
19850
|
+
{
|
|
19851
|
+
stdio: "pipe",
|
|
19852
|
+
timeout: 12e4,
|
|
19853
|
+
// 2 minute timeout per clone
|
|
19854
|
+
env: {
|
|
19855
|
+
...process.env,
|
|
19856
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
19857
|
+
// Never prompt for auth
|
|
19858
|
+
}
|
|
19859
|
+
}
|
|
19860
|
+
);
|
|
19861
|
+
} catch (err) {
|
|
19862
|
+
this.cleanupTemp(tempDir);
|
|
19863
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
19864
|
+
throw new Error(`Clone failed for ${repo.full_name}: ${msg}`);
|
|
19865
|
+
}
|
|
19866
|
+
return tempDir;
|
|
19867
|
+
}
|
|
19868
|
+
/**
|
|
19869
|
+
* Removes a temporary clone directory.
|
|
19870
|
+
*/
|
|
19871
|
+
cleanupTemp(tempDir) {
|
|
19872
|
+
if (existsSync16(tempDir)) {
|
|
19873
|
+
rmSync2(tempDir, { recursive: true, force: true });
|
|
19874
|
+
}
|
|
19875
|
+
}
|
|
19876
|
+
// ─── Rate Limit Accessors ──────────────────────────────────
|
|
19877
|
+
/** Returns the last observed rate limit info, if any. */
|
|
19878
|
+
getRateLimit() {
|
|
19879
|
+
return this.lastRateLimit;
|
|
19880
|
+
}
|
|
19881
|
+
/**
|
|
19882
|
+
* Returns the hourly rate limit for this client configuration.
|
|
19883
|
+
* - Unauthenticated: 60 requests/hour
|
|
19884
|
+
* - Authenticated (PAT/OAuth): 5,000 requests/hour
|
|
19885
|
+
*/
|
|
19886
|
+
getExpectedRateLimit() {
|
|
19887
|
+
return this.token ? 5e3 : 60;
|
|
19888
|
+
}
|
|
19889
|
+
// ─── Internals ─────────────────────────────────────────────
|
|
19890
|
+
/**
|
|
19891
|
+
* Paginated GET — fetches all pages of a list endpoint.
|
|
19892
|
+
* GitHub uses `per_page` (max 100) and `page` parameters.
|
|
19893
|
+
*/
|
|
19894
|
+
async paginate(url, params = {}) {
|
|
19895
|
+
const all = [];
|
|
19896
|
+
let page = 1;
|
|
19897
|
+
const perPage = 100;
|
|
19898
|
+
while (true) {
|
|
19899
|
+
const searchParams = new URLSearchParams({
|
|
19900
|
+
...params,
|
|
19901
|
+
per_page: String(perPage),
|
|
19902
|
+
page: String(page)
|
|
19903
|
+
});
|
|
19904
|
+
const fullUrl = `${url}?${searchParams.toString()}`;
|
|
19905
|
+
const items = await this.fetch(fullUrl);
|
|
19906
|
+
if (items.length === 0) break;
|
|
19907
|
+
all.push(...items);
|
|
19908
|
+
page++;
|
|
19909
|
+
if (items.length < perPage) break;
|
|
19910
|
+
}
|
|
19911
|
+
return all;
|
|
19912
|
+
}
|
|
19913
|
+
/**
|
|
19914
|
+
* Builds an authenticated HTTPS URL for git clone.
|
|
19915
|
+
* Embeds the token as a password with "x-access-token" user.
|
|
19916
|
+
*/
|
|
19917
|
+
buildAuthUrl(httpUrl) {
|
|
19918
|
+
const url = new URL(httpUrl);
|
|
19919
|
+
url.username = "x-access-token";
|
|
19920
|
+
url.password = this.token;
|
|
19921
|
+
return url.toString();
|
|
19922
|
+
}
|
|
19923
|
+
/**
|
|
19924
|
+
* Makes an authenticated GET request to the GitHub API.
|
|
19925
|
+
*
|
|
19926
|
+
* Rate limit handling:
|
|
19927
|
+
* - Reads X-RateLimit-Remaining and X-RateLimit-Reset headers
|
|
19928
|
+
* - If remaining is 0, waits until the reset time or throws with actionable message
|
|
19929
|
+
* - Retries on transient 502/503/504 with exponential backoff (up to 3 retries)
|
|
19930
|
+
* - Returns 403/429 rate limit errors with reset time information
|
|
19931
|
+
*/
|
|
19932
|
+
async fetch(url) {
|
|
19933
|
+
const maxRetries = 3;
|
|
19934
|
+
let lastError = null;
|
|
19935
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
19936
|
+
if (this.lastRateLimit && this.lastRateLimit.remaining === 0) {
|
|
19937
|
+
const now = Date.now();
|
|
19938
|
+
const resetMs = this.lastRateLimit.resetAt.getTime();
|
|
19939
|
+
if (now < resetMs) {
|
|
19940
|
+
const waitSec = Math.ceil((resetMs - now) / 1e3);
|
|
19941
|
+
if (waitSec <= 60) {
|
|
19942
|
+
console.log(` Rate limit reached. Waiting ${waitSec}s for reset...`);
|
|
19943
|
+
await this.sleep(waitSec * 1e3);
|
|
19944
|
+
} else {
|
|
19945
|
+
throw new Error(
|
|
19946
|
+
`GitHub API rate limit exceeded. Limit: ${this.lastRateLimit.limit} requests/hour${this.token ? "" : " (unauthenticated \u2014 use --token for 5,000/hour)"}. Resets at ${this.lastRateLimit.resetAt.toLocaleTimeString()} (${waitSec}s from now).`
|
|
19947
|
+
);
|
|
19948
|
+
}
|
|
19949
|
+
}
|
|
19950
|
+
}
|
|
19951
|
+
const headers = {
|
|
19952
|
+
"Accept": "application/vnd.github+json",
|
|
19953
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
19954
|
+
};
|
|
19955
|
+
if (this.token) {
|
|
19956
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
19957
|
+
}
|
|
19958
|
+
let response;
|
|
19959
|
+
try {
|
|
19960
|
+
response = await globalThis.fetch(url, { headers });
|
|
19961
|
+
} catch (err) {
|
|
19962
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
19963
|
+
if (attempt < maxRetries) {
|
|
19964
|
+
await this.sleep(1e3 * 2 ** attempt);
|
|
19965
|
+
continue;
|
|
19966
|
+
}
|
|
19967
|
+
throw lastError;
|
|
19968
|
+
}
|
|
19969
|
+
this.updateRateLimit(response);
|
|
19970
|
+
if (response.ok) {
|
|
19971
|
+
return response.json();
|
|
19972
|
+
}
|
|
19973
|
+
if (response.status === 403 || response.status === 429) {
|
|
19974
|
+
const body2 = await response.text().catch(() => "no body");
|
|
19975
|
+
if (body2.includes("rate limit") || body2.includes("API rate limit") || response.status === 429) {
|
|
19976
|
+
const resetHeader = response.headers.get("X-RateLimit-Reset");
|
|
19977
|
+
const resetAt = resetHeader ? new Date(Number(resetHeader) * 1e3) : new Date(Date.now() + 6e4);
|
|
19978
|
+
const waitSec = Math.ceil((resetAt.getTime() - Date.now()) / 1e3);
|
|
19979
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
19980
|
+
if (retryAfter && attempt < maxRetries) {
|
|
19981
|
+
const waitMs = Number(retryAfter) * 1e3;
|
|
19982
|
+
console.log(` Rate limited (secondary). Waiting ${retryAfter}s...`);
|
|
19983
|
+
await this.sleep(Math.min(waitMs, 6e4));
|
|
19984
|
+
continue;
|
|
19985
|
+
}
|
|
19986
|
+
throw new Error(
|
|
19987
|
+
`GitHub API rate limit exceeded (${response.status}). ${this.token ? "" : "Unauthenticated requests are limited to 60/hour \u2014 use --token for 5,000/hour. "}Resets at ${resetAt.toLocaleTimeString()} (${Math.max(0, waitSec)}s from now).`
|
|
19988
|
+
);
|
|
19989
|
+
}
|
|
19990
|
+
throw new Error(
|
|
19991
|
+
`GitHub API error: ${response.status} ${response.statusText} \u2014 ${url}
|
|
19992
|
+
${body2}`
|
|
19993
|
+
);
|
|
19994
|
+
}
|
|
19995
|
+
if ([502, 503, 504].includes(response.status) && attempt < maxRetries) {
|
|
19996
|
+
lastError = new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
19997
|
+
await this.sleep(1e3 * 2 ** attempt);
|
|
19998
|
+
continue;
|
|
19999
|
+
}
|
|
20000
|
+
const body = await response.text().catch(() => "no body");
|
|
20001
|
+
throw new Error(
|
|
20002
|
+
`GitHub API error: ${response.status} ${response.statusText} \u2014 ${url}
|
|
20003
|
+
${body}`
|
|
20004
|
+
);
|
|
20005
|
+
}
|
|
20006
|
+
throw lastError ?? new Error(`GitHub API request failed after ${maxRetries} retries`);
|
|
20007
|
+
}
|
|
20008
|
+
/**
|
|
20009
|
+
* Updates internal rate limit tracker from response headers.
|
|
20010
|
+
*/
|
|
20011
|
+
updateRateLimit(response) {
|
|
20012
|
+
const limit = response.headers.get("X-RateLimit-Limit");
|
|
20013
|
+
const remaining = response.headers.get("X-RateLimit-Remaining");
|
|
20014
|
+
const reset = response.headers.get("X-RateLimit-Reset");
|
|
20015
|
+
const used = response.headers.get("X-RateLimit-Used");
|
|
20016
|
+
if (limit && remaining && reset) {
|
|
20017
|
+
this.lastRateLimit = {
|
|
20018
|
+
limit: Number(limit),
|
|
20019
|
+
remaining: Number(remaining),
|
|
20020
|
+
resetAt: new Date(Number(reset) * 1e3),
|
|
20021
|
+
used: used ? Number(used) : 0
|
|
20022
|
+
};
|
|
20023
|
+
}
|
|
20024
|
+
}
|
|
20025
|
+
sleep(ms) {
|
|
20026
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
20027
|
+
}
|
|
20028
|
+
};
|
|
20029
|
+
|
|
20030
|
+
// src/github/orchestrator.ts
|
|
20031
|
+
var GitHubOrchestrator = class {
|
|
20032
|
+
discovery = new LockfileDiscovery();
|
|
20033
|
+
/**
|
|
20034
|
+
* Scans all repos for a GitHub profile (org or user).
|
|
20035
|
+
*/
|
|
20036
|
+
async scanProfile(config) {
|
|
20037
|
+
const startTime = Date.now();
|
|
20038
|
+
const client = new GitHubClient(config.baseUrl, config.token);
|
|
20039
|
+
const { login } = parseProfile(config.profile, config.baseUrl);
|
|
20040
|
+
console.log(`
|
|
20041
|
+
GitHub profile: ${login}`);
|
|
20042
|
+
console.log(` Base URL: ${config.baseUrl}`);
|
|
20043
|
+
console.log(` Auth: ${config.token ? "token provided (5,000 req/h)" : "unauthenticated (60 req/h)"}`);
|
|
20044
|
+
process.stdout.write(" Detecting profile type... ");
|
|
20045
|
+
const ownerType = await client.detectOwnerType(login);
|
|
20046
|
+
console.log(ownerType);
|
|
20047
|
+
console.log(" Listing repositories...");
|
|
20048
|
+
const repos = await client.listRepos(login, ownerType, {
|
|
20049
|
+
ownerOnly: config.ownerOnly ?? false
|
|
20050
|
+
});
|
|
20051
|
+
console.log(` Found ${repos.length} repositories
|
|
20052
|
+
`);
|
|
20053
|
+
const { toScan, skipped } = this.filterRepos(repos, config);
|
|
20054
|
+
console.log(` Scanning ${toScan.length} repos (${skipped.length} skipped)
|
|
20055
|
+
`);
|
|
20056
|
+
const scannedRepos = [];
|
|
20057
|
+
const failedRepos = [];
|
|
20058
|
+
for (let i = 0; i < toScan.length; i++) {
|
|
20059
|
+
const repo = toScan[i];
|
|
20060
|
+
const label = `[${i + 1}/${toScan.length}]`;
|
|
20061
|
+
console.log(` ${label} ${repo.full_name}`);
|
|
20062
|
+
const repoStart = Date.now();
|
|
20063
|
+
let tempDir = null;
|
|
20064
|
+
try {
|
|
20065
|
+
process.stdout.write(" Cloning... ");
|
|
20066
|
+
tempDir = client.cloneToTemp(repo, config.branch);
|
|
20067
|
+
console.log("done");
|
|
20068
|
+
const discovered = await this.discovery.discover({
|
|
20069
|
+
rootPath: tempDir
|
|
20070
|
+
});
|
|
20071
|
+
if (discovered.length === 0) {
|
|
20072
|
+
console.log(" no lockfile found");
|
|
20073
|
+
scannedRepos.push({
|
|
20074
|
+
repo,
|
|
20075
|
+
reports: [],
|
|
20076
|
+
hasLockfile: false,
|
|
20077
|
+
durationMs: Date.now() - repoStart
|
|
20078
|
+
});
|
|
20079
|
+
continue;
|
|
20080
|
+
}
|
|
20081
|
+
console.log(` Found ${discovered.length} project(s)`);
|
|
20082
|
+
const reports = [];
|
|
20083
|
+
const autoGroupName = this.getAutoGroupName(repo, discovered.length, config.groupName);
|
|
20084
|
+
for (const disc of discovered) {
|
|
20085
|
+
const subLabel = discovered.length > 1 ? ` (${disc.relativePath})` : "";
|
|
20086
|
+
const uploadProjectName = this.getUploadProjectName(repo, disc.relativePath, discovered.length);
|
|
20087
|
+
const safeArtifactSuffix = uploadProjectName.replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, "-").toLowerCase();
|
|
20088
|
+
const sbomOutput = `${tempDir}/sbom.${safeArtifactSuffix}.cdx.json`;
|
|
20089
|
+
process.stdout.write(` Scanning${subLabel}... `);
|
|
20090
|
+
try {
|
|
20091
|
+
const report = await scan({
|
|
20092
|
+
projectPath: disc.projectPath,
|
|
20093
|
+
sbomOutput,
|
|
20094
|
+
skipCveCheck: config.skipCveCheck ?? false,
|
|
20095
|
+
apiKey: config.apiKey,
|
|
20096
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
20097
|
+
groupName: autoGroupName,
|
|
20098
|
+
uploadProjectName,
|
|
20099
|
+
repositoryUrl: repo.html_url,
|
|
20100
|
+
platform: "github"
|
|
20101
|
+
});
|
|
20102
|
+
const vulnCount = report.summary.totalVulnerabilities;
|
|
20103
|
+
const depCount = report.summary.totalDependencies;
|
|
20104
|
+
if (vulnCount > 0) {
|
|
20105
|
+
console.log(
|
|
20106
|
+
`${depCount} deps, ${vulnCount} vulns (C:${report.summary.critical} H:${report.summary.high} M:${report.summary.medium} L:${report.summary.low})`
|
|
20107
|
+
);
|
|
20108
|
+
} else {
|
|
20109
|
+
console.log(`${depCount} deps, clean`);
|
|
20110
|
+
}
|
|
20111
|
+
reports.push(report);
|
|
20112
|
+
} catch (scanErr) {
|
|
20113
|
+
const msg = scanErr instanceof Error ? scanErr.message : String(scanErr);
|
|
20114
|
+
console.log(`FAILED: ${msg.slice(0, 80)}`);
|
|
20115
|
+
}
|
|
20116
|
+
}
|
|
20117
|
+
const durationMs = Date.now() - repoStart;
|
|
20118
|
+
scannedRepos.push({
|
|
20119
|
+
repo,
|
|
20120
|
+
reports,
|
|
20121
|
+
hasLockfile: true,
|
|
20122
|
+
durationMs
|
|
20123
|
+
});
|
|
20124
|
+
} catch (err) {
|
|
20125
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
20126
|
+
const durationMs = Date.now() - repoStart;
|
|
20127
|
+
if (msg.includes("No supported lockfile") || msg.includes("NoLockfileError")) {
|
|
20128
|
+
console.log(" no lockfile found");
|
|
20129
|
+
scannedRepos.push({
|
|
20130
|
+
repo,
|
|
20131
|
+
reports: [],
|
|
20132
|
+
hasLockfile: false,
|
|
20133
|
+
durationMs
|
|
20134
|
+
});
|
|
20135
|
+
} else {
|
|
20136
|
+
console.log(` FAILED: ${msg.slice(0, 100)}`);
|
|
20137
|
+
failedRepos.push({ repo, error: msg });
|
|
20138
|
+
}
|
|
20139
|
+
} finally {
|
|
20140
|
+
if (tempDir) {
|
|
20141
|
+
client.cleanupTemp(tempDir);
|
|
20142
|
+
}
|
|
20143
|
+
}
|
|
20144
|
+
console.log("");
|
|
20145
|
+
}
|
|
20146
|
+
const result = this.aggregate(
|
|
20147
|
+
config.baseUrl,
|
|
20148
|
+
login,
|
|
20149
|
+
ownerType,
|
|
20150
|
+
repos.length,
|
|
20151
|
+
scannedRepos,
|
|
20152
|
+
skipped,
|
|
20153
|
+
failedRepos,
|
|
20154
|
+
Date.now() - startTime
|
|
20155
|
+
);
|
|
20156
|
+
this.printSummary(result);
|
|
20157
|
+
return result;
|
|
20158
|
+
}
|
|
20159
|
+
// ─── Filtering ──────────────────────────────────────────────
|
|
20160
|
+
filterRepos(repos, config) {
|
|
20161
|
+
const toScan = [];
|
|
20162
|
+
const skipped = [];
|
|
20163
|
+
for (const repo of repos) {
|
|
20164
|
+
if (config.excludeArchived !== false && repo.archived) {
|
|
20165
|
+
skipped.push({ repo, reason: "archived" });
|
|
20166
|
+
continue;
|
|
20167
|
+
}
|
|
20168
|
+
if (config.excludeForks && repo.fork) {
|
|
20169
|
+
skipped.push({ repo, reason: "fork" });
|
|
20170
|
+
continue;
|
|
20171
|
+
}
|
|
20172
|
+
toScan.push(repo);
|
|
20173
|
+
}
|
|
20174
|
+
if (config.maxRepos && toScan.length > config.maxRepos) {
|
|
20175
|
+
const trimmed = toScan.splice(config.maxRepos);
|
|
20176
|
+
for (const r of trimmed) {
|
|
20177
|
+
skipped.push({ repo: r, reason: "exceeded --max-repos limit" });
|
|
20178
|
+
}
|
|
20179
|
+
}
|
|
20180
|
+
return { toScan, skipped };
|
|
20181
|
+
}
|
|
20182
|
+
getUploadProjectName(repo, relativePath, totalProjectsInRepo) {
|
|
20183
|
+
if (totalProjectsInRepo > 1) {
|
|
20184
|
+
return relativePath === "." ? repo.name : relativePath;
|
|
20185
|
+
}
|
|
20186
|
+
return repo.full_name;
|
|
20187
|
+
}
|
|
20188
|
+
getAutoGroupName(repo, totalProjectsInRepo, configuredGroupName) {
|
|
20189
|
+
if (configuredGroupName) {
|
|
20190
|
+
return configuredGroupName;
|
|
20191
|
+
}
|
|
20192
|
+
return totalProjectsInRepo > 1 ? repo.full_name : void 0;
|
|
20193
|
+
}
|
|
20194
|
+
// ─── Aggregation ────────────────────────────────────────────
|
|
20195
|
+
aggregate(instanceUrl, profile, profileType, totalDiscovered, scannedRepos, skippedRepos, failedRepos, durationMs) {
|
|
20196
|
+
const reposWithData = scannedRepos.filter((r) => r.reports.length > 0);
|
|
20197
|
+
const ecosystemBreakdown = {};
|
|
20198
|
+
let totalDeps = 0;
|
|
20199
|
+
let totalVulns = 0;
|
|
20200
|
+
let critical = 0;
|
|
20201
|
+
let high = 0;
|
|
20202
|
+
let medium = 0;
|
|
20203
|
+
let low = 0;
|
|
20204
|
+
let exploitedInWild = 0;
|
|
20205
|
+
let reposWithVulns = 0;
|
|
20206
|
+
for (const { reports } of reposWithData) {
|
|
20207
|
+
let repoHasVulns = false;
|
|
20208
|
+
for (const report of reports) {
|
|
20209
|
+
totalDeps += report.summary.totalDependencies;
|
|
20210
|
+
totalVulns += report.summary.totalVulnerabilities;
|
|
20211
|
+
critical += report.summary.critical;
|
|
20212
|
+
high += report.summary.high;
|
|
20213
|
+
medium += report.summary.medium;
|
|
20214
|
+
low += report.summary.low;
|
|
20215
|
+
exploitedInWild += report.summary.exploitedInWild;
|
|
20216
|
+
if (report.summary.totalVulnerabilities > 0) {
|
|
20217
|
+
repoHasVulns = true;
|
|
20218
|
+
}
|
|
20219
|
+
const eco = report.project.ecosystem;
|
|
20220
|
+
ecosystemBreakdown[eco] = (ecosystemBreakdown[eco] ?? 0) + 1;
|
|
20221
|
+
}
|
|
20222
|
+
if (repoHasVulns) reposWithVulns++;
|
|
20223
|
+
}
|
|
20224
|
+
const vulnMap = /* @__PURE__ */ new Map();
|
|
20225
|
+
for (const { repo, reports } of reposWithData) {
|
|
20226
|
+
for (const report of reports) {
|
|
20227
|
+
for (const vuln of report.cveCheck.vulnerabilities) {
|
|
20228
|
+
const existing = vulnMap.get(vuln.id);
|
|
20229
|
+
if (existing) {
|
|
20230
|
+
if (!existing.affectedRepos.includes(repo.full_name)) {
|
|
20231
|
+
existing.affectedRepos.push(repo.full_name);
|
|
20232
|
+
}
|
|
20233
|
+
} else {
|
|
20234
|
+
vulnMap.set(vuln.id, {
|
|
20235
|
+
id: vuln.id,
|
|
20236
|
+
severity: vuln.severity,
|
|
20237
|
+
summary: vuln.summary,
|
|
20238
|
+
affectedRepos: [repo.full_name],
|
|
20239
|
+
fixedVersion: vuln.fixedVersion,
|
|
20240
|
+
exploitedInWild: vuln.exploitedInWild
|
|
20241
|
+
});
|
|
20242
|
+
}
|
|
20243
|
+
}
|
|
20244
|
+
}
|
|
20245
|
+
}
|
|
20246
|
+
const severityOrder3 = {
|
|
20247
|
+
CRITICAL: 0,
|
|
20248
|
+
HIGH: 1,
|
|
20249
|
+
MEDIUM: 2,
|
|
20250
|
+
LOW: 3,
|
|
20251
|
+
UNKNOWN: 4
|
|
20252
|
+
};
|
|
20253
|
+
const topVulnerabilities = Array.from(vulnMap.values()).sort((a, b) => {
|
|
20254
|
+
const sevDiff = severityOrder3[a.severity] - severityOrder3[b.severity];
|
|
20255
|
+
if (sevDiff !== 0) return sevDiff;
|
|
20256
|
+
return b.affectedRepos.length - a.affectedRepos.length;
|
|
20257
|
+
});
|
|
20258
|
+
return {
|
|
20259
|
+
instanceUrl,
|
|
20260
|
+
profile,
|
|
20261
|
+
profileType,
|
|
20262
|
+
totalReposDiscovered: totalDiscovered,
|
|
20263
|
+
scannedRepos,
|
|
20264
|
+
skippedRepos,
|
|
20265
|
+
failedRepos,
|
|
20266
|
+
summary: {
|
|
20267
|
+
totalRepos: reposWithData.length,
|
|
20268
|
+
reposWithVulnerabilities: reposWithVulns,
|
|
20269
|
+
totalDependencies: totalDeps,
|
|
20270
|
+
totalVulnerabilities: totalVulns,
|
|
20271
|
+
critical,
|
|
20272
|
+
high,
|
|
20273
|
+
medium,
|
|
20274
|
+
low,
|
|
20275
|
+
exploitedInWild,
|
|
20276
|
+
ecosystemBreakdown
|
|
20277
|
+
},
|
|
20278
|
+
topVulnerabilities,
|
|
20279
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
20280
|
+
durationMs
|
|
20281
|
+
};
|
|
20282
|
+
}
|
|
20283
|
+
// ─── Summary Printing ───────────────────────────────────────
|
|
20284
|
+
printSummary(result) {
|
|
20285
|
+
const noLockfile = result.scannedRepos.filter((r) => !r.hasLockfile).length;
|
|
20286
|
+
const withLockfile = result.scannedRepos.filter((r) => r.hasLockfile).length;
|
|
20287
|
+
console.log("\n" + "\u2550".repeat(60));
|
|
20288
|
+
console.log(" VERIMU GITHUB SCAN \u2014 COMPLETE");
|
|
20289
|
+
console.log("\u2550".repeat(60));
|
|
20290
|
+
console.log(`
|
|
20291
|
+
Profile: ${result.profile} (${result.profileType})`);
|
|
20292
|
+
console.log(` Instance: ${result.instanceUrl}`);
|
|
20293
|
+
console.log("");
|
|
20294
|
+
console.log(` Repos found: ${result.totalReposDiscovered}`);
|
|
20295
|
+
console.log(` With lockfile: ${withLockfile}`);
|
|
20296
|
+
console.log(` No lockfile: ${noLockfile}`);
|
|
20297
|
+
console.log(` Skipped: ${result.skippedRepos.length} (archived/fork/limit)`);
|
|
20298
|
+
console.log(` Failed: ${result.failedRepos.length} (clone/scan error)`);
|
|
20299
|
+
console.log("");
|
|
20300
|
+
console.log(` Repos with vulns: ${result.summary.reposWithVulnerabilities} / ${withLockfile}`);
|
|
20301
|
+
console.log("");
|
|
20302
|
+
console.log(` Total dependencies: ${result.summary.totalDependencies}`);
|
|
20303
|
+
console.log(` Total vulnerabilities: ${result.summary.totalVulnerabilities}`);
|
|
20304
|
+
console.log(` Critical: ${result.summary.critical}`);
|
|
20305
|
+
console.log(` High: ${result.summary.high}`);
|
|
20306
|
+
console.log(` Medium: ${result.summary.medium}`);
|
|
20307
|
+
console.log(` Low: ${result.summary.low}`);
|
|
20308
|
+
if (result.summary.exploitedInWild > 0) {
|
|
20309
|
+
console.log(`
|
|
20310
|
+
\u{1F534} ${result.summary.exploitedInWild} actively exploited \u2014 CRA 24h reporting required`);
|
|
20311
|
+
}
|
|
20312
|
+
if (Object.keys(result.summary.ecosystemBreakdown).length > 0) {
|
|
20313
|
+
console.log("\n Ecosystems:");
|
|
20314
|
+
for (const [eco, count] of Object.entries(result.summary.ecosystemBreakdown)) {
|
|
20315
|
+
console.log(` ${eco}: ${count} project(s)`);
|
|
20316
|
+
}
|
|
20317
|
+
}
|
|
20318
|
+
console.log(`
|
|
20319
|
+
Completed in ${(result.durationMs / 1e3).toFixed(1)}s`);
|
|
20320
|
+
console.log("");
|
|
20321
|
+
}
|
|
20322
|
+
};
|
|
20323
|
+
|
|
20324
|
+
// src/reporters/html.ts
|
|
20325
|
+
var HtmlReporter = class {
|
|
20326
|
+
name = "html";
|
|
20327
|
+
generate(result) {
|
|
20328
|
+
return `<!DOCTYPE html>
|
|
20329
|
+
<html lang="en">
|
|
20330
|
+
<head>
|
|
20331
|
+
<meta charset="UTF-8">
|
|
20332
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
20333
|
+
<title>Verimu Security Report \u2014 ${this.escapeHtml(result.instanceUrl)}</title>
|
|
20334
|
+
${this.styles()}
|
|
20335
|
+
</head>
|
|
20336
|
+
<body>
|
|
20337
|
+
<div class="container">
|
|
20338
|
+
${this.header(result)}
|
|
20339
|
+
${this.summaryCards(result)}
|
|
20340
|
+
${this.severityChart(result)}
|
|
20341
|
+
${this.topVulnerabilities(result)}
|
|
20342
|
+
${this.repoBreakdown(result)}
|
|
20343
|
+
${this.ecosystemBreakdown(result)}
|
|
20344
|
+
${this.craTimeline(result)}
|
|
20345
|
+
${this.footer(result)}
|
|
20346
|
+
</div>
|
|
20347
|
+
</body>
|
|
20348
|
+
</html>`;
|
|
20349
|
+
}
|
|
20350
|
+
// ─── Sections ─────────────────────────────────────────────
|
|
20351
|
+
header(result) {
|
|
20352
|
+
const date = new Date(result.scannedAt).toLocaleDateString("en-US", {
|
|
20353
|
+
year: "numeric",
|
|
20354
|
+
month: "long",
|
|
20355
|
+
day: "numeric"
|
|
20356
|
+
});
|
|
20357
|
+
return `
|
|
20358
|
+
<header>
|
|
20359
|
+
<div class="brand">
|
|
20360
|
+
<div class="logo">V</div>
|
|
20361
|
+
<div>
|
|
20362
|
+
<h1>Security posture report</h1>
|
|
20363
|
+
<p class="subtitle">${this.escapeHtml(result.instanceUrl)} \u2014 ${date}</p>
|
|
20364
|
+
</div>
|
|
20365
|
+
</div>
|
|
20366
|
+
<p class="intro">
|
|
20367
|
+
This report summarizes the current vulnerability landscape across
|
|
20368
|
+
<strong>${result.summary.totalRepos} repositories</strong> containing
|
|
20369
|
+
<strong>${result.summary.totalDependencies.toLocaleString()} dependencies</strong>.
|
|
20370
|
+
Generated by <a href="https://verimu.com">Verimu</a> CRA Compliance Scanner.
|
|
20371
|
+
</p>
|
|
20372
|
+
</header>`;
|
|
20373
|
+
}
|
|
20374
|
+
summaryCards(result) {
|
|
20375
|
+
const s = result.summary;
|
|
20376
|
+
const vulnRepoPercent = s.totalRepos > 0 ? Math.round(s.reposWithVulnerabilities / s.totalRepos * 100) : 0;
|
|
20377
|
+
return `
|
|
20378
|
+
<section class="cards">
|
|
20379
|
+
<div class="card">
|
|
20380
|
+
<div class="card-value">${s.totalRepos}</div>
|
|
20381
|
+
<div class="card-label">Repos scanned</div>
|
|
20382
|
+
</div>
|
|
20383
|
+
<div class="card">
|
|
20384
|
+
<div class="card-value">${s.totalDependencies.toLocaleString()}</div>
|
|
20385
|
+
<div class="card-label">Total dependencies</div>
|
|
20386
|
+
</div>
|
|
20387
|
+
<div class="card card-danger">
|
|
20388
|
+
<div class="card-value">${s.totalVulnerabilities}</div>
|
|
20389
|
+
<div class="card-label">Vulnerabilities found</div>
|
|
20390
|
+
</div>
|
|
20391
|
+
<div class="card ${vulnRepoPercent > 50 ? "card-danger" : vulnRepoPercent > 25 ? "card-warn" : ""}">
|
|
20392
|
+
<div class="card-value">${vulnRepoPercent}%</div>
|
|
20393
|
+
<div class="card-label">Repos with vulns</div>
|
|
20394
|
+
</div>
|
|
20395
|
+
</section>`;
|
|
20396
|
+
}
|
|
20397
|
+
severityChart(result) {
|
|
20398
|
+
const s = result.summary;
|
|
20399
|
+
const total = s.totalVulnerabilities || 1;
|
|
20400
|
+
const bars = [
|
|
20401
|
+
{ label: "Critical", count: s.critical, cls: "sev-critical" },
|
|
20402
|
+
{ label: "High", count: s.high, cls: "sev-high" },
|
|
20403
|
+
{ label: "Medium", count: s.medium, cls: "sev-medium" },
|
|
20404
|
+
{ label: "Low", count: s.low, cls: "sev-low" }
|
|
20405
|
+
];
|
|
20406
|
+
const barHtml = bars.map((b) => {
|
|
20407
|
+
const width = Math.max(2, b.count / total * 100);
|
|
20408
|
+
return `
|
|
20409
|
+
<div class="bar-row">
|
|
20410
|
+
<span class="bar-label">${b.label}</span>
|
|
20411
|
+
<div class="bar-track">
|
|
20412
|
+
<div class="bar-fill ${b.cls}" style="width: ${width}%"></div>
|
|
20413
|
+
</div>
|
|
20414
|
+
<span class="bar-count">${b.count}</span>
|
|
20415
|
+
</div>`;
|
|
20416
|
+
}).join("");
|
|
20417
|
+
return `
|
|
20418
|
+
<section>
|
|
20419
|
+
<h2>Severity breakdown</h2>
|
|
20420
|
+
<div class="chart">${barHtml}</div>
|
|
20421
|
+
${s.exploitedInWild > 0 ? `
|
|
20422
|
+
<div class="alert alert-critical">
|
|
20423
|
+
<strong>\u26A0 ${s.exploitedInWild} vulnerabilit${s.exploitedInWild === 1 ? "y" : "ies"} actively exploited in the wild.</strong>
|
|
20424
|
+
Under the EU Cyber Resilience Act, actively exploited vulnerabilities require
|
|
20425
|
+
notification to ENISA within 24 hours of awareness.
|
|
20426
|
+
</div>` : ""}
|
|
20427
|
+
</section>`;
|
|
20428
|
+
}
|
|
20429
|
+
topVulnerabilities(result) {
|
|
20430
|
+
if (result.topVulnerabilities.length === 0) {
|
|
20431
|
+
return `
|
|
20432
|
+
<section>
|
|
20433
|
+
<h2>Vulnerabilities</h2>
|
|
20434
|
+
<p class="empty">No known vulnerabilities detected. Nice work.</p>
|
|
20435
|
+
</section>`;
|
|
20436
|
+
}
|
|
20437
|
+
const top = result.topVulnerabilities.slice(0, 30);
|
|
20438
|
+
const rows = top.map(
|
|
20439
|
+
(vuln) => `
|
|
20440
|
+
<tr>
|
|
20441
|
+
<td><span class="sev-badge ${this.sevClass(vuln.severity)}">${vuln.severity}</span></td>
|
|
20442
|
+
<td class="vuln-id">${this.escapeHtml(vuln.id)}</td>
|
|
20443
|
+
<td>${this.escapeHtml(vuln.summary.slice(0, 120))}</td>
|
|
20444
|
+
<td>${vuln.fixedVersion ? this.escapeHtml(vuln.fixedVersion) : '<span class="no-fix">none</span>'}</td>
|
|
20445
|
+
<td>${vuln.affectedRepos.length}</td>
|
|
20446
|
+
<td>${vuln.exploitedInWild ? '<span class="exploited">YES</span>' : ""}</td>
|
|
20447
|
+
</tr>`
|
|
20448
|
+
).join("");
|
|
20449
|
+
return `
|
|
20450
|
+
<section>
|
|
20451
|
+
<h2>Top vulnerabilities</h2>
|
|
20452
|
+
<p class="section-desc">Sorted by severity, then by number of affected repositories.</p>
|
|
20453
|
+
<div class="table-wrap">
|
|
20454
|
+
<table>
|
|
20455
|
+
<thead>
|
|
20456
|
+
<tr>
|
|
20457
|
+
<th>Severity</th>
|
|
20458
|
+
<th>ID</th>
|
|
20459
|
+
<th>Summary</th>
|
|
20460
|
+
<th>Fix</th>
|
|
20461
|
+
<th>Repos</th>
|
|
20462
|
+
<th>Exploited</th>
|
|
20463
|
+
</tr>
|
|
20464
|
+
</thead>
|
|
20465
|
+
<tbody>${rows}</tbody>
|
|
20466
|
+
</table>
|
|
20467
|
+
</div>
|
|
20468
|
+
</section>`;
|
|
20469
|
+
}
|
|
20470
|
+
repoBreakdown(result) {
|
|
20471
|
+
const repos = result.scannedRepos.filter((r) => r.reports.length > 0).sort((a, b) => {
|
|
20472
|
+
const aVulns = a.reports.reduce((s, r) => s + r.summary.totalVulnerabilities, 0);
|
|
20473
|
+
const bVulns = b.reports.reduce((s, r) => s + r.summary.totalVulnerabilities, 0);
|
|
20474
|
+
return bVulns - aVulns;
|
|
20475
|
+
});
|
|
20476
|
+
const rows = repos.map((r) => {
|
|
20477
|
+
const s = r.reports.reduce((acc, rpt) => ({ totalVulnerabilities: acc.totalVulnerabilities + rpt.summary.totalVulnerabilities, critical: acc.critical + rpt.summary.critical, high: acc.high + rpt.summary.high, medium: acc.medium + rpt.summary.medium, low: acc.low + rpt.summary.low, totalDependencies: acc.totalDependencies + rpt.summary.totalDependencies }), { totalVulnerabilities: 0, critical: 0, high: 0, medium: 0, low: 0, totalDependencies: 0 });
|
|
20478
|
+
const vulnBadge = s.totalVulnerabilities > 0 ? `<span class="sev-badge ${s.critical > 0 ? "sev-critical" : s.high > 0 ? "sev-high" : s.medium > 0 ? "sev-medium" : "sev-low"}">${s.totalVulnerabilities}</span>` : '<span class="clean">clean</span>';
|
|
20479
|
+
return `
|
|
20480
|
+
<tr>
|
|
20481
|
+
<td class="repo-name">${this.escapeHtml(r.project.path_with_namespace)}</td>
|
|
20482
|
+
<td>${r.reports.map((rpt) => rpt.project.ecosystem).filter((v, i, a) => a.indexOf(v) === i).join(", ")}</td>
|
|
20483
|
+
<td>${s.totalDependencies}</td>
|
|
20484
|
+
<td>${vulnBadge}</td>
|
|
20485
|
+
<td>${s.critical}</td>
|
|
20486
|
+
<td>${s.high}</td>
|
|
20487
|
+
<td>${s.medium}</td>
|
|
20488
|
+
<td>${s.low}</td>
|
|
20489
|
+
</tr>`;
|
|
20490
|
+
}).join("");
|
|
20491
|
+
return `
|
|
20492
|
+
<section>
|
|
20493
|
+
<h2>Repository breakdown</h2>
|
|
20494
|
+
<p class="section-desc">${repos.length} repositories with dependency lockfiles.</p>
|
|
20495
|
+
<div class="table-wrap">
|
|
20496
|
+
<table>
|
|
20497
|
+
<thead>
|
|
20498
|
+
<tr>
|
|
20499
|
+
<th>Repository</th>
|
|
20500
|
+
<th>Ecosystem</th>
|
|
20501
|
+
<th>Deps</th>
|
|
20502
|
+
<th>Vulns</th>
|
|
20503
|
+
<th>Crit</th>
|
|
20504
|
+
<th>High</th>
|
|
20505
|
+
<th>Med</th>
|
|
20506
|
+
<th>Low</th>
|
|
20507
|
+
</tr>
|
|
20508
|
+
</thead>
|
|
20509
|
+
<tbody>${rows}</tbody>
|
|
20510
|
+
</table>
|
|
20511
|
+
</div>
|
|
20512
|
+
</section>`;
|
|
20513
|
+
}
|
|
20514
|
+
ecosystemBreakdown(result) {
|
|
20515
|
+
const eco = result.summary.ecosystemBreakdown;
|
|
20516
|
+
if (Object.keys(eco).length === 0) return "";
|
|
20517
|
+
const items = Object.entries(eco).sort((a, b) => b[1] - a[1]).map(([name, count]) => `
|
|
20518
|
+
<div class="eco-item">
|
|
20519
|
+
<span class="eco-name">${name}</span>
|
|
20520
|
+
<span class="eco-count">${count} repo${count !== 1 ? "s" : ""}</span>
|
|
20521
|
+
</div>`).join("");
|
|
20522
|
+
return `
|
|
20523
|
+
<section>
|
|
20524
|
+
<h2>Ecosystem distribution</h2>
|
|
20525
|
+
<div class="eco-grid">${items}</div>
|
|
20526
|
+
</section>`;
|
|
20527
|
+
}
|
|
20528
|
+
craTimeline(result) {
|
|
20529
|
+
const s = result.summary;
|
|
20530
|
+
if (s.totalVulnerabilities === 0) return "";
|
|
20531
|
+
return `
|
|
20532
|
+
<section>
|
|
20533
|
+
<h2>CRA compliance implications</h2>
|
|
20534
|
+
<div class="cra-box">
|
|
20535
|
+
<p>The <strong>EU Cyber Resilience Act</strong> (Regulation 2024/2847) establishes
|
|
20536
|
+
mandatory cybersecurity requirements for products with digital elements.
|
|
20537
|
+
Key obligations relevant to this scan:</p>
|
|
20538
|
+
<ul>
|
|
20539
|
+
<li><strong>Article 14(2):</strong> Manufacturers must identify and document
|
|
20540
|
+
vulnerabilities, including in third-party components (your dependencies).</li>
|
|
20541
|
+
<li><strong>Article 14(4):</strong> Actively exploited vulnerabilities must be
|
|
20542
|
+
reported to ENISA within 24 hours of awareness${s.exploitedInWild > 0 ? ` \u2014 <strong class="text-danger">${s.exploitedInWild} found in this scan</strong>` : ""}.</li>
|
|
20543
|
+
<li><strong>Article 14(3):</strong> Security updates must be provided free of charge
|
|
20544
|
+
for the support period.</li>
|
|
20545
|
+
<li><strong>Annex I, Part II(1):</strong> An SBOM documenting all components must
|
|
20546
|
+
be maintained \u2014 Verimu generates these in CycloneDX, SPDX, and SWID formats.</li>
|
|
20547
|
+
</ul>
|
|
20548
|
+
<p class="cra-note">Full enforcement begins <strong>11 December 2027</strong>.
|
|
20549
|
+
Vulnerability reporting obligations apply from <strong>11 September 2026</strong>.</p>
|
|
20550
|
+
</div>
|
|
20551
|
+
</section>`;
|
|
20552
|
+
}
|
|
20553
|
+
footer(result) {
|
|
20554
|
+
const duration = (result.durationMs / 1e3).toFixed(1);
|
|
20555
|
+
return `
|
|
20556
|
+
<footer>
|
|
20557
|
+
<p>Generated by <a href="https://verimu.com">Verimu</a> CRA Compliance Scanner
|
|
20558
|
+
in ${duration}s on ${new Date(result.scannedAt).toISOString()}</p>
|
|
20559
|
+
<p class="footer-cta">
|
|
20560
|
+
Continuous monitoring, SBOM management, and CRA compliance dashboards available at
|
|
20561
|
+
<a href="https://app.verimu.com">app.verimu.com</a>
|
|
20562
|
+
</p>
|
|
20563
|
+
</footer>`;
|
|
20564
|
+
}
|
|
20565
|
+
// ─── Styling ──────────────────────────────────────────────
|
|
20566
|
+
styles() {
|
|
20567
|
+
return `<style>
|
|
20568
|
+
:root {
|
|
20569
|
+
--bg: #ffffff; --bg2: #f7f8fa; --text: #1a1a2e; --text2: #555770;
|
|
20570
|
+
--border: #e2e4ea; --accent: #4f46e5; --accent-light: #eef2ff;
|
|
20571
|
+
--crit: #dc2626; --crit-bg: #fef2f2;
|
|
20572
|
+
--high: #ea580c; --high-bg: #fff7ed;
|
|
20573
|
+
--med: #d97706; --med-bg: #fffbeb;
|
|
20574
|
+
--low: #2563eb; --low-bg: #eff6ff;
|
|
20575
|
+
--green: #16a34a; --green-bg: #f0fdf4;
|
|
20576
|
+
--radius: 8px; --radius-lg: 12px;
|
|
20577
|
+
}
|
|
20578
|
+
@media (prefers-color-scheme: dark) {
|
|
20579
|
+
:root {
|
|
20580
|
+
--bg: #0f0f1a; --bg2: #1a1a2e; --text: #e4e4f0; --text2: #9595ad;
|
|
20581
|
+
--border: #2a2a40; --accent: #818cf8; --accent-light: #1e1b4b;
|
|
20582
|
+
--crit-bg: #2a0a0a; --high-bg: #2a1a0a; --med-bg: #2a2200; --low-bg: #0a1a2e;
|
|
20583
|
+
--green-bg: #0a2a1a;
|
|
20584
|
+
}
|
|
20585
|
+
}
|
|
20586
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
20587
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
20588
|
+
background: var(--bg); color: var(--text); line-height: 1.6; }
|
|
20589
|
+
.container { max-width: 960px; margin: 0 auto; padding: 2rem 1.5rem; }
|
|
20590
|
+
a { color: var(--accent); text-decoration: none; }
|
|
20591
|
+
a:hover { text-decoration: underline; }
|
|
20592
|
+
|
|
20593
|
+
/* Header */
|
|
20594
|
+
header { margin-bottom: 2rem; }
|
|
20595
|
+
.brand { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
|
20596
|
+
.logo { width: 48px; height: 48px; background: var(--accent); color: #fff;
|
|
20597
|
+
border-radius: var(--radius); display: flex; align-items: center; justify-content: center;
|
|
20598
|
+
font-size: 24px; font-weight: 700; }
|
|
20599
|
+
h1 { font-size: 22px; font-weight: 600; }
|
|
20600
|
+
.subtitle { color: var(--text2); font-size: 14px; }
|
|
20601
|
+
.intro { color: var(--text2); font-size: 15px; max-width: 700px; }
|
|
20602
|
+
|
|
20603
|
+
/* Cards */
|
|
20604
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
20605
|
+
gap: 12px; margin-bottom: 2rem; }
|
|
20606
|
+
.card { background: var(--bg2); border-radius: var(--radius-lg); padding: 1.25rem;
|
|
20607
|
+
border: 1px solid var(--border); }
|
|
20608
|
+
.card-value { font-size: 28px; font-weight: 700; }
|
|
20609
|
+
.card-label { font-size: 13px; color: var(--text2); margin-top: 4px; }
|
|
20610
|
+
.card-danger .card-value { color: var(--crit); }
|
|
20611
|
+
.card-warn .card-value { color: var(--med); }
|
|
20612
|
+
|
|
20613
|
+
/* Sections */
|
|
20614
|
+
section { margin-bottom: 2.5rem; }
|
|
20615
|
+
h2 { font-size: 18px; font-weight: 600; margin-bottom: 0.75rem; }
|
|
20616
|
+
.section-desc { color: var(--text2); font-size: 14px; margin-bottom: 1rem; }
|
|
20617
|
+
.empty { color: var(--green); font-weight: 500; }
|
|
20618
|
+
|
|
20619
|
+
/* Bar chart */
|
|
20620
|
+
.chart { max-width: 500px; }
|
|
20621
|
+
.bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
20622
|
+
.bar-label { width: 60px; font-size: 13px; color: var(--text2); text-align: right; }
|
|
20623
|
+
.bar-track { flex: 1; height: 24px; background: var(--bg2); border-radius: 4px;
|
|
20624
|
+
overflow: hidden; border: 1px solid var(--border); }
|
|
20625
|
+
.bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
|
20626
|
+
.bar-count { width: 36px; font-size: 14px; font-weight: 600; }
|
|
20627
|
+
.sev-critical, .bar-fill.sev-critical { background: var(--crit); color: #fff; }
|
|
20628
|
+
.sev-high, .bar-fill.sev-high { background: var(--high); color: #fff; }
|
|
20629
|
+
.sev-medium, .bar-fill.sev-medium { background: var(--med); color: #fff; }
|
|
20630
|
+
.sev-low, .bar-fill.sev-low { background: var(--low); color: #fff; }
|
|
20631
|
+
|
|
20632
|
+
/* Tables */
|
|
20633
|
+
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); }
|
|
20634
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
20635
|
+
th { background: var(--bg2); font-weight: 600; font-size: 12px; text-transform: uppercase;
|
|
20636
|
+
letter-spacing: 0.5px; color: var(--text2); padding: 10px 12px; text-align: left;
|
|
20637
|
+
border-bottom: 1px solid var(--border); }
|
|
20638
|
+
td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
20639
|
+
tr:last-child td { border-bottom: none; }
|
|
20640
|
+
tr:hover td { background: var(--bg2); }
|
|
20641
|
+
.repo-name { font-weight: 500; font-size: 13px; }
|
|
20642
|
+
.vuln-id { font-family: monospace; font-size: 12px; white-space: nowrap; }
|
|
20643
|
+
|
|
20644
|
+
/* Badges */
|
|
20645
|
+
.sev-badge { display: inline-block; padding: 2px 8px; border-radius: 4px;
|
|
20646
|
+
font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
|
20647
|
+
.clean { color: var(--green); font-size: 12px; font-weight: 500; }
|
|
20648
|
+
.no-fix { color: var(--text2); font-size: 12px; }
|
|
20649
|
+
.exploited { color: var(--crit); font-weight: 700; font-size: 12px; }
|
|
20650
|
+
|
|
20651
|
+
/* Alert */
|
|
20652
|
+
.alert { padding: 1rem 1.25rem; border-radius: var(--radius); margin-top: 1rem; font-size: 14px; }
|
|
20653
|
+
.alert-critical { background: var(--crit-bg); border: 1px solid var(--crit); color: var(--crit); }
|
|
20654
|
+
|
|
20655
|
+
/* Ecosystem grid */
|
|
20656
|
+
.eco-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
20657
|
+
.eco-item { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
|
|
20658
|
+
padding: 8px 16px; display: flex; align-items: center; gap: 8px; }
|
|
20659
|
+
.eco-name { font-weight: 600; font-size: 14px; }
|
|
20660
|
+
.eco-count { color: var(--text2); font-size: 13px; }
|
|
20661
|
+
|
|
20662
|
+
/* CRA */
|
|
20663
|
+
.cra-box { background: var(--accent-light); border: 1px solid var(--accent);
|
|
20664
|
+
border-radius: var(--radius-lg); padding: 1.5rem; font-size: 14px; }
|
|
20665
|
+
.cra-box ul { margin: 0.75rem 0; padding-left: 1.5rem; }
|
|
20666
|
+
.cra-box li { margin-bottom: 0.5rem; }
|
|
20667
|
+
.cra-note { margin-top: 1rem; font-weight: 600; }
|
|
20668
|
+
.text-danger { color: var(--crit); }
|
|
20669
|
+
|
|
20670
|
+
/* Footer */
|
|
20671
|
+
footer { border-top: 1px solid var(--border); padding-top: 1.5rem; margin-top: 2rem;
|
|
20672
|
+
color: var(--text2); font-size: 13px; }
|
|
20673
|
+
.footer-cta { margin-top: 0.5rem; }
|
|
20674
|
+
|
|
20675
|
+
@media print {
|
|
20676
|
+
.container { max-width: 100%; }
|
|
20677
|
+
.card { break-inside: avoid; }
|
|
20678
|
+
}
|
|
20679
|
+
</style>`;
|
|
20680
|
+
}
|
|
20681
|
+
// ─── Helpers ──────────────────────────────────────────────
|
|
20682
|
+
escapeHtml(str) {
|
|
20683
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
20684
|
+
}
|
|
20685
|
+
sevClass(severity) {
|
|
20686
|
+
const map = {
|
|
20687
|
+
CRITICAL: "sev-critical",
|
|
20688
|
+
HIGH: "sev-high",
|
|
20689
|
+
MEDIUM: "sev-medium",
|
|
20690
|
+
LOW: "sev-low",
|
|
20691
|
+
UNKNOWN: "sev-low"
|
|
20692
|
+
};
|
|
20693
|
+
return map[severity] ?? "sev-low";
|
|
20694
|
+
}
|
|
20695
|
+
};
|
|
20696
|
+
|
|
19270
20697
|
// src/cli.ts
|
|
19271
20698
|
var require2 = createRequire(import.meta.url);
|
|
19272
20699
|
var pkg = require2("../package.json");
|
|
@@ -19303,6 +20730,14 @@ function parseArgs(argv) {
|
|
|
19303
20730
|
groupName: void 0,
|
|
19304
20731
|
recursive: true,
|
|
19305
20732
|
// Recursive by default
|
|
20733
|
+
gitlabUrl: void 0,
|
|
20734
|
+
gitlabToken: void 0,
|
|
20735
|
+
gitlabGroups: void 0,
|
|
20736
|
+
excludeArchived: true,
|
|
20737
|
+
excludeForks: true,
|
|
20738
|
+
maxRepos: void 0,
|
|
20739
|
+
htmlOutput: void 0,
|
|
20740
|
+
jsonOutput: void 0,
|
|
19306
20741
|
exclude: void 0
|
|
19307
20742
|
};
|
|
19308
20743
|
let i = 0;
|
|
@@ -19313,6 +20748,37 @@ function parseArgs(argv) {
|
|
|
19313
20748
|
} else if (arg === "generate-sbom" || arg === "sbom") {
|
|
19314
20749
|
result.command = "generate-sbom";
|
|
19315
20750
|
result.skipCveCheck = true;
|
|
20751
|
+
} else if (arg === "gitlab") {
|
|
20752
|
+
result.command = "gitlab";
|
|
20753
|
+
} else if (arg === "github") {
|
|
20754
|
+
result.command = "github";
|
|
20755
|
+
} else if (arg === "--url") {
|
|
20756
|
+
const urlVal = args[++i] ?? "";
|
|
20757
|
+
result.gitlabUrl = urlVal;
|
|
20758
|
+
result.githubUrl = urlVal;
|
|
20759
|
+
} else if (arg === "--token") {
|
|
20760
|
+
const tokenVal = args[++i] ?? "";
|
|
20761
|
+
result.gitlabToken = tokenVal;
|
|
20762
|
+
result.githubToken = tokenVal;
|
|
20763
|
+
} else if (arg === "--profile") {
|
|
20764
|
+
result.githubProfile = args[++i] ?? "";
|
|
20765
|
+
} else if (arg === "--owner-only") {
|
|
20766
|
+
result.githubOwnerOnly = true;
|
|
20767
|
+
} else if (arg === "--groups") {
|
|
20768
|
+
const val = args[++i] ?? "";
|
|
20769
|
+
result.gitlabGroups = val.split(",").map((g) => g.trim());
|
|
20770
|
+
} else if (arg === "--no-archived") {
|
|
20771
|
+
result.excludeArchived = true;
|
|
20772
|
+
} else if (arg === "--include-archived") {
|
|
20773
|
+
result.excludeArchived = false;
|
|
20774
|
+
} else if (arg === "--include-forks") {
|
|
20775
|
+
result.excludeForks = false;
|
|
20776
|
+
} else if (arg === "--max-repos") {
|
|
20777
|
+
result.maxRepos = Number.parseInt(args[++i] ?? "0", 10);
|
|
20778
|
+
} else if (arg === "--html-output" || arg === "--html") {
|
|
20779
|
+
result.htmlOutput = args[++i] ?? "./verimu-report.html";
|
|
20780
|
+
} else if (arg === "--json-output") {
|
|
20781
|
+
result.jsonOutput = args[++i] ?? "./verimu-report.json";
|
|
19316
20782
|
} else if (arg === "help" || arg === "--help" || arg === "-h") {
|
|
19317
20783
|
result.command = "help";
|
|
19318
20784
|
} else if (arg === "version" || arg === "--version" || arg === "-v") {
|
|
@@ -19386,6 +20852,16 @@ async function main() {
|
|
|
19386
20852
|
printHelp();
|
|
19387
20853
|
return;
|
|
19388
20854
|
}
|
|
20855
|
+
if (args.command === "gitlab") {
|
|
20856
|
+
console.log(BRAND);
|
|
20857
|
+
await runGitLabScan(args);
|
|
20858
|
+
return;
|
|
20859
|
+
}
|
|
20860
|
+
if (args.command === "github") {
|
|
20861
|
+
console.log(BRAND);
|
|
20862
|
+
await runGitHubScan(args);
|
|
20863
|
+
return;
|
|
20864
|
+
}
|
|
19389
20865
|
console.log(BRAND);
|
|
19390
20866
|
const apiKey = process.env.VERIMU_API_KEY;
|
|
19391
20867
|
const apiBaseUrl = process.env.VERIMU_API_URL;
|
|
@@ -19471,6 +20947,146 @@ async function main() {
|
|
|
19471
20947
|
process.exit(1);
|
|
19472
20948
|
}
|
|
19473
20949
|
}
|
|
20950
|
+
async function runGitLabScan(args) {
|
|
20951
|
+
const url = args.gitlabUrl || process.env.GITLAB_URL || process.env.VERIMU_GITLAB_URL;
|
|
20952
|
+
const token = args.gitlabToken || process.env.GITLAB_TOKEN || process.env.VERIMU_GITLAB_TOKEN;
|
|
20953
|
+
if (!url) {
|
|
20954
|
+
logError("GitLab URL required. Use --url or set GITLAB_URL / VERIMU_GITLAB_URL");
|
|
20955
|
+
process.exit(2);
|
|
20956
|
+
}
|
|
20957
|
+
if (!token) {
|
|
20958
|
+
logError("GitLab token required. Use --token or set GITLAB_TOKEN / VERIMU_GITLAB_TOKEN");
|
|
20959
|
+
process.exit(2);
|
|
20960
|
+
}
|
|
20961
|
+
const config = {
|
|
20962
|
+
url,
|
|
20963
|
+
token,
|
|
20964
|
+
groups: args.gitlabGroups,
|
|
20965
|
+
excludeArchived: args.excludeArchived ?? true,
|
|
20966
|
+
excludeForks: args.excludeForks ?? true,
|
|
20967
|
+
maxRepos: args.maxRepos,
|
|
20968
|
+
htmlOutput: args.htmlOutput,
|
|
20969
|
+
jsonOutput: args.jsonOutput,
|
|
20970
|
+
skipCveCheck: args.skipCveCheck,
|
|
20971
|
+
apiKey: process.env.VERIMU_API_KEY,
|
|
20972
|
+
apiBaseUrl: process.env.VERIMU_API_URL,
|
|
20973
|
+
groupName: args.groupName
|
|
20974
|
+
};
|
|
20975
|
+
const orchestrator = new GitLabOrchestrator();
|
|
20976
|
+
const result = await orchestrator.scanInstance(config);
|
|
20977
|
+
if (args.htmlOutput) {
|
|
20978
|
+
const reporter = new HtmlReporter();
|
|
20979
|
+
const html = reporter.generate(result);
|
|
20980
|
+
const { writeFile: wf } = await import("fs/promises");
|
|
20981
|
+
await wf(args.htmlOutput, html, "utf-8");
|
|
20982
|
+
logSuccess("HTML report: " + args.htmlOutput);
|
|
20983
|
+
}
|
|
20984
|
+
if (args.jsonOutput) {
|
|
20985
|
+
const { writeFile: wf } = await import("fs/promises");
|
|
20986
|
+
await wf(args.jsonOutput, JSON.stringify(result, null, 2), "utf-8");
|
|
20987
|
+
logSuccess("JSON report: " + args.jsonOutput);
|
|
20988
|
+
}
|
|
20989
|
+
if (result.summary.totalVulnerabilities > 0 && args.failOnSeverity) {
|
|
20990
|
+
process.exit(1);
|
|
20991
|
+
}
|
|
20992
|
+
}
|
|
20993
|
+
async function runGitHubScan(args) {
|
|
20994
|
+
const baseUrl = args.githubUrl || process.env.GITHUB_URL || "https://github.com";
|
|
20995
|
+
const token = args.githubToken || process.env.GITHUB_TOKEN;
|
|
20996
|
+
const profile = args.githubProfile || process.env.GITHUB_PROFILE;
|
|
20997
|
+
if (!profile) {
|
|
20998
|
+
logError("GitHub profile required. Use --profile <org-or-user> or set GITHUB_PROFILE");
|
|
20999
|
+
log(" Example: npx verimu github --profile octokit");
|
|
21000
|
+
log(" Example: npx verimu github --profile https://github.com/my-org");
|
|
21001
|
+
process.exit(2);
|
|
21002
|
+
}
|
|
21003
|
+
if (!token) {
|
|
21004
|
+
logWarn("No GitHub token provided. Only public repos will be scanned (60 API requests/hour).");
|
|
21005
|
+
log(" Use --token or set GITHUB_TOKEN for private repo access (5,000 requests/hour).");
|
|
21006
|
+
console.log("");
|
|
21007
|
+
}
|
|
21008
|
+
const config = {
|
|
21009
|
+
baseUrl,
|
|
21010
|
+
profile,
|
|
21011
|
+
token: token || void 0,
|
|
21012
|
+
ownerOnly: args.githubOwnerOnly ?? false,
|
|
21013
|
+
excludeArchived: args.excludeArchived ?? true,
|
|
21014
|
+
excludeForks: args.excludeForks ?? true,
|
|
21015
|
+
maxRepos: args.maxRepos,
|
|
21016
|
+
htmlOutput: args.htmlOutput,
|
|
21017
|
+
jsonOutput: args.jsonOutput,
|
|
21018
|
+
skipCveCheck: args.skipCveCheck,
|
|
21019
|
+
apiKey: process.env.VERIMU_API_KEY,
|
|
21020
|
+
apiBaseUrl: process.env.VERIMU_API_URL,
|
|
21021
|
+
groupName: args.groupName
|
|
21022
|
+
};
|
|
21023
|
+
const orchestrator = new GitHubOrchestrator();
|
|
21024
|
+
const result = await orchestrator.scanProfile(config);
|
|
21025
|
+
if (args.htmlOutput) {
|
|
21026
|
+
const reporter = new HtmlReporter();
|
|
21027
|
+
const adapted = adaptGitHubResultForHtml(result);
|
|
21028
|
+
const html = reporter.generate(adapted);
|
|
21029
|
+
const { writeFile: wf } = await import("fs/promises");
|
|
21030
|
+
await wf(args.htmlOutput, html, "utf-8");
|
|
21031
|
+
logSuccess("HTML report: " + args.htmlOutput);
|
|
21032
|
+
}
|
|
21033
|
+
if (args.jsonOutput) {
|
|
21034
|
+
const { writeFile: wf } = await import("fs/promises");
|
|
21035
|
+
await wf(args.jsonOutput, JSON.stringify(result, null, 2), "utf-8");
|
|
21036
|
+
logSuccess("JSON report: " + args.jsonOutput);
|
|
21037
|
+
}
|
|
21038
|
+
if (result.summary.totalVulnerabilities > 0 && args.failOnSeverity) {
|
|
21039
|
+
process.exit(1);
|
|
21040
|
+
}
|
|
21041
|
+
}
|
|
21042
|
+
function adaptGitHubResultForHtml(result) {
|
|
21043
|
+
const toGitLabProject = (repo) => ({
|
|
21044
|
+
id: repo.id,
|
|
21045
|
+
name: repo.name,
|
|
21046
|
+
name_with_namespace: repo.full_name,
|
|
21047
|
+
path: repo.name,
|
|
21048
|
+
path_with_namespace: repo.full_name,
|
|
21049
|
+
description: null,
|
|
21050
|
+
http_url_to_repo: repo.clone_url,
|
|
21051
|
+
ssh_url_to_repo: "",
|
|
21052
|
+
web_url: repo.html_url,
|
|
21053
|
+
default_branch: repo.default_branch,
|
|
21054
|
+
archived: repo.archived,
|
|
21055
|
+
empty_repo: false,
|
|
21056
|
+
visibility: repo.private ? "private" : "public",
|
|
21057
|
+
last_activity_at: "",
|
|
21058
|
+
namespace: {
|
|
21059
|
+
id: 0,
|
|
21060
|
+
name: repo.owner.login,
|
|
21061
|
+
path: repo.owner.login,
|
|
21062
|
+
kind: repo.owner.type === "Organization" ? "group" : "user",
|
|
21063
|
+
full_path: repo.owner.login
|
|
21064
|
+
}
|
|
21065
|
+
});
|
|
21066
|
+
return {
|
|
21067
|
+
instanceUrl: `${result.instanceUrl}/${result.profile}`,
|
|
21068
|
+
totalReposDiscovered: result.totalReposDiscovered,
|
|
21069
|
+
scannedRepos: result.scannedRepos.map((r) => ({
|
|
21070
|
+
project: toGitLabProject(r.repo),
|
|
21071
|
+
reports: r.reports,
|
|
21072
|
+
hasLockfile: r.hasLockfile,
|
|
21073
|
+
error: r.error,
|
|
21074
|
+
durationMs: r.durationMs
|
|
21075
|
+
})),
|
|
21076
|
+
skippedRepos: result.skippedRepos.map((s) => ({
|
|
21077
|
+
project: toGitLabProject(s.repo),
|
|
21078
|
+
reason: s.reason
|
|
21079
|
+
})),
|
|
21080
|
+
failedRepos: result.failedRepos.map((f) => ({
|
|
21081
|
+
project: toGitLabProject(f.repo),
|
|
21082
|
+
error: f.error
|
|
21083
|
+
})),
|
|
21084
|
+
summary: result.summary,
|
|
21085
|
+
topVulnerabilities: result.topVulnerabilities,
|
|
21086
|
+
scannedAt: result.scannedAt,
|
|
21087
|
+
durationMs: result.durationMs
|
|
21088
|
+
};
|
|
21089
|
+
}
|
|
19474
21090
|
function printMultiProjectSummary(result) {
|
|
19475
21091
|
console.log("\n" + "\u2500".repeat(60));
|
|
19476
21092
|
console.log("Multi-Project Scan Summary");
|
|
@@ -19520,6 +21136,37 @@ function printHelp() {
|
|
|
19520
21136
|
verimu help Show this help
|
|
19521
21137
|
verimu version Show version
|
|
19522
21138
|
|
|
21139
|
+
GitLab scanning:
|
|
21140
|
+
verimu gitlab [options] Scan all repos on a GitLab instance
|
|
21141
|
+
|
|
21142
|
+
GitLab options:
|
|
21143
|
+
--url <url> GitLab instance URL (or GITLAB_URL env)
|
|
21144
|
+
--token <token> Personal access token (or GITLAB_TOKEN env)
|
|
21145
|
+
--groups <g1,g2> Only scan repos in these groups
|
|
21146
|
+
--include-archived Include archived repos (excluded by default)
|
|
21147
|
+
--include-forks Include forked repos (excluded by default)
|
|
21148
|
+
--max-repos <n> Limit number of repos to scan
|
|
21149
|
+
--html-output <file> Write HTML report (e.g., ./report.html)
|
|
21150
|
+
--json-output <file> Write JSON aggregate report
|
|
21151
|
+
|
|
21152
|
+
GitHub scanning:
|
|
21153
|
+
verimu github [options] Scan repos for a GitHub org or user
|
|
21154
|
+
|
|
21155
|
+
GitHub options:
|
|
21156
|
+
--profile <handle|url> GitHub org/user to scan (required)
|
|
21157
|
+
--url <url> GitHub base URL (default: https://github.com, for GHES)
|
|
21158
|
+
--token <token> GitHub PAT (or GITHUB_TOKEN env). Without token: public
|
|
21159
|
+
repos only, 60 API requests/hour. With token: private +
|
|
21160
|
+
public repos, 5,000 requests/hour.
|
|
21161
|
+
--owner-only For user profiles, list only owner repos (default: all)
|
|
21162
|
+
--include-archived Include archived repos (excluded by default)
|
|
21163
|
+
--include-forks Include forked repos (excluded by default)
|
|
21164
|
+
--max-repos <n> Limit number of repos to scan
|
|
21165
|
+
--html-output <file> Write HTML report
|
|
21166
|
+
--json-output <file> Write JSON aggregate report
|
|
21167
|
+
--skip-cve Skip CVE vulnerability checking
|
|
21168
|
+
--group-name <name> Group name for Verimu platform
|
|
21169
|
+
|
|
19523
21170
|
Options:
|
|
19524
21171
|
--path, -p <dir> Project directory to scan (default: .)
|
|
19525
21172
|
--output, -o <file> CycloneDX output path (SPDX/SWID are written alongside it)
|
|
@@ -19541,6 +21188,8 @@ function printHelp() {
|
|
|
19541
21188
|
Environment:
|
|
19542
21189
|
VERIMU_API_KEY API key for Verimu platform (from app.verimu.com)
|
|
19543
21190
|
VERIMU_API_URL Custom API URL (default: https://api.verimu.com)
|
|
21191
|
+
GITHUB_TOKEN GitHub personal access token
|
|
21192
|
+
GITHUB_URL GitHub base URL for GHES (default: https://github.com)
|
|
19544
21193
|
|
|
19545
21194
|
Examples:
|
|
19546
21195
|
npx verimu # Scan all projects recursively
|
|
@@ -19553,6 +21202,18 @@ function printHelp() {
|
|
|
19553
21202
|
npx verimu scan --no-recursive # Scan only root directory
|
|
19554
21203
|
npx verimu scan --exclude "legacy/*" # Exclude legacy projects
|
|
19555
21204
|
|
|
21205
|
+
GitLab examples:
|
|
21206
|
+
GITLAB_TOKEN=xxx npx verimu gitlab --url https://git.example.com --html report.html
|
|
21207
|
+
npx verimu gitlab --url https://git.example.com --token xxx --groups myteam
|
|
21208
|
+
npx verimu gitlab --url https://git.example.com --token xxx --max-repos 5
|
|
21209
|
+
|
|
21210
|
+
GitHub examples:
|
|
21211
|
+
npx verimu github --profile octokit --max-repos 5 --json-output report.json
|
|
21212
|
+
npx verimu github --profile Saksham0170 --owner-only
|
|
21213
|
+
GITHUB_TOKEN=ghp_xxx npx verimu github --profile my-org --html-output report.html
|
|
21214
|
+
npx verimu github --profile https://github.com/my-org --token ghp_xxx
|
|
21215
|
+
npx verimu github --url https://github.company.com --profile team --token xxx
|
|
21216
|
+
|
|
19556
21217
|
Supported ecosystems:
|
|
19557
21218
|
npm (package-lock.json) pip (requirements.txt)
|
|
19558
21219
|
Maven (pom.xml) NuGet (packages.lock.json)
|