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 +1659 -1
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1653 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +490 -1
- package/dist/index.d.ts +490 -1
- package/dist/index.mjs +1647 -34
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
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
|
|
15721
|
-
const cleanPath =
|
|
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(
|
|
15734
|
-
if (
|
|
15735
|
-
const lastAtIndex =
|
|
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 =
|
|
15740
|
-
const version2 =
|
|
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 =
|
|
15743
|
+
const atIndex = path15.indexOf("@");
|
|
15744
15744
|
if (atIndex < 0) {
|
|
15745
15745
|
return { name: null, version: null };
|
|
15746
15746
|
}
|
|
15747
|
-
const name =
|
|
15748
|
-
const version =
|
|
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(
|
|
15755
|
-
if (
|
|
15756
|
-
const parts =
|
|
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 =
|
|
15764
|
+
const slashIndex = path15.indexOf("/");
|
|
15765
15765
|
if (slashIndex < 0) {
|
|
15766
15766
|
return { name: null, version: null };
|
|
15767
15767
|
}
|
|
15768
|
-
const name =
|
|
15769
|
-
const version =
|
|
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: (
|
|
17338
|
-
const 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,
|
|
17343
|
-
for (const specifier of
|
|
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: (
|
|
17350
|
-
const 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,
|
|
17354
|
+
addMatch(pkgKey, path15.node.loc?.start.line ?? 1, "export_from", void 0, 0.85);
|
|
17355
17355
|
},
|
|
17356
|
-
ExportAllDeclaration: (
|
|
17357
|
-
const 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,
|
|
17361
|
+
addMatch(pkgKey, path15.node.loc?.start.line ?? 1, "export_from", void 0, 0.85);
|
|
17362
17362
|
},
|
|
17363
|
-
VariableDeclarator: (
|
|
17364
|
-
const 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: (
|
|
17378
|
-
const 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: (
|
|
17400
|
-
const 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,
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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,
|