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