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/index.mjs CHANGED
@@ -15717,8 +15717,8 @@ var PnpmScanner = class {
15717
15717
  * "/pkg@1.0.0(dep@2.0.0)" → name: "pkg", version: "1.0.0"
15718
15718
  */
15719
15719
  parsePackagePath(pkgPath, lockfileVersion) {
15720
- const path14 = pkgPath.startsWith("/") ? pkgPath.slice(1) : pkgPath;
15721
- const cleanPath = path14.split("_")[0].split("(")[0];
15720
+ const path15 = pkgPath.startsWith("/") ? pkgPath.slice(1) : pkgPath;
15721
+ const cleanPath = path15.split("_")[0].split("(")[0];
15722
15722
  if (!cleanPath) {
15723
15723
  return { name: null, version: null };
15724
15724
  }
@@ -15730,30 +15730,30 @@ var PnpmScanner = class {
15730
15730
  /**
15731
15731
  * Parses v6+ format: "express@4.18.2" or "@types/node@20.11.5"
15732
15732
  */
15733
- parseV6Format(path14) {
15734
- if (path14.startsWith("@")) {
15735
- const lastAtIndex = path14.lastIndexOf("@");
15733
+ parseV6Format(path15) {
15734
+ if (path15.startsWith("@")) {
15735
+ const lastAtIndex = path15.lastIndexOf("@");
15736
15736
  if (lastAtIndex <= 0) {
15737
15737
  return { name: null, version: null };
15738
15738
  }
15739
- const name2 = path14.substring(0, lastAtIndex);
15740
- const version2 = path14.substring(lastAtIndex + 1);
15739
+ const name2 = path15.substring(0, lastAtIndex);
15740
+ const version2 = path15.substring(lastAtIndex + 1);
15741
15741
  return { name: name2, version: version2 };
15742
15742
  }
15743
- const atIndex = path14.indexOf("@");
15743
+ const atIndex = path15.indexOf("@");
15744
15744
  if (atIndex < 0) {
15745
15745
  return { name: null, version: null };
15746
15746
  }
15747
- const name = path14.substring(0, atIndex);
15748
- const version = path14.substring(atIndex + 1);
15747
+ const name = path15.substring(0, atIndex);
15748
+ const version = path15.substring(atIndex + 1);
15749
15749
  return { name, version };
15750
15750
  }
15751
15751
  /**
15752
15752
  * Parses v5.x format: "express/4.18.2" or "@types/node/20.11.5"
15753
15753
  */
15754
- parseV5Format(path14) {
15755
- if (path14.startsWith("@")) {
15756
- const parts = path14.split("/");
15754
+ parseV5Format(path15) {
15755
+ if (path15.startsWith("@")) {
15756
+ const parts = path15.split("/");
15757
15757
  if (parts.length < 3) {
15758
15758
  return { name: null, version: null };
15759
15759
  }
@@ -15761,12 +15761,12 @@ var PnpmScanner = class {
15761
15761
  const version2 = parts[2];
15762
15762
  return { name: name2, version: version2 };
15763
15763
  }
15764
- const slashIndex = path14.indexOf("/");
15764
+ const slashIndex = path15.indexOf("/");
15765
15765
  if (slashIndex < 0) {
15766
15766
  return { name: null, version: null };
15767
15767
  }
15768
- const name = path14.substring(0, slashIndex);
15769
- const version = path14.substring(slashIndex + 1);
15768
+ const name = path15.substring(0, slashIndex);
15769
+ const version = path15.substring(slashIndex + 1);
15770
15770
  return { name, version };
15771
15771
  }
15772
15772
  /**
@@ -17334,34 +17334,34 @@ var JsAstAnalyzer = class {
17334
17334
  matchCandidates.push({ packageKey: packageKey3, line, matchKind, calledSymbol, confidence });
17335
17335
  };
17336
17336
  traverseFn(ast, {
17337
- ImportDeclaration: (path14) => {
17338
- const source = path14.node.source;
17337
+ ImportDeclaration: (path15) => {
17338
+ const source = path15.node.source;
17339
17339
  if (!(0, import_types.isStringLiteral)(source)) return;
17340
17340
  const pkgKey = findPackageKey(resolveImportTarget(source.value), packageMap.byName);
17341
17341
  if (!pkgKey) return;
17342
- addMatch(pkgKey, path14.node.loc?.start.line ?? 1, "import", void 0, 0.95);
17343
- for (const specifier of path14.node.specifiers) {
17342
+ addMatch(pkgKey, path15.node.loc?.start.line ?? 1, "import", void 0, 0.95);
17343
+ for (const specifier of path15.node.specifiers) {
17344
17344
  if ((0, import_types.isImportDefaultSpecifier)(specifier) || (0, import_types.isImportNamespaceSpecifier)(specifier) || (0, import_types.isImportSpecifier)(specifier)) {
17345
17345
  symbolToPackage.set(specifier.local.name, pkgKey);
17346
17346
  }
17347
17347
  }
17348
17348
  },
17349
- ExportNamedDeclaration: (path14) => {
17350
- const source = path14.node.source;
17349
+ ExportNamedDeclaration: (path15) => {
17350
+ const source = path15.node.source;
17351
17351
  if (!source || !(0, import_types.isStringLiteral)(source)) return;
17352
17352
  const pkgKey = findPackageKey(resolveImportTarget(source.value), packageMap.byName);
17353
17353
  if (!pkgKey) return;
17354
- addMatch(pkgKey, path14.node.loc?.start.line ?? 1, "export_from", void 0, 0.85);
17354
+ addMatch(pkgKey, path15.node.loc?.start.line ?? 1, "export_from", void 0, 0.85);
17355
17355
  },
17356
- ExportAllDeclaration: (path14) => {
17357
- const source = path14.node.source;
17356
+ ExportAllDeclaration: (path15) => {
17357
+ const source = path15.node.source;
17358
17358
  if (!(0, import_types.isStringLiteral)(source)) return;
17359
17359
  const pkgKey = findPackageKey(resolveImportTarget(source.value), packageMap.byName);
17360
17360
  if (!pkgKey) return;
17361
- addMatch(pkgKey, path14.node.loc?.start.line ?? 1, "export_from", void 0, 0.85);
17361
+ addMatch(pkgKey, path15.node.loc?.start.line ?? 1, "export_from", void 0, 0.85);
17362
17362
  },
17363
- VariableDeclarator: (path14) => {
17364
- const node = path14.node;
17363
+ VariableDeclarator: (path15) => {
17364
+ const node = path15.node;
17365
17365
  if (!(0, import_types.isVariableDeclarator)(node)) return;
17366
17366
  if (!node.init || !(0, import_types.isCallExpression)(node.init)) return;
17367
17367
  if (!(0, import_types.isIdentifier)(node.init.callee, { name: "require" })) return;
@@ -17374,8 +17374,8 @@ var JsAstAnalyzer = class {
17374
17374
  symbolToPackage.set(identifier, pkgKey);
17375
17375
  }
17376
17376
  },
17377
- CallExpression: (path14) => {
17378
- const node = path14.node;
17377
+ CallExpression: (path15) => {
17378
+ const node = path15.node;
17379
17379
  if ((0, import_types.isIdentifier)(node.callee, { name: "require" })) {
17380
17380
  const firstArg = node.arguments[0];
17381
17381
  if (firstArg && (0, import_types.isStringLiteral)(firstArg)) {
@@ -17396,12 +17396,12 @@ var JsAstAnalyzer = class {
17396
17396
  0.75
17397
17397
  );
17398
17398
  },
17399
- ImportExpression: (path14) => {
17400
- const source = path14.node.source;
17399
+ ImportExpression: (path15) => {
17400
+ const source = path15.node.source;
17401
17401
  if (!(0, import_types.isStringLiteral)(source)) return;
17402
17402
  const pkgKey = findPackageKey(resolveImportTarget(source.value), packageMap.byName);
17403
17403
  if (!pkgKey) return;
17404
- addMatch(pkgKey, path14.node.loc?.start.line ?? 1, "dynamic_import", void 0, 0.9);
17404
+ addMatch(pkgKey, path15.node.loc?.start.line ?? 1, "dynamic_import", void 0, 0.9);
17405
17405
  }
17406
17406
  });
17407
17407
  for (const candidate of matchCandidates) {
@@ -18946,10 +18946,12 @@ async function uploadToVerimu(report, config) {
18946
18946
  throw new Error("API key required for upload");
18947
18947
  }
18948
18948
  const client = new VerimuApiClient(config.apiKey, config.apiBaseUrl);
18949
- const projectName = basename(config.projectPath);
18949
+ const projectName = config.uploadProjectName ?? basename(config.projectPath);
18950
18950
  const upsertRes = await client.upsertProject({
18951
18951
  name: projectName,
18952
18952
  ecosystem: report.project.ecosystem,
18953
+ repositoryUrl: config.repositoryUrl,
18954
+ platform: config.platform,
18953
18955
  groupName: config.groupName
18954
18956
  });
18955
18957
  const projectId = upsertRes.project.id;
@@ -19014,6 +19016,1611 @@ function sanitizeUsageContextForUpload(usageContext) {
19014
19016
  const { artifactPath: _artifactPath, ...rest } = usageContext;
19015
19017
  return rest;
19016
19018
  }
19019
+
19020
+ // src/gitlab/client.ts
19021
+ import { execSync as execSync2 } from "child_process";
19022
+ import { mkdtempSync, rmSync, existsSync as existsSync14 } from "fs";
19023
+ import { join as join4 } from "path";
19024
+ import { tmpdir } from "os";
19025
+ var GitLabClient = class {
19026
+ baseUrl;
19027
+ token;
19028
+ apiUrl;
19029
+ constructor(baseUrl, token) {
19030
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
19031
+ this.token = token;
19032
+ this.apiUrl = `${this.baseUrl}/api/v4`;
19033
+ }
19034
+ // ─── Project Listing ────────────────────────────────────────
19035
+ /**
19036
+ * Lists all accessible projects, paginated.
19037
+ * Returns all pages concatenated.
19038
+ */
19039
+ async listAllProjects(options) {
19040
+ const perPage = options?.perPage ?? 100;
19041
+ const maxPages = options?.maxPages ?? 100;
19042
+ const allProjects = [];
19043
+ let page = 1;
19044
+ while (page <= maxPages) {
19045
+ const params = new URLSearchParams({
19046
+ per_page: String(perPage),
19047
+ page: String(page),
19048
+ order_by: "last_activity_at",
19049
+ sort: "desc",
19050
+ simple: "false"
19051
+ });
19052
+ if (options?.archived !== void 0) {
19053
+ params.set("archived", String(options.archived));
19054
+ }
19055
+ const url = `${this.apiUrl}/projects?${params.toString()}`;
19056
+ const projects = await this.fetch(url);
19057
+ if (projects.length === 0) break;
19058
+ allProjects.push(...projects);
19059
+ page++;
19060
+ if (projects.length < perPage) break;
19061
+ }
19062
+ return allProjects;
19063
+ }
19064
+ /**
19065
+ * Lists projects within a specific group (and its subgroups).
19066
+ */
19067
+ async listGroupProjects(groupPath, options) {
19068
+ const perPage = options?.perPage ?? 100;
19069
+ const includeSubgroups = options?.includeSubgroups ?? true;
19070
+ const allProjects = [];
19071
+ let page = 1;
19072
+ while (true) {
19073
+ const params = new URLSearchParams({
19074
+ per_page: String(perPage),
19075
+ page: String(page),
19076
+ include_subgroups: String(includeSubgroups),
19077
+ order_by: "last_activity_at",
19078
+ sort: "desc"
19079
+ });
19080
+ const encoded = encodeURIComponent(groupPath);
19081
+ const url = `${this.apiUrl}/groups/${encoded}/projects?${params.toString()}`;
19082
+ const projects = await this.fetch(url);
19083
+ if (projects.length === 0) break;
19084
+ allProjects.push(...projects);
19085
+ page++;
19086
+ if (projects.length < perPage) break;
19087
+ }
19088
+ return allProjects;
19089
+ }
19090
+ /**
19091
+ * Lists all groups accessible to the token.
19092
+ */
19093
+ async listGroups() {
19094
+ const allGroups = [];
19095
+ let page = 1;
19096
+ while (true) {
19097
+ const params = new URLSearchParams({
19098
+ per_page: "100",
19099
+ page: String(page)
19100
+ });
19101
+ const url = `${this.apiUrl}/groups?${params.toString()}`;
19102
+ const groups = await this.fetch(url);
19103
+ if (groups.length === 0) break;
19104
+ allGroups.push(...groups);
19105
+ page++;
19106
+ if (groups.length < 100) break;
19107
+ }
19108
+ return allGroups;
19109
+ }
19110
+ // ─── Cloning ────────────────────────────────────────────────
19111
+ /**
19112
+ * Shallow-clones a repo into a temporary directory.
19113
+ * Returns the path to the cloned repo.
19114
+ *
19115
+ * Uses HTTPS with token auth embedded in the URL
19116
+ * (works for self-hosted GitLab with private-token).
19117
+ */
19118
+ cloneToTemp(project, branch) {
19119
+ const tempDir = mkdtempSync(join4(tmpdir(), `verimu-gl-${project.id}-`));
19120
+ const cloneUrl = this.buildAuthUrl(project.http_url_to_repo);
19121
+ const targetBranch = branch ?? project.default_branch;
19122
+ try {
19123
+ execSync2(
19124
+ `git clone --depth 1 --branch "${targetBranch}" --single-branch "${cloneUrl}" "${tempDir}"`,
19125
+ {
19126
+ stdio: "pipe",
19127
+ timeout: 12e4,
19128
+ // 2 minute timeout per clone
19129
+ env: {
19130
+ ...process.env,
19131
+ GIT_TERMINAL_PROMPT: "0"
19132
+ // Never prompt for auth
19133
+ }
19134
+ }
19135
+ );
19136
+ } catch (err) {
19137
+ this.cleanupTemp(tempDir);
19138
+ const msg = err instanceof Error ? err.message : String(err);
19139
+ throw new Error(`Clone failed for ${project.path_with_namespace}: ${msg}`);
19140
+ }
19141
+ return tempDir;
19142
+ }
19143
+ /**
19144
+ * Removes a temporary clone directory.
19145
+ */
19146
+ cleanupTemp(tempDir) {
19147
+ if (existsSync14(tempDir)) {
19148
+ rmSync(tempDir, { recursive: true, force: true });
19149
+ }
19150
+ }
19151
+ // ─── Helpers ────────────────────────────────────────────────
19152
+ /**
19153
+ * Builds an authenticated HTTPS URL for git clone.
19154
+ * Embeds the token as oauth2 password.
19155
+ */
19156
+ buildAuthUrl(httpUrl) {
19157
+ const url = new URL(httpUrl);
19158
+ url.username = "oauth2";
19159
+ url.password = this.token;
19160
+ return url.toString();
19161
+ }
19162
+ /**
19163
+ * Makes an authenticated GET request to the GitLab API.
19164
+ */
19165
+ async fetch(url) {
19166
+ const response = await globalThis.fetch(url, {
19167
+ headers: {
19168
+ "PRIVATE-TOKEN": this.token,
19169
+ "Accept": "application/json"
19170
+ }
19171
+ });
19172
+ if (!response.ok) {
19173
+ const body = await response.text().catch(() => "no body");
19174
+ throw new Error(
19175
+ `GitLab API error: ${response.status} ${response.statusText} \u2014 ${url}
19176
+ ${body}`
19177
+ );
19178
+ }
19179
+ return response.json();
19180
+ }
19181
+ };
19182
+
19183
+ // src/discovery/lockfile-discovery.ts
19184
+ import { readdir as readdir3, stat } from "fs/promises";
19185
+ import { existsSync as existsSync15 } from "fs";
19186
+ import path14 from "path";
19187
+ var LOCKFILE_MAP = {
19188
+ "pnpm-lock.yaml": { ecosystem: "npm", scanner: "pnpm" },
19189
+ "yarn.lock": { ecosystem: "npm", scanner: "yarn" },
19190
+ "package-lock.json": { ecosystem: "npm", scanner: "npm" },
19191
+ "deno.lock": { ecosystem: "npm", scanner: "deno" },
19192
+ "Cargo.lock": { ecosystem: "cargo", scanner: "cargo" },
19193
+ "go.sum": { ecosystem: "go", scanner: "go" },
19194
+ "Gemfile.lock": { ecosystem: "ruby", scanner: "ruby" },
19195
+ "composer.lock": { ecosystem: "composer", scanner: "composer" },
19196
+ "packages.lock.json": { ecosystem: "nuget", scanner: "nuget" },
19197
+ "poetry.lock": { ecosystem: "poetry", scanner: "poetry" },
19198
+ "uv.lock": { ecosystem: "uv", scanner: "uv" },
19199
+ "Pipfile.lock": { ecosystem: "pip", scanner: "pip" },
19200
+ "requirements.txt": { ecosystem: "pip", scanner: "pip" },
19201
+ "pom.xml": { ecosystem: "maven", scanner: "maven" }
19202
+ };
19203
+ var DEFAULT_EXCLUDES = [
19204
+ "node_modules",
19205
+ ".git",
19206
+ ".hg",
19207
+ ".svn",
19208
+ "vendor",
19209
+ "target",
19210
+ "dist",
19211
+ "build",
19212
+ ".next",
19213
+ ".nuxt",
19214
+ "__pycache__",
19215
+ ".venv",
19216
+ "venv",
19217
+ ".tox",
19218
+ "coverage",
19219
+ ".cache",
19220
+ "out",
19221
+ ".output"
19222
+ ];
19223
+ var LockfileDiscovery = class {
19224
+ lockfileNames = Object.keys(LOCKFILE_MAP);
19225
+ /**
19226
+ * Discovers all lockfiles recursively starting from rootPath.
19227
+ * Returns a list of projects that can be scanned.
19228
+ */
19229
+ async discover(options) {
19230
+ const { rootPath, exclude, maxDepth } = options;
19231
+ const absoluteRoot = path14.resolve(rootPath);
19232
+ const discovered = [];
19233
+ const excludePatterns = this.buildExcludePatterns(exclude);
19234
+ await this.walkDirectory(absoluteRoot, absoluteRoot, discovered, {
19235
+ excludePatterns,
19236
+ maxDepth: maxDepth ?? Infinity,
19237
+ //not set, infinite for now, can add as a cli-flag later if needed
19238
+ currentDepth: 0
19239
+ });
19240
+ discovered.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
19241
+ return discovered;
19242
+ }
19243
+ /**
19244
+ * Recursively walks directories looking for lockfiles.
19245
+ * Stops descending into a directory once a lockfile is found
19246
+ * (to avoid scanning nested node_modules, etc.)
19247
+ */
19248
+ async walkDirectory(currentPath, rootPath, results, options) {
19249
+ const { excludePatterns, maxDepth, currentDepth } = options;
19250
+ if (currentDepth > maxDepth) return;
19251
+ const relativePath = path14.relative(rootPath, currentPath) || ".";
19252
+ if (this.matchesAnyPattern(relativePath, excludePatterns)) {
19253
+ return;
19254
+ }
19255
+ const foundLockfile = await this.findLockfileInDir(currentPath);
19256
+ if (foundLockfile) {
19257
+ results.push({
19258
+ projectPath: currentPath,
19259
+ relativePath,
19260
+ lockfile: {
19261
+ name: foundLockfile.name,
19262
+ path: path14.join(currentPath, foundLockfile.name)
19263
+ },
19264
+ ecosystem: foundLockfile.ecosystem,
19265
+ scannerType: foundLockfile.scanner
19266
+ });
19267
+ return;
19268
+ }
19269
+ let entries;
19270
+ try {
19271
+ entries = await readdir3(currentPath);
19272
+ } catch {
19273
+ return;
19274
+ }
19275
+ for (const entry of entries) {
19276
+ const entryPath = path14.join(currentPath, entry);
19277
+ try {
19278
+ const stats = await stat(entryPath);
19279
+ if (stats.isDirectory()) {
19280
+ if (this.isDefaultExclude(entry)) continue;
19281
+ await this.walkDirectory(entryPath, rootPath, results, {
19282
+ ...options,
19283
+ currentDepth: currentDepth + 1
19284
+ });
19285
+ }
19286
+ } catch {
19287
+ }
19288
+ }
19289
+ }
19290
+ /**
19291
+ * Looks for a lockfile in the given directory.
19292
+ * Returns the first match in priority order.
19293
+ */
19294
+ async findLockfileInDir(dirPath) {
19295
+ const priorityOrder = [
19296
+ "pnpm-lock.yaml",
19297
+ "yarn.lock",
19298
+ "package-lock.json",
19299
+ "deno.lock",
19300
+ "Cargo.lock",
19301
+ "go.sum",
19302
+ "poetry.lock",
19303
+ "uv.lock",
19304
+ "Pipfile.lock",
19305
+ "composer.lock",
19306
+ "Gemfile.lock",
19307
+ "packages.lock.json",
19308
+ "pom.xml",
19309
+ "requirements.txt"
19310
+ ];
19311
+ for (const lockfileName of priorityOrder) {
19312
+ const lockfilePath = path14.join(dirPath, lockfileName);
19313
+ if (existsSync15(lockfilePath)) {
19314
+ const info = LOCKFILE_MAP[lockfileName];
19315
+ return { name: lockfileName, ...info };
19316
+ }
19317
+ }
19318
+ return null;
19319
+ }
19320
+ /**
19321
+ * Builds exclude patterns from user input + defaults.
19322
+ */
19323
+ buildExcludePatterns(userExcludes) {
19324
+ const patterns = [];
19325
+ for (const dir of DEFAULT_EXCLUDES) {
19326
+ patterns.push(`**/${dir}`);
19327
+ patterns.push(`**/${dir}/**`);
19328
+ }
19329
+ if (userExcludes) {
19330
+ patterns.push(...userExcludes);
19331
+ }
19332
+ return patterns;
19333
+ }
19334
+ /**
19335
+ * Quick check if a directory name is in the default exclude list.
19336
+ */
19337
+ isDefaultExclude(dirName) {
19338
+ return DEFAULT_EXCLUDES.includes(dirName);
19339
+ }
19340
+ /**
19341
+ * Checks if a path matches any of the given glob patterns.
19342
+ * Uses simple glob matching (supports *, **, ?).
19343
+ */
19344
+ matchesAnyPattern(relativePath, patterns) {
19345
+ const normalized = relativePath.replace(/\\/g, "/");
19346
+ for (const pattern of patterns) {
19347
+ if (this.matchGlob(normalized, pattern)) {
19348
+ return true;
19349
+ }
19350
+ }
19351
+ return false;
19352
+ }
19353
+ /**
19354
+ * Simple glob matcher supporting:
19355
+ * - * (matches any characters except /)
19356
+ * - ** (matches any characters including /)
19357
+ * - ? (matches single character)
19358
+ */
19359
+ matchGlob(str, pattern) {
19360
+ let regex = pattern.replace(/\\/g, "/").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/{{GLOBSTAR}}/g, ".*").replace(/\?/g, ".");
19361
+ regex = `^${regex}$`;
19362
+ return new RegExp(regex).test(str);
19363
+ }
19364
+ };
19365
+
19366
+ // src/gitlab/orchestrator.ts
19367
+ var GitLabOrchestrator = class {
19368
+ discovery = new LockfileDiscovery();
19369
+ /**
19370
+ * Scans all accessible repos on a GitLab instance.
19371
+ */
19372
+ async scanInstance(config) {
19373
+ const startTime = Date.now();
19374
+ const client = new GitLabClient(config.url, config.token);
19375
+ console.log(`
19376
+ Connecting to ${config.url}...`);
19377
+ let projects;
19378
+ if (config.groups && config.groups.length > 0) {
19379
+ const grouped = [];
19380
+ for (const group of config.groups) {
19381
+ console.log(` Listing projects in group: ${group}`);
19382
+ const groupProjects = await client.listGroupProjects(group);
19383
+ grouped.push(...groupProjects);
19384
+ }
19385
+ const seen = /* @__PURE__ */ new Set();
19386
+ projects = grouped.filter((p) => {
19387
+ if (seen.has(p.id)) return false;
19388
+ seen.add(p.id);
19389
+ return true;
19390
+ });
19391
+ } else {
19392
+ console.log(" Listing all accessible projects...");
19393
+ projects = await client.listAllProjects({
19394
+ archived: config.excludeArchived !== false ? false : void 0
19395
+ });
19396
+ }
19397
+ console.log(` Found ${projects.length} projects
19398
+ `);
19399
+ const { toScan, skipped } = this.filterProjects(projects, config);
19400
+ console.log(` Scanning ${toScan.length} repos (${skipped.length} skipped)
19401
+ `);
19402
+ const scannedRepos = [];
19403
+ const failedRepos = [];
19404
+ for (let i = 0; i < toScan.length; i++) {
19405
+ const project = toScan[i];
19406
+ const label = `[${i + 1}/${toScan.length}]`;
19407
+ console.log(` ${label} ${project.path_with_namespace}`);
19408
+ const repoStart = Date.now();
19409
+ let tempDir = null;
19410
+ try {
19411
+ process.stdout.write(" Cloning... ");
19412
+ tempDir = client.cloneToTemp(project, config.branch);
19413
+ console.log("done");
19414
+ const discovered = await this.discovery.discover({
19415
+ rootPath: tempDir
19416
+ });
19417
+ if (discovered.length === 0) {
19418
+ console.log(" no lockfile found");
19419
+ scannedRepos.push({
19420
+ project,
19421
+ reports: [],
19422
+ hasLockfile: false,
19423
+ durationMs: Date.now() - repoStart
19424
+ });
19425
+ continue;
19426
+ }
19427
+ console.log(` Found ${discovered.length} project(s)`);
19428
+ const reports = [];
19429
+ for (const disc of discovered) {
19430
+ const subLabel = discovered.length > 1 ? ` (${disc.relativePath})` : "";
19431
+ const uploadProjectName = discovered.length > 1 && disc.relativePath !== "." ? `${project.path_with_namespace}/${disc.relativePath}` : project.path_with_namespace;
19432
+ const safeArtifactSuffix = uploadProjectName.replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, "-").toLowerCase();
19433
+ const sbomOutput = `${tempDir}/sbom.${safeArtifactSuffix}.cdx.json`;
19434
+ process.stdout.write(` Scanning${subLabel}... `);
19435
+ try {
19436
+ const report = await scan({
19437
+ projectPath: disc.projectPath,
19438
+ sbomOutput,
19439
+ skipCveCheck: config.skipCveCheck ?? false,
19440
+ apiKey: config.apiKey,
19441
+ apiBaseUrl: config.apiBaseUrl,
19442
+ groupName: config.groupName,
19443
+ uploadProjectName,
19444
+ repositoryUrl: project.web_url,
19445
+ platform: "gitlab"
19446
+ });
19447
+ const vulnCount = report.summary.totalVulnerabilities;
19448
+ const depCount = report.summary.totalDependencies;
19449
+ if (vulnCount > 0) {
19450
+ console.log(
19451
+ `${depCount} deps, ${vulnCount} vulns (C:${report.summary.critical} H:${report.summary.high} M:${report.summary.medium} L:${report.summary.low})`
19452
+ );
19453
+ } else {
19454
+ console.log(`${depCount} deps, clean`);
19455
+ }
19456
+ reports.push(report);
19457
+ } catch (scanErr) {
19458
+ const msg = scanErr instanceof Error ? scanErr.message : String(scanErr);
19459
+ console.log(`FAILED: ${msg.slice(0, 80)}`);
19460
+ }
19461
+ }
19462
+ const durationMs = Date.now() - repoStart;
19463
+ scannedRepos.push({
19464
+ project,
19465
+ reports,
19466
+ hasLockfile: true,
19467
+ durationMs
19468
+ });
19469
+ } catch (err) {
19470
+ const msg = err instanceof Error ? err.message : String(err);
19471
+ const durationMs = Date.now() - repoStart;
19472
+ if (msg.includes("No supported lockfile") || msg.includes("NoLockfileError")) {
19473
+ console.log(" no lockfile found");
19474
+ scannedRepos.push({
19475
+ project,
19476
+ reports: [],
19477
+ hasLockfile: false,
19478
+ durationMs
19479
+ });
19480
+ } else {
19481
+ console.log(` FAILED: ${msg.slice(0, 100)}`);
19482
+ failedRepos.push({ project, error: msg });
19483
+ }
19484
+ } finally {
19485
+ if (tempDir) {
19486
+ client.cleanupTemp(tempDir);
19487
+ }
19488
+ }
19489
+ console.log("");
19490
+ }
19491
+ const result = this.aggregate(
19492
+ config.url,
19493
+ projects.length,
19494
+ scannedRepos,
19495
+ skipped,
19496
+ failedRepos,
19497
+ Date.now() - startTime
19498
+ );
19499
+ this.printSummary(result);
19500
+ return result;
19501
+ }
19502
+ // ─── Filtering ──────────────────────────────────────────────
19503
+ filterProjects(projects, config) {
19504
+ const toScan = [];
19505
+ const skipped = [];
19506
+ for (const project of projects) {
19507
+ if (config.excludeArchived !== false && project.archived) {
19508
+ skipped.push({ project, reason: "archived" });
19509
+ continue;
19510
+ }
19511
+ if (config.excludeEmpty !== false && project.empty_repo) {
19512
+ skipped.push({ project, reason: "empty repository" });
19513
+ continue;
19514
+ }
19515
+ if (config.excludePatterns?.length) {
19516
+ const matched = config.excludePatterns.some(
19517
+ (pattern) => this.matchGlob(project.path_with_namespace, pattern)
19518
+ );
19519
+ if (matched) {
19520
+ skipped.push({ project, reason: "matched exclude pattern" });
19521
+ continue;
19522
+ }
19523
+ }
19524
+ toScan.push(project);
19525
+ }
19526
+ if (config.maxRepos && toScan.length > config.maxRepos) {
19527
+ const trimmed = toScan.splice(config.maxRepos);
19528
+ for (const p of trimmed) {
19529
+ skipped.push({ project: p, reason: "exceeded --max-repos limit" });
19530
+ }
19531
+ }
19532
+ return { toScan, skipped };
19533
+ }
19534
+ matchGlob(str, pattern) {
19535
+ const regex = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/{{GLOBSTAR}}/g, ".*").replace(/\?/g, ".");
19536
+ return new RegExp(`^${regex}$`).test(str);
19537
+ }
19538
+ // ─── Aggregation ────────────────────────────────────────────
19539
+ aggregate(instanceUrl, totalDiscovered, scannedRepos, skippedRepos, failedRepos, durationMs) {
19540
+ const reposWithData = scannedRepos.filter((r) => r.reports.length > 0);
19541
+ const ecosystemBreakdown = {};
19542
+ let totalDeps = 0;
19543
+ let totalVulns = 0;
19544
+ let critical = 0;
19545
+ let high = 0;
19546
+ let medium = 0;
19547
+ let low = 0;
19548
+ let exploitedInWild = 0;
19549
+ let reposWithVulns = 0;
19550
+ for (const { reports } of reposWithData) {
19551
+ let repoHasVulns = false;
19552
+ for (const report of reports) {
19553
+ totalDeps += report.summary.totalDependencies;
19554
+ totalVulns += report.summary.totalVulnerabilities;
19555
+ critical += report.summary.critical;
19556
+ high += report.summary.high;
19557
+ medium += report.summary.medium;
19558
+ low += report.summary.low;
19559
+ exploitedInWild += report.summary.exploitedInWild;
19560
+ if (report.summary.totalVulnerabilities > 0) {
19561
+ repoHasVulns = true;
19562
+ }
19563
+ const eco = report.project.ecosystem;
19564
+ ecosystemBreakdown[eco] = (ecosystemBreakdown[eco] ?? 0) + 1;
19565
+ }
19566
+ if (repoHasVulns) reposWithVulns++;
19567
+ }
19568
+ const vulnMap = /* @__PURE__ */ new Map();
19569
+ for (const { project, reports } of reposWithData) {
19570
+ for (const report of reports) {
19571
+ for (const vuln of report.cveCheck.vulnerabilities) {
19572
+ const existing = vulnMap.get(vuln.id);
19573
+ if (existing) {
19574
+ if (!existing.affectedRepos.includes(project.path_with_namespace)) {
19575
+ existing.affectedRepos.push(project.path_with_namespace);
19576
+ }
19577
+ } else {
19578
+ vulnMap.set(vuln.id, {
19579
+ id: vuln.id,
19580
+ severity: vuln.severity,
19581
+ summary: vuln.summary,
19582
+ affectedRepos: [project.path_with_namespace],
19583
+ fixedVersion: vuln.fixedVersion,
19584
+ exploitedInWild: vuln.exploitedInWild
19585
+ });
19586
+ }
19587
+ }
19588
+ }
19589
+ }
19590
+ const severityOrder2 = {
19591
+ CRITICAL: 0,
19592
+ HIGH: 1,
19593
+ MEDIUM: 2,
19594
+ LOW: 3,
19595
+ UNKNOWN: 4
19596
+ };
19597
+ const topVulnerabilities = Array.from(vulnMap.values()).sort((a, b) => {
19598
+ const sevDiff = severityOrder2[a.severity] - severityOrder2[b.severity];
19599
+ if (sevDiff !== 0) return sevDiff;
19600
+ return b.affectedRepos.length - a.affectedRepos.length;
19601
+ });
19602
+ return {
19603
+ instanceUrl,
19604
+ totalReposDiscovered: totalDiscovered,
19605
+ scannedRepos,
19606
+ skippedRepos,
19607
+ failedRepos,
19608
+ summary: {
19609
+ totalRepos: reposWithData.length,
19610
+ reposWithVulnerabilities: reposWithVulns,
19611
+ totalDependencies: totalDeps,
19612
+ totalVulnerabilities: totalVulns,
19613
+ critical,
19614
+ high,
19615
+ medium,
19616
+ low,
19617
+ exploitedInWild,
19618
+ ecosystemBreakdown
19619
+ },
19620
+ topVulnerabilities,
19621
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
19622
+ durationMs
19623
+ };
19624
+ }
19625
+ // ─── Summary Printing ───────────────────────────────────────
19626
+ printSummary(result) {
19627
+ console.log("\n" + "\u2550".repeat(60));
19628
+ console.log(" VERIMU GITLAB INSTANCE SCAN \u2014 COMPLETE");
19629
+ console.log("\u2550".repeat(60));
19630
+ console.log(`
19631
+ Instance: ${result.instanceUrl}`);
19632
+ console.log(` Repos found: ${result.totalReposDiscovered}`);
19633
+ console.log(` Repos scanned: ${result.summary.totalRepos}`);
19634
+ console.log(` Repos with vulns: ${result.summary.reposWithVulnerabilities}`);
19635
+ console.log(` Skipped: ${result.skippedRepos.length}`);
19636
+ console.log(` Failed: ${result.failedRepos.length}`);
19637
+ console.log("");
19638
+ console.log(` Total dependencies: ${result.summary.totalDependencies}`);
19639
+ console.log(` Total vulnerabilities: ${result.summary.totalVulnerabilities}`);
19640
+ console.log(` Critical: ${result.summary.critical}`);
19641
+ console.log(` High: ${result.summary.high}`);
19642
+ console.log(` Medium: ${result.summary.medium}`);
19643
+ console.log(` Low: ${result.summary.low}`);
19644
+ if (result.summary.exploitedInWild > 0) {
19645
+ console.log(`
19646
+ \u{1F534} ${result.summary.exploitedInWild} actively exploited \u2014 CRA 24h reporting required`);
19647
+ }
19648
+ if (Object.keys(result.summary.ecosystemBreakdown).length > 0) {
19649
+ console.log("\n Ecosystems:");
19650
+ for (const [eco, count] of Object.entries(result.summary.ecosystemBreakdown)) {
19651
+ console.log(` ${eco}: ${count} project(s)`);
19652
+ }
19653
+ }
19654
+ console.log(`
19655
+ Completed in ${(result.durationMs / 1e3).toFixed(1)}s`);
19656
+ console.log("");
19657
+ }
19658
+ };
19659
+
19660
+ // src/reporters/html.ts
19661
+ var HtmlReporter = class {
19662
+ name = "html";
19663
+ generate(result) {
19664
+ return `<!DOCTYPE html>
19665
+ <html lang="en">
19666
+ <head>
19667
+ <meta charset="UTF-8">
19668
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
19669
+ <title>Verimu Security Report \u2014 ${this.escapeHtml(result.instanceUrl)}</title>
19670
+ ${this.styles()}
19671
+ </head>
19672
+ <body>
19673
+ <div class="container">
19674
+ ${this.header(result)}
19675
+ ${this.summaryCards(result)}
19676
+ ${this.severityChart(result)}
19677
+ ${this.topVulnerabilities(result)}
19678
+ ${this.repoBreakdown(result)}
19679
+ ${this.ecosystemBreakdown(result)}
19680
+ ${this.craTimeline(result)}
19681
+ ${this.footer(result)}
19682
+ </div>
19683
+ </body>
19684
+ </html>`;
19685
+ }
19686
+ // ─── Sections ─────────────────────────────────────────────
19687
+ header(result) {
19688
+ const date = new Date(result.scannedAt).toLocaleDateString("en-US", {
19689
+ year: "numeric",
19690
+ month: "long",
19691
+ day: "numeric"
19692
+ });
19693
+ return `
19694
+ <header>
19695
+ <div class="brand">
19696
+ <div class="logo">V</div>
19697
+ <div>
19698
+ <h1>Security posture report</h1>
19699
+ <p class="subtitle">${this.escapeHtml(result.instanceUrl)} \u2014 ${date}</p>
19700
+ </div>
19701
+ </div>
19702
+ <p class="intro">
19703
+ This report summarizes the current vulnerability landscape across
19704
+ <strong>${result.summary.totalRepos} repositories</strong> containing
19705
+ <strong>${result.summary.totalDependencies.toLocaleString()} dependencies</strong>.
19706
+ Generated by <a href="https://verimu.com">Verimu</a> CRA Compliance Scanner.
19707
+ </p>
19708
+ </header>`;
19709
+ }
19710
+ summaryCards(result) {
19711
+ const s = result.summary;
19712
+ const vulnRepoPercent = s.totalRepos > 0 ? Math.round(s.reposWithVulnerabilities / s.totalRepos * 100) : 0;
19713
+ return `
19714
+ <section class="cards">
19715
+ <div class="card">
19716
+ <div class="card-value">${s.totalRepos}</div>
19717
+ <div class="card-label">Repos scanned</div>
19718
+ </div>
19719
+ <div class="card">
19720
+ <div class="card-value">${s.totalDependencies.toLocaleString()}</div>
19721
+ <div class="card-label">Total dependencies</div>
19722
+ </div>
19723
+ <div class="card card-danger">
19724
+ <div class="card-value">${s.totalVulnerabilities}</div>
19725
+ <div class="card-label">Vulnerabilities found</div>
19726
+ </div>
19727
+ <div class="card ${vulnRepoPercent > 50 ? "card-danger" : vulnRepoPercent > 25 ? "card-warn" : ""}">
19728
+ <div class="card-value">${vulnRepoPercent}%</div>
19729
+ <div class="card-label">Repos with vulns</div>
19730
+ </div>
19731
+ </section>`;
19732
+ }
19733
+ severityChart(result) {
19734
+ const s = result.summary;
19735
+ const total = s.totalVulnerabilities || 1;
19736
+ const bars = [
19737
+ { label: "Critical", count: s.critical, cls: "sev-critical" },
19738
+ { label: "High", count: s.high, cls: "sev-high" },
19739
+ { label: "Medium", count: s.medium, cls: "sev-medium" },
19740
+ { label: "Low", count: s.low, cls: "sev-low" }
19741
+ ];
19742
+ const barHtml = bars.map((b) => {
19743
+ const width = Math.max(2, b.count / total * 100);
19744
+ return `
19745
+ <div class="bar-row">
19746
+ <span class="bar-label">${b.label}</span>
19747
+ <div class="bar-track">
19748
+ <div class="bar-fill ${b.cls}" style="width: ${width}%"></div>
19749
+ </div>
19750
+ <span class="bar-count">${b.count}</span>
19751
+ </div>`;
19752
+ }).join("");
19753
+ return `
19754
+ <section>
19755
+ <h2>Severity breakdown</h2>
19756
+ <div class="chart">${barHtml}</div>
19757
+ ${s.exploitedInWild > 0 ? `
19758
+ <div class="alert alert-critical">
19759
+ <strong>\u26A0 ${s.exploitedInWild} vulnerabilit${s.exploitedInWild === 1 ? "y" : "ies"} actively exploited in the wild.</strong>
19760
+ Under the EU Cyber Resilience Act, actively exploited vulnerabilities require
19761
+ notification to ENISA within 24 hours of awareness.
19762
+ </div>` : ""}
19763
+ </section>`;
19764
+ }
19765
+ topVulnerabilities(result) {
19766
+ if (result.topVulnerabilities.length === 0) {
19767
+ return `
19768
+ <section>
19769
+ <h2>Vulnerabilities</h2>
19770
+ <p class="empty">No known vulnerabilities detected. Nice work.</p>
19771
+ </section>`;
19772
+ }
19773
+ const top = result.topVulnerabilities.slice(0, 30);
19774
+ const rows = top.map(
19775
+ (vuln) => `
19776
+ <tr>
19777
+ <td><span class="sev-badge ${this.sevClass(vuln.severity)}">${vuln.severity}</span></td>
19778
+ <td class="vuln-id">${this.escapeHtml(vuln.id)}</td>
19779
+ <td>${this.escapeHtml(vuln.summary.slice(0, 120))}</td>
19780
+ <td>${vuln.fixedVersion ? this.escapeHtml(vuln.fixedVersion) : '<span class="no-fix">none</span>'}</td>
19781
+ <td>${vuln.affectedRepos.length}</td>
19782
+ <td>${vuln.exploitedInWild ? '<span class="exploited">YES</span>' : ""}</td>
19783
+ </tr>`
19784
+ ).join("");
19785
+ return `
19786
+ <section>
19787
+ <h2>Top vulnerabilities</h2>
19788
+ <p class="section-desc">Sorted by severity, then by number of affected repositories.</p>
19789
+ <div class="table-wrap">
19790
+ <table>
19791
+ <thead>
19792
+ <tr>
19793
+ <th>Severity</th>
19794
+ <th>ID</th>
19795
+ <th>Summary</th>
19796
+ <th>Fix</th>
19797
+ <th>Repos</th>
19798
+ <th>Exploited</th>
19799
+ </tr>
19800
+ </thead>
19801
+ <tbody>${rows}</tbody>
19802
+ </table>
19803
+ </div>
19804
+ </section>`;
19805
+ }
19806
+ repoBreakdown(result) {
19807
+ const repos = result.scannedRepos.filter((r) => r.reports.length > 0).sort((a, b) => {
19808
+ const aVulns = a.reports.reduce((s, r) => s + r.summary.totalVulnerabilities, 0);
19809
+ const bVulns = b.reports.reduce((s, r) => s + r.summary.totalVulnerabilities, 0);
19810
+ return bVulns - aVulns;
19811
+ });
19812
+ const rows = repos.map((r) => {
19813
+ 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 });
19814
+ 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>';
19815
+ return `
19816
+ <tr>
19817
+ <td class="repo-name">${this.escapeHtml(r.project.path_with_namespace)}</td>
19818
+ <td>${r.reports.map((rpt) => rpt.project.ecosystem).filter((v, i, a) => a.indexOf(v) === i).join(", ")}</td>
19819
+ <td>${s.totalDependencies}</td>
19820
+ <td>${vulnBadge}</td>
19821
+ <td>${s.critical}</td>
19822
+ <td>${s.high}</td>
19823
+ <td>${s.medium}</td>
19824
+ <td>${s.low}</td>
19825
+ </tr>`;
19826
+ }).join("");
19827
+ return `
19828
+ <section>
19829
+ <h2>Repository breakdown</h2>
19830
+ <p class="section-desc">${repos.length} repositories with dependency lockfiles.</p>
19831
+ <div class="table-wrap">
19832
+ <table>
19833
+ <thead>
19834
+ <tr>
19835
+ <th>Repository</th>
19836
+ <th>Ecosystem</th>
19837
+ <th>Deps</th>
19838
+ <th>Vulns</th>
19839
+ <th>Crit</th>
19840
+ <th>High</th>
19841
+ <th>Med</th>
19842
+ <th>Low</th>
19843
+ </tr>
19844
+ </thead>
19845
+ <tbody>${rows}</tbody>
19846
+ </table>
19847
+ </div>
19848
+ </section>`;
19849
+ }
19850
+ ecosystemBreakdown(result) {
19851
+ const eco = result.summary.ecosystemBreakdown;
19852
+ if (Object.keys(eco).length === 0) return "";
19853
+ const items = Object.entries(eco).sort((a, b) => b[1] - a[1]).map(([name, count]) => `
19854
+ <div class="eco-item">
19855
+ <span class="eco-name">${name}</span>
19856
+ <span class="eco-count">${count} repo${count !== 1 ? "s" : ""}</span>
19857
+ </div>`).join("");
19858
+ return `
19859
+ <section>
19860
+ <h2>Ecosystem distribution</h2>
19861
+ <div class="eco-grid">${items}</div>
19862
+ </section>`;
19863
+ }
19864
+ craTimeline(result) {
19865
+ const s = result.summary;
19866
+ if (s.totalVulnerabilities === 0) return "";
19867
+ return `
19868
+ <section>
19869
+ <h2>CRA compliance implications</h2>
19870
+ <div class="cra-box">
19871
+ <p>The <strong>EU Cyber Resilience Act</strong> (Regulation 2024/2847) establishes
19872
+ mandatory cybersecurity requirements for products with digital elements.
19873
+ Key obligations relevant to this scan:</p>
19874
+ <ul>
19875
+ <li><strong>Article 14(2):</strong> Manufacturers must identify and document
19876
+ vulnerabilities, including in third-party components (your dependencies).</li>
19877
+ <li><strong>Article 14(4):</strong> Actively exploited vulnerabilities must be
19878
+ reported to ENISA within 24 hours of awareness${s.exploitedInWild > 0 ? ` \u2014 <strong class="text-danger">${s.exploitedInWild} found in this scan</strong>` : ""}.</li>
19879
+ <li><strong>Article 14(3):</strong> Security updates must be provided free of charge
19880
+ for the support period.</li>
19881
+ <li><strong>Annex I, Part II(1):</strong> An SBOM documenting all components must
19882
+ be maintained \u2014 Verimu generates these in CycloneDX, SPDX, and SWID formats.</li>
19883
+ </ul>
19884
+ <p class="cra-note">Full enforcement begins <strong>11 December 2027</strong>.
19885
+ Vulnerability reporting obligations apply from <strong>11 September 2026</strong>.</p>
19886
+ </div>
19887
+ </section>`;
19888
+ }
19889
+ footer(result) {
19890
+ const duration = (result.durationMs / 1e3).toFixed(1);
19891
+ return `
19892
+ <footer>
19893
+ <p>Generated by <a href="https://verimu.com">Verimu</a> CRA Compliance Scanner
19894
+ in ${duration}s on ${new Date(result.scannedAt).toISOString()}</p>
19895
+ <p class="footer-cta">
19896
+ Continuous monitoring, SBOM management, and CRA compliance dashboards available at
19897
+ <a href="https://app.verimu.com">app.verimu.com</a>
19898
+ </p>
19899
+ </footer>`;
19900
+ }
19901
+ // ─── Styling ──────────────────────────────────────────────
19902
+ styles() {
19903
+ return `<style>
19904
+ :root {
19905
+ --bg: #ffffff; --bg2: #f7f8fa; --text: #1a1a2e; --text2: #555770;
19906
+ --border: #e2e4ea; --accent: #4f46e5; --accent-light: #eef2ff;
19907
+ --crit: #dc2626; --crit-bg: #fef2f2;
19908
+ --high: #ea580c; --high-bg: #fff7ed;
19909
+ --med: #d97706; --med-bg: #fffbeb;
19910
+ --low: #2563eb; --low-bg: #eff6ff;
19911
+ --green: #16a34a; --green-bg: #f0fdf4;
19912
+ --radius: 8px; --radius-lg: 12px;
19913
+ }
19914
+ @media (prefers-color-scheme: dark) {
19915
+ :root {
19916
+ --bg: #0f0f1a; --bg2: #1a1a2e; --text: #e4e4f0; --text2: #9595ad;
19917
+ --border: #2a2a40; --accent: #818cf8; --accent-light: #1e1b4b;
19918
+ --crit-bg: #2a0a0a; --high-bg: #2a1a0a; --med-bg: #2a2200; --low-bg: #0a1a2e;
19919
+ --green-bg: #0a2a1a;
19920
+ }
19921
+ }
19922
+ * { box-sizing: border-box; margin: 0; padding: 0; }
19923
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
19924
+ background: var(--bg); color: var(--text); line-height: 1.6; }
19925
+ .container { max-width: 960px; margin: 0 auto; padding: 2rem 1.5rem; }
19926
+ a { color: var(--accent); text-decoration: none; }
19927
+ a:hover { text-decoration: underline; }
19928
+
19929
+ /* Header */
19930
+ header { margin-bottom: 2rem; }
19931
+ .brand { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
19932
+ .logo { width: 48px; height: 48px; background: var(--accent); color: #fff;
19933
+ border-radius: var(--radius); display: flex; align-items: center; justify-content: center;
19934
+ font-size: 24px; font-weight: 700; }
19935
+ h1 { font-size: 22px; font-weight: 600; }
19936
+ .subtitle { color: var(--text2); font-size: 14px; }
19937
+ .intro { color: var(--text2); font-size: 15px; max-width: 700px; }
19938
+
19939
+ /* Cards */
19940
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
19941
+ gap: 12px; margin-bottom: 2rem; }
19942
+ .card { background: var(--bg2); border-radius: var(--radius-lg); padding: 1.25rem;
19943
+ border: 1px solid var(--border); }
19944
+ .card-value { font-size: 28px; font-weight: 700; }
19945
+ .card-label { font-size: 13px; color: var(--text2); margin-top: 4px; }
19946
+ .card-danger .card-value { color: var(--crit); }
19947
+ .card-warn .card-value { color: var(--med); }
19948
+
19949
+ /* Sections */
19950
+ section { margin-bottom: 2.5rem; }
19951
+ h2 { font-size: 18px; font-weight: 600; margin-bottom: 0.75rem; }
19952
+ .section-desc { color: var(--text2); font-size: 14px; margin-bottom: 1rem; }
19953
+ .empty { color: var(--green); font-weight: 500; }
19954
+
19955
+ /* Bar chart */
19956
+ .chart { max-width: 500px; }
19957
+ .bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
19958
+ .bar-label { width: 60px; font-size: 13px; color: var(--text2); text-align: right; }
19959
+ .bar-track { flex: 1; height: 24px; background: var(--bg2); border-radius: 4px;
19960
+ overflow: hidden; border: 1px solid var(--border); }
19961
+ .bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
19962
+ .bar-count { width: 36px; font-size: 14px; font-weight: 600; }
19963
+ .sev-critical, .bar-fill.sev-critical { background: var(--crit); color: #fff; }
19964
+ .sev-high, .bar-fill.sev-high { background: var(--high); color: #fff; }
19965
+ .sev-medium, .bar-fill.sev-medium { background: var(--med); color: #fff; }
19966
+ .sev-low, .bar-fill.sev-low { background: var(--low); color: #fff; }
19967
+
19968
+ /* Tables */
19969
+ .table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); }
19970
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
19971
+ th { background: var(--bg2); font-weight: 600; font-size: 12px; text-transform: uppercase;
19972
+ letter-spacing: 0.5px; color: var(--text2); padding: 10px 12px; text-align: left;
19973
+ border-bottom: 1px solid var(--border); }
19974
+ td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
19975
+ tr:last-child td { border-bottom: none; }
19976
+ tr:hover td { background: var(--bg2); }
19977
+ .repo-name { font-weight: 500; font-size: 13px; }
19978
+ .vuln-id { font-family: monospace; font-size: 12px; white-space: nowrap; }
19979
+
19980
+ /* Badges */
19981
+ .sev-badge { display: inline-block; padding: 2px 8px; border-radius: 4px;
19982
+ font-size: 11px; font-weight: 700; text-transform: uppercase; }
19983
+ .clean { color: var(--green); font-size: 12px; font-weight: 500; }
19984
+ .no-fix { color: var(--text2); font-size: 12px; }
19985
+ .exploited { color: var(--crit); font-weight: 700; font-size: 12px; }
19986
+
19987
+ /* Alert */
19988
+ .alert { padding: 1rem 1.25rem; border-radius: var(--radius); margin-top: 1rem; font-size: 14px; }
19989
+ .alert-critical { background: var(--crit-bg); border: 1px solid var(--crit); color: var(--crit); }
19990
+
19991
+ /* Ecosystem grid */
19992
+ .eco-grid { display: flex; flex-wrap: wrap; gap: 8px; }
19993
+ .eco-item { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
19994
+ padding: 8px 16px; display: flex; align-items: center; gap: 8px; }
19995
+ .eco-name { font-weight: 600; font-size: 14px; }
19996
+ .eco-count { color: var(--text2); font-size: 13px; }
19997
+
19998
+ /* CRA */
19999
+ .cra-box { background: var(--accent-light); border: 1px solid var(--accent);
20000
+ border-radius: var(--radius-lg); padding: 1.5rem; font-size: 14px; }
20001
+ .cra-box ul { margin: 0.75rem 0; padding-left: 1.5rem; }
20002
+ .cra-box li { margin-bottom: 0.5rem; }
20003
+ .cra-note { margin-top: 1rem; font-weight: 600; }
20004
+ .text-danger { color: var(--crit); }
20005
+
20006
+ /* Footer */
20007
+ footer { border-top: 1px solid var(--border); padding-top: 1.5rem; margin-top: 2rem;
20008
+ color: var(--text2); font-size: 13px; }
20009
+ .footer-cta { margin-top: 0.5rem; }
20010
+
20011
+ @media print {
20012
+ .container { max-width: 100%; }
20013
+ .card { break-inside: avoid; }
20014
+ }
20015
+ </style>`;
20016
+ }
20017
+ // ─── Helpers ──────────────────────────────────────────────
20018
+ escapeHtml(str) {
20019
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20020
+ }
20021
+ sevClass(severity) {
20022
+ const map = {
20023
+ CRITICAL: "sev-critical",
20024
+ HIGH: "sev-high",
20025
+ MEDIUM: "sev-medium",
20026
+ LOW: "sev-low",
20027
+ UNKNOWN: "sev-low"
20028
+ };
20029
+ return map[severity] ?? "sev-low";
20030
+ }
20031
+ };
20032
+
20033
+ // src/github/client.ts
20034
+ import { execSync as execSync3 } from "child_process";
20035
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, existsSync as existsSync16 } from "fs";
20036
+ import { join as join5 } from "path";
20037
+ import { tmpdir as tmpdir2 } from "os";
20038
+ function parseProfile(input, baseUrl) {
20039
+ const trimmed = input.trim();
20040
+ try {
20041
+ const url = new URL(trimmed);
20042
+ const pathSegments = url.pathname.split("/").filter(Boolean);
20043
+ if (pathSegments.length >= 1) {
20044
+ return { login: pathSegments[0] };
20045
+ }
20046
+ } catch {
20047
+ }
20048
+ if (trimmed.includes("/") && !trimmed.startsWith("http")) {
20049
+ try {
20050
+ const url = new URL(`https://${trimmed}`);
20051
+ const pathSegments = url.pathname.split("/").filter(Boolean);
20052
+ if (pathSegments.length >= 1) {
20053
+ return { login: pathSegments[0] };
20054
+ }
20055
+ } catch {
20056
+ }
20057
+ }
20058
+ if (!trimmed || trimmed.includes(" ")) {
20059
+ throw new Error(`Invalid GitHub profile: "${input}". Provide an org/user handle or URL.`);
20060
+ }
20061
+ return { login: trimmed };
20062
+ }
20063
+ var GitHubClient = class {
20064
+ baseUrl;
20065
+ apiUrl;
20066
+ token;
20067
+ lastRateLimit;
20068
+ constructor(baseUrl, token) {
20069
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
20070
+ this.token = token;
20071
+ if (this.baseUrl === "https://github.com" || this.baseUrl === "http://github.com") {
20072
+ this.apiUrl = "https://api.github.com";
20073
+ } else {
20074
+ this.apiUrl = `${this.baseUrl}/api/v3`;
20075
+ }
20076
+ }
20077
+ // ─── Owner Type Detection ──────────────────────────────────
20078
+ /**
20079
+ * Determines whether a login is a User or Organization by
20080
+ * calling GET /users/{username} and reading the `type` field.
20081
+ */
20082
+ async detectOwnerType(login) {
20083
+ const data = await this.fetch(`${this.apiUrl}/users/${encodeURIComponent(login)}`);
20084
+ if (data.type === "Organization") return "org";
20085
+ return "user";
20086
+ }
20087
+ // ─── Repo Listing ──────────────────────────────────────────
20088
+ /**
20089
+ * Lists repositories for an organization.
20090
+ * Uses GET /orgs/{org}/repos with pagination.
20091
+ */
20092
+ async listOrgRepos(org) {
20093
+ return this.paginate(
20094
+ `${this.apiUrl}/orgs/${encodeURIComponent(org)}/repos`,
20095
+ { type: "all", sort: "pushed", direction: "desc" }
20096
+ );
20097
+ }
20098
+ /**
20099
+ * Lists repositories for a user.
20100
+ *
20101
+ * - Without token: GET /users/{username}/repos
20102
+ * - default: type=all
20103
+ * - ownerOnly: type=owner
20104
+ * - With token for own user: GET /user/repos filtered by owner login
20105
+ * (includes private repos the token can see)
20106
+ * - With token for other user: GET /users/{username}/repos
20107
+ * (only public repos visible)
20108
+ */
20109
+ async listUserRepos(login, ownerOnly = false) {
20110
+ if (this.token) {
20111
+ try {
20112
+ const authedUser = await this.fetch(`${this.apiUrl}/user`);
20113
+ if (authedUser.login.toLowerCase() === login.toLowerCase()) {
20114
+ const allRepos = await this.paginate(
20115
+ `${this.apiUrl}/user/repos`,
20116
+ { sort: "pushed", direction: "desc", affiliation: "owner" }
20117
+ );
20118
+ return allRepos;
20119
+ }
20120
+ } catch {
20121
+ }
20122
+ }
20123
+ return this.paginate(
20124
+ `${this.apiUrl}/users/${encodeURIComponent(login)}/repos`,
20125
+ { type: ownerOnly ? "owner" : "all", sort: "pushed", direction: "desc" }
20126
+ );
20127
+ }
20128
+ /**
20129
+ * Lists repos based on resolved owner type.
20130
+ */
20131
+ async listRepos(login, ownerType, options) {
20132
+ if (ownerType === "org") {
20133
+ return this.listOrgRepos(login);
20134
+ }
20135
+ return this.listUserRepos(login, options?.ownerOnly ?? false);
20136
+ }
20137
+ // ─── Cloning ───────────────────────────────────────────────
20138
+ /**
20139
+ * Shallow-clones a repo into a temporary directory.
20140
+ * Returns the path to the cloned repo.
20141
+ *
20142
+ * Uses HTTPS with token auth embedded in the URL (when token is available).
20143
+ */
20144
+ cloneToTemp(repo, branch) {
20145
+ const tempDir = mkdtempSync2(join5(tmpdir2(), `verimu-gh-${repo.id}-`));
20146
+ const cloneUrl = this.token ? this.buildAuthUrl(repo.clone_url) : repo.clone_url;
20147
+ const targetBranch = branch ?? repo.default_branch;
20148
+ try {
20149
+ execSync3(
20150
+ `git clone --depth 1 --branch "${targetBranch}" --single-branch "${cloneUrl}" "${tempDir}"`,
20151
+ {
20152
+ stdio: "pipe",
20153
+ timeout: 12e4,
20154
+ // 2 minute timeout per clone
20155
+ env: {
20156
+ ...process.env,
20157
+ GIT_TERMINAL_PROMPT: "0"
20158
+ // Never prompt for auth
20159
+ }
20160
+ }
20161
+ );
20162
+ } catch (err) {
20163
+ this.cleanupTemp(tempDir);
20164
+ const msg = err instanceof Error ? err.message : String(err);
20165
+ throw new Error(`Clone failed for ${repo.full_name}: ${msg}`);
20166
+ }
20167
+ return tempDir;
20168
+ }
20169
+ /**
20170
+ * Removes a temporary clone directory.
20171
+ */
20172
+ cleanupTemp(tempDir) {
20173
+ if (existsSync16(tempDir)) {
20174
+ rmSync2(tempDir, { recursive: true, force: true });
20175
+ }
20176
+ }
20177
+ // ─── Rate Limit Accessors ──────────────────────────────────
20178
+ /** Returns the last observed rate limit info, if any. */
20179
+ getRateLimit() {
20180
+ return this.lastRateLimit;
20181
+ }
20182
+ /**
20183
+ * Returns the hourly rate limit for this client configuration.
20184
+ * - Unauthenticated: 60 requests/hour
20185
+ * - Authenticated (PAT/OAuth): 5,000 requests/hour
20186
+ */
20187
+ getExpectedRateLimit() {
20188
+ return this.token ? 5e3 : 60;
20189
+ }
20190
+ // ─── Internals ─────────────────────────────────────────────
20191
+ /**
20192
+ * Paginated GET — fetches all pages of a list endpoint.
20193
+ * GitHub uses `per_page` (max 100) and `page` parameters.
20194
+ */
20195
+ async paginate(url, params = {}) {
20196
+ const all = [];
20197
+ let page = 1;
20198
+ const perPage = 100;
20199
+ while (true) {
20200
+ const searchParams = new URLSearchParams({
20201
+ ...params,
20202
+ per_page: String(perPage),
20203
+ page: String(page)
20204
+ });
20205
+ const fullUrl = `${url}?${searchParams.toString()}`;
20206
+ const items = await this.fetch(fullUrl);
20207
+ if (items.length === 0) break;
20208
+ all.push(...items);
20209
+ page++;
20210
+ if (items.length < perPage) break;
20211
+ }
20212
+ return all;
20213
+ }
20214
+ /**
20215
+ * Builds an authenticated HTTPS URL for git clone.
20216
+ * Embeds the token as a password with "x-access-token" user.
20217
+ */
20218
+ buildAuthUrl(httpUrl) {
20219
+ const url = new URL(httpUrl);
20220
+ url.username = "x-access-token";
20221
+ url.password = this.token;
20222
+ return url.toString();
20223
+ }
20224
+ /**
20225
+ * Makes an authenticated GET request to the GitHub API.
20226
+ *
20227
+ * Rate limit handling:
20228
+ * - Reads X-RateLimit-Remaining and X-RateLimit-Reset headers
20229
+ * - If remaining is 0, waits until the reset time or throws with actionable message
20230
+ * - Retries on transient 502/503/504 with exponential backoff (up to 3 retries)
20231
+ * - Returns 403/429 rate limit errors with reset time information
20232
+ */
20233
+ async fetch(url) {
20234
+ const maxRetries = 3;
20235
+ let lastError = null;
20236
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
20237
+ if (this.lastRateLimit && this.lastRateLimit.remaining === 0) {
20238
+ const now = Date.now();
20239
+ const resetMs = this.lastRateLimit.resetAt.getTime();
20240
+ if (now < resetMs) {
20241
+ const waitSec = Math.ceil((resetMs - now) / 1e3);
20242
+ if (waitSec <= 60) {
20243
+ console.log(` Rate limit reached. Waiting ${waitSec}s for reset...`);
20244
+ await this.sleep(waitSec * 1e3);
20245
+ } else {
20246
+ throw new Error(
20247
+ `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).`
20248
+ );
20249
+ }
20250
+ }
20251
+ }
20252
+ const headers = {
20253
+ "Accept": "application/vnd.github+json",
20254
+ "X-GitHub-Api-Version": "2022-11-28"
20255
+ };
20256
+ if (this.token) {
20257
+ headers["Authorization"] = `Bearer ${this.token}`;
20258
+ }
20259
+ let response;
20260
+ try {
20261
+ response = await globalThis.fetch(url, { headers });
20262
+ } catch (err) {
20263
+ lastError = err instanceof Error ? err : new Error(String(err));
20264
+ if (attempt < maxRetries) {
20265
+ await this.sleep(1e3 * 2 ** attempt);
20266
+ continue;
20267
+ }
20268
+ throw lastError;
20269
+ }
20270
+ this.updateRateLimit(response);
20271
+ if (response.ok) {
20272
+ return response.json();
20273
+ }
20274
+ if (response.status === 403 || response.status === 429) {
20275
+ const body2 = await response.text().catch(() => "no body");
20276
+ if (body2.includes("rate limit") || body2.includes("API rate limit") || response.status === 429) {
20277
+ const resetHeader = response.headers.get("X-RateLimit-Reset");
20278
+ const resetAt = resetHeader ? new Date(Number(resetHeader) * 1e3) : new Date(Date.now() + 6e4);
20279
+ const waitSec = Math.ceil((resetAt.getTime() - Date.now()) / 1e3);
20280
+ const retryAfter = response.headers.get("Retry-After");
20281
+ if (retryAfter && attempt < maxRetries) {
20282
+ const waitMs = Number(retryAfter) * 1e3;
20283
+ console.log(` Rate limited (secondary). Waiting ${retryAfter}s...`);
20284
+ await this.sleep(Math.min(waitMs, 6e4));
20285
+ continue;
20286
+ }
20287
+ throw new Error(
20288
+ `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).`
20289
+ );
20290
+ }
20291
+ throw new Error(
20292
+ `GitHub API error: ${response.status} ${response.statusText} \u2014 ${url}
20293
+ ${body2}`
20294
+ );
20295
+ }
20296
+ if ([502, 503, 504].includes(response.status) && attempt < maxRetries) {
20297
+ lastError = new Error(`GitHub API error: ${response.status} ${response.statusText}`);
20298
+ await this.sleep(1e3 * 2 ** attempt);
20299
+ continue;
20300
+ }
20301
+ const body = await response.text().catch(() => "no body");
20302
+ throw new Error(
20303
+ `GitHub API error: ${response.status} ${response.statusText} \u2014 ${url}
20304
+ ${body}`
20305
+ );
20306
+ }
20307
+ throw lastError ?? new Error(`GitHub API request failed after ${maxRetries} retries`);
20308
+ }
20309
+ /**
20310
+ * Updates internal rate limit tracker from response headers.
20311
+ */
20312
+ updateRateLimit(response) {
20313
+ const limit = response.headers.get("X-RateLimit-Limit");
20314
+ const remaining = response.headers.get("X-RateLimit-Remaining");
20315
+ const reset = response.headers.get("X-RateLimit-Reset");
20316
+ const used = response.headers.get("X-RateLimit-Used");
20317
+ if (limit && remaining && reset) {
20318
+ this.lastRateLimit = {
20319
+ limit: Number(limit),
20320
+ remaining: Number(remaining),
20321
+ resetAt: new Date(Number(reset) * 1e3),
20322
+ used: used ? Number(used) : 0
20323
+ };
20324
+ }
20325
+ }
20326
+ sleep(ms) {
20327
+ return new Promise((resolve) => setTimeout(resolve, ms));
20328
+ }
20329
+ };
20330
+
20331
+ // src/github/orchestrator.ts
20332
+ var GitHubOrchestrator = class {
20333
+ discovery = new LockfileDiscovery();
20334
+ /**
20335
+ * Scans all repos for a GitHub profile (org or user).
20336
+ */
20337
+ async scanProfile(config) {
20338
+ const startTime = Date.now();
20339
+ const client = new GitHubClient(config.baseUrl, config.token);
20340
+ const { login } = parseProfile(config.profile, config.baseUrl);
20341
+ console.log(`
20342
+ GitHub profile: ${login}`);
20343
+ console.log(` Base URL: ${config.baseUrl}`);
20344
+ console.log(` Auth: ${config.token ? "token provided (5,000 req/h)" : "unauthenticated (60 req/h)"}`);
20345
+ process.stdout.write(" Detecting profile type... ");
20346
+ const ownerType = await client.detectOwnerType(login);
20347
+ console.log(ownerType);
20348
+ console.log(" Listing repositories...");
20349
+ const repos = await client.listRepos(login, ownerType, {
20350
+ ownerOnly: config.ownerOnly ?? false
20351
+ });
20352
+ console.log(` Found ${repos.length} repositories
20353
+ `);
20354
+ const { toScan, skipped } = this.filterRepos(repos, config);
20355
+ console.log(` Scanning ${toScan.length} repos (${skipped.length} skipped)
20356
+ `);
20357
+ const scannedRepos = [];
20358
+ const failedRepos = [];
20359
+ for (let i = 0; i < toScan.length; i++) {
20360
+ const repo = toScan[i];
20361
+ const label = `[${i + 1}/${toScan.length}]`;
20362
+ console.log(` ${label} ${repo.full_name}`);
20363
+ const repoStart = Date.now();
20364
+ let tempDir = null;
20365
+ try {
20366
+ process.stdout.write(" Cloning... ");
20367
+ tempDir = client.cloneToTemp(repo, config.branch);
20368
+ console.log("done");
20369
+ const discovered = await this.discovery.discover({
20370
+ rootPath: tempDir
20371
+ });
20372
+ if (discovered.length === 0) {
20373
+ console.log(" no lockfile found");
20374
+ scannedRepos.push({
20375
+ repo,
20376
+ reports: [],
20377
+ hasLockfile: false,
20378
+ durationMs: Date.now() - repoStart
20379
+ });
20380
+ continue;
20381
+ }
20382
+ console.log(` Found ${discovered.length} project(s)`);
20383
+ const reports = [];
20384
+ const autoGroupName = this.getAutoGroupName(repo, discovered.length, config.groupName);
20385
+ for (const disc of discovered) {
20386
+ const subLabel = discovered.length > 1 ? ` (${disc.relativePath})` : "";
20387
+ const uploadProjectName = this.getUploadProjectName(repo, disc.relativePath, discovered.length);
20388
+ const safeArtifactSuffix = uploadProjectName.replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, "-").toLowerCase();
20389
+ const sbomOutput = `${tempDir}/sbom.${safeArtifactSuffix}.cdx.json`;
20390
+ process.stdout.write(` Scanning${subLabel}... `);
20391
+ try {
20392
+ const report = await scan({
20393
+ projectPath: disc.projectPath,
20394
+ sbomOutput,
20395
+ skipCveCheck: config.skipCveCheck ?? false,
20396
+ apiKey: config.apiKey,
20397
+ apiBaseUrl: config.apiBaseUrl,
20398
+ groupName: autoGroupName,
20399
+ uploadProjectName,
20400
+ repositoryUrl: repo.html_url,
20401
+ platform: "github"
20402
+ });
20403
+ const vulnCount = report.summary.totalVulnerabilities;
20404
+ const depCount = report.summary.totalDependencies;
20405
+ if (vulnCount > 0) {
20406
+ console.log(
20407
+ `${depCount} deps, ${vulnCount} vulns (C:${report.summary.critical} H:${report.summary.high} M:${report.summary.medium} L:${report.summary.low})`
20408
+ );
20409
+ } else {
20410
+ console.log(`${depCount} deps, clean`);
20411
+ }
20412
+ reports.push(report);
20413
+ } catch (scanErr) {
20414
+ const msg = scanErr instanceof Error ? scanErr.message : String(scanErr);
20415
+ console.log(`FAILED: ${msg.slice(0, 80)}`);
20416
+ }
20417
+ }
20418
+ const durationMs = Date.now() - repoStart;
20419
+ scannedRepos.push({
20420
+ repo,
20421
+ reports,
20422
+ hasLockfile: true,
20423
+ durationMs
20424
+ });
20425
+ } catch (err) {
20426
+ const msg = err instanceof Error ? err.message : String(err);
20427
+ const durationMs = Date.now() - repoStart;
20428
+ if (msg.includes("No supported lockfile") || msg.includes("NoLockfileError")) {
20429
+ console.log(" no lockfile found");
20430
+ scannedRepos.push({
20431
+ repo,
20432
+ reports: [],
20433
+ hasLockfile: false,
20434
+ durationMs
20435
+ });
20436
+ } else {
20437
+ console.log(` FAILED: ${msg.slice(0, 100)}`);
20438
+ failedRepos.push({ repo, error: msg });
20439
+ }
20440
+ } finally {
20441
+ if (tempDir) {
20442
+ client.cleanupTemp(tempDir);
20443
+ }
20444
+ }
20445
+ console.log("");
20446
+ }
20447
+ const result = this.aggregate(
20448
+ config.baseUrl,
20449
+ login,
20450
+ ownerType,
20451
+ repos.length,
20452
+ scannedRepos,
20453
+ skipped,
20454
+ failedRepos,
20455
+ Date.now() - startTime
20456
+ );
20457
+ this.printSummary(result);
20458
+ return result;
20459
+ }
20460
+ // ─── Filtering ──────────────────────────────────────────────
20461
+ filterRepos(repos, config) {
20462
+ const toScan = [];
20463
+ const skipped = [];
20464
+ for (const repo of repos) {
20465
+ if (config.excludeArchived !== false && repo.archived) {
20466
+ skipped.push({ repo, reason: "archived" });
20467
+ continue;
20468
+ }
20469
+ if (config.excludeForks && repo.fork) {
20470
+ skipped.push({ repo, reason: "fork" });
20471
+ continue;
20472
+ }
20473
+ toScan.push(repo);
20474
+ }
20475
+ if (config.maxRepos && toScan.length > config.maxRepos) {
20476
+ const trimmed = toScan.splice(config.maxRepos);
20477
+ for (const r of trimmed) {
20478
+ skipped.push({ repo: r, reason: "exceeded --max-repos limit" });
20479
+ }
20480
+ }
20481
+ return { toScan, skipped };
20482
+ }
20483
+ getUploadProjectName(repo, relativePath, totalProjectsInRepo) {
20484
+ if (totalProjectsInRepo > 1) {
20485
+ return relativePath === "." ? repo.name : relativePath;
20486
+ }
20487
+ return repo.full_name;
20488
+ }
20489
+ getAutoGroupName(repo, totalProjectsInRepo, configuredGroupName) {
20490
+ if (configuredGroupName) {
20491
+ return configuredGroupName;
20492
+ }
20493
+ return totalProjectsInRepo > 1 ? repo.full_name : void 0;
20494
+ }
20495
+ // ─── Aggregation ────────────────────────────────────────────
20496
+ aggregate(instanceUrl, profile, profileType, totalDiscovered, scannedRepos, skippedRepos, failedRepos, durationMs) {
20497
+ const reposWithData = scannedRepos.filter((r) => r.reports.length > 0);
20498
+ const ecosystemBreakdown = {};
20499
+ let totalDeps = 0;
20500
+ let totalVulns = 0;
20501
+ let critical = 0;
20502
+ let high = 0;
20503
+ let medium = 0;
20504
+ let low = 0;
20505
+ let exploitedInWild = 0;
20506
+ let reposWithVulns = 0;
20507
+ for (const { reports } of reposWithData) {
20508
+ let repoHasVulns = false;
20509
+ for (const report of reports) {
20510
+ totalDeps += report.summary.totalDependencies;
20511
+ totalVulns += report.summary.totalVulnerabilities;
20512
+ critical += report.summary.critical;
20513
+ high += report.summary.high;
20514
+ medium += report.summary.medium;
20515
+ low += report.summary.low;
20516
+ exploitedInWild += report.summary.exploitedInWild;
20517
+ if (report.summary.totalVulnerabilities > 0) {
20518
+ repoHasVulns = true;
20519
+ }
20520
+ const eco = report.project.ecosystem;
20521
+ ecosystemBreakdown[eco] = (ecosystemBreakdown[eco] ?? 0) + 1;
20522
+ }
20523
+ if (repoHasVulns) reposWithVulns++;
20524
+ }
20525
+ const vulnMap = /* @__PURE__ */ new Map();
20526
+ for (const { repo, reports } of reposWithData) {
20527
+ for (const report of reports) {
20528
+ for (const vuln of report.cveCheck.vulnerabilities) {
20529
+ const existing = vulnMap.get(vuln.id);
20530
+ if (existing) {
20531
+ if (!existing.affectedRepos.includes(repo.full_name)) {
20532
+ existing.affectedRepos.push(repo.full_name);
20533
+ }
20534
+ } else {
20535
+ vulnMap.set(vuln.id, {
20536
+ id: vuln.id,
20537
+ severity: vuln.severity,
20538
+ summary: vuln.summary,
20539
+ affectedRepos: [repo.full_name],
20540
+ fixedVersion: vuln.fixedVersion,
20541
+ exploitedInWild: vuln.exploitedInWild
20542
+ });
20543
+ }
20544
+ }
20545
+ }
20546
+ }
20547
+ const severityOrder2 = {
20548
+ CRITICAL: 0,
20549
+ HIGH: 1,
20550
+ MEDIUM: 2,
20551
+ LOW: 3,
20552
+ UNKNOWN: 4
20553
+ };
20554
+ const topVulnerabilities = Array.from(vulnMap.values()).sort((a, b) => {
20555
+ const sevDiff = severityOrder2[a.severity] - severityOrder2[b.severity];
20556
+ if (sevDiff !== 0) return sevDiff;
20557
+ return b.affectedRepos.length - a.affectedRepos.length;
20558
+ });
20559
+ return {
20560
+ instanceUrl,
20561
+ profile,
20562
+ profileType,
20563
+ totalReposDiscovered: totalDiscovered,
20564
+ scannedRepos,
20565
+ skippedRepos,
20566
+ failedRepos,
20567
+ summary: {
20568
+ totalRepos: reposWithData.length,
20569
+ reposWithVulnerabilities: reposWithVulns,
20570
+ totalDependencies: totalDeps,
20571
+ totalVulnerabilities: totalVulns,
20572
+ critical,
20573
+ high,
20574
+ medium,
20575
+ low,
20576
+ exploitedInWild,
20577
+ ecosystemBreakdown
20578
+ },
20579
+ topVulnerabilities,
20580
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
20581
+ durationMs
20582
+ };
20583
+ }
20584
+ // ─── Summary Printing ───────────────────────────────────────
20585
+ printSummary(result) {
20586
+ const noLockfile = result.scannedRepos.filter((r) => !r.hasLockfile).length;
20587
+ const withLockfile = result.scannedRepos.filter((r) => r.hasLockfile).length;
20588
+ console.log("\n" + "\u2550".repeat(60));
20589
+ console.log(" VERIMU GITHUB SCAN \u2014 COMPLETE");
20590
+ console.log("\u2550".repeat(60));
20591
+ console.log(`
20592
+ Profile: ${result.profile} (${result.profileType})`);
20593
+ console.log(` Instance: ${result.instanceUrl}`);
20594
+ console.log("");
20595
+ console.log(` Repos found: ${result.totalReposDiscovered}`);
20596
+ console.log(` With lockfile: ${withLockfile}`);
20597
+ console.log(` No lockfile: ${noLockfile}`);
20598
+ console.log(` Skipped: ${result.skippedRepos.length} (archived/fork/limit)`);
20599
+ console.log(` Failed: ${result.failedRepos.length} (clone/scan error)`);
20600
+ console.log("");
20601
+ console.log(` Repos with vulns: ${result.summary.reposWithVulnerabilities} / ${withLockfile}`);
20602
+ console.log("");
20603
+ console.log(` Total dependencies: ${result.summary.totalDependencies}`);
20604
+ console.log(` Total vulnerabilities: ${result.summary.totalVulnerabilities}`);
20605
+ console.log(` Critical: ${result.summary.critical}`);
20606
+ console.log(` High: ${result.summary.high}`);
20607
+ console.log(` Medium: ${result.summary.medium}`);
20608
+ console.log(` Low: ${result.summary.low}`);
20609
+ if (result.summary.exploitedInWild > 0) {
20610
+ console.log(`
20611
+ \u{1F534} ${result.summary.exploitedInWild} actively exploited \u2014 CRA 24h reporting required`);
20612
+ }
20613
+ if (Object.keys(result.summary.ecosystemBreakdown).length > 0) {
20614
+ console.log("\n Ecosystems:");
20615
+ for (const [eco, count] of Object.entries(result.summary.ecosystemBreakdown)) {
20616
+ console.log(` ${eco}: ${count} project(s)`);
20617
+ }
20618
+ }
20619
+ console.log(`
20620
+ Completed in ${(result.durationMs / 1e3).toFixed(1)}s`);
20621
+ console.log("");
20622
+ }
20623
+ };
19017
20624
  export {
19018
20625
  ApiKeyRequiredError,
19019
20626
  CargoScanner,
@@ -19023,7 +20630,12 @@ export {
19023
20630
  CveSourceError,
19024
20631
  CycloneDxGenerator,
19025
20632
  DenoScanner,
20633
+ GitHubClient,
20634
+ GitHubOrchestrator,
20635
+ GitLabClient,
20636
+ GitLabOrchestrator,
19026
20637
  GoScanner,
20638
+ HtmlReporter,
19027
20639
  LockfileParseError,
19028
20640
  MavenScanner,
19029
20641
  NoLockfileError,
@@ -19045,6 +20657,7 @@ export {
19045
20657
  generateSbomArtifacts,
19046
20658
  generateSpdxSbom,
19047
20659
  generateSwidTag,
20660
+ parseProfile,
19048
20661
  printReport,
19049
20662
  scan,
19050
20663
  shouldFailCi,