verimu 0.0.21 → 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 CHANGED
@@ -18745,10 +18745,12 @@ async function uploadToVerimu(report, config) {
18745
18745
  throw new Error("API key required for upload");
18746
18746
  }
18747
18747
  const client = new VerimuApiClient(config.apiKey, config.apiBaseUrl);
18748
- const projectName = basename(config.projectPath);
18748
+ const projectName = config.uploadProjectName ?? basename(config.projectPath);
18749
18749
  const upsertRes = await client.upsertProject({
18750
18750
  name: projectName,
18751
18751
  ecosystem: report.project.ecosystem,
18752
+ repositoryUrl: config.repositoryUrl,
18753
+ platform: config.platform,
18752
18754
  groupName: config.groupName
18753
18755
  });
18754
18756
  const projectId = upsertRes.project.id;
@@ -19270,6 +19272,1428 @@ Discovering projects in ${config.projectPath}...`);
19270
19272
  }
19271
19273
  };
19272
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+
19273
20697
  // src/cli.ts
19274
20698
  var require2 = createRequire(import.meta.url);
19275
20699
  var pkg = require2("../package.json");
@@ -19306,6 +20730,14 @@ function parseArgs(argv) {
19306
20730
  groupName: void 0,
19307
20731
  recursive: true,
19308
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,
19309
20741
  exclude: void 0
19310
20742
  };
19311
20743
  let i = 0;
@@ -19316,6 +20748,37 @@ function parseArgs(argv) {
19316
20748
  } else if (arg === "generate-sbom" || arg === "sbom") {
19317
20749
  result.command = "generate-sbom";
19318
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";
19319
20782
  } else if (arg === "help" || arg === "--help" || arg === "-h") {
19320
20783
  result.command = "help";
19321
20784
  } else if (arg === "version" || arg === "--version" || arg === "-v") {
@@ -19389,6 +20852,16 @@ async function main() {
19389
20852
  printHelp();
19390
20853
  return;
19391
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
+ }
19392
20865
  console.log(BRAND);
19393
20866
  const apiKey = process.env.VERIMU_API_KEY;
19394
20867
  const apiBaseUrl = process.env.VERIMU_API_URL;
@@ -19474,6 +20947,146 @@ async function main() {
19474
20947
  process.exit(1);
19475
20948
  }
19476
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
+ }
19477
21090
  function printMultiProjectSummary(result) {
19478
21091
  console.log("\n" + "\u2500".repeat(60));
19479
21092
  console.log("Multi-Project Scan Summary");
@@ -19523,6 +21136,37 @@ function printHelp() {
19523
21136
  verimu help Show this help
19524
21137
  verimu version Show version
19525
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
+
19526
21170
  Options:
19527
21171
  --path, -p <dir> Project directory to scan (default: .)
19528
21172
  --output, -o <file> CycloneDX output path (SPDX/SWID are written alongside it)
@@ -19544,6 +21188,8 @@ function printHelp() {
19544
21188
  Environment:
19545
21189
  VERIMU_API_KEY API key for Verimu platform (from app.verimu.com)
19546
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)
19547
21193
 
19548
21194
  Examples:
19549
21195
  npx verimu # Scan all projects recursively
@@ -19556,6 +21202,18 @@ function printHelp() {
19556
21202
  npx verimu scan --no-recursive # Scan only root directory
19557
21203
  npx verimu scan --exclude "legacy/*" # Exclude legacy projects
19558
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
+
19559
21217
  Supported ecosystems:
19560
21218
  npm (package-lock.json) pip (requirements.txt)
19561
21219
  Maven (pom.xml) NuGet (packages.lock.json)