@atbash/cli 0.5.15 → 0.6.1-dev.2

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.
Files changed (66) hide show
  1. package/README.md +389 -2
  2. package/dist/bin/atbash.js +39 -4
  3. package/dist/bin/atbash.js.map +1 -1
  4. package/dist/commands/config-cmd.js +15 -15
  5. package/dist/commands/config-cmd.js.map +1 -1
  6. package/dist/commands/connect.d.ts +115 -1
  7. package/dist/commands/connect.js +292 -73
  8. package/dist/commands/connect.js.map +1 -1
  9. package/dist/commands/github-scan.d.ts +104 -0
  10. package/dist/commands/github-scan.js +1054 -0
  11. package/dist/commands/github-scan.js.map +1 -0
  12. package/dist/commands/held.js +7 -2
  13. package/dist/commands/held.js.map +1 -1
  14. package/dist/commands/history.js +4 -1
  15. package/dist/commands/history.js.map +1 -1
  16. package/dist/commands/judge-options.d.ts +21 -0
  17. package/dist/commands/judge-options.js +24 -0
  18. package/dist/commands/judge-options.js.map +1 -0
  19. package/dist/commands/judge.js +5 -2
  20. package/dist/commands/judge.js.map +1 -1
  21. package/dist/commands/mcp-cmd.d.ts +13 -0
  22. package/dist/commands/mcp-cmd.js +216 -0
  23. package/dist/commands/mcp-cmd.js.map +1 -0
  24. package/dist/commands/policy.js +4 -1
  25. package/dist/commands/policy.js.map +1 -1
  26. package/dist/commands/scan.d.ts +45 -0
  27. package/dist/commands/scan.js +301 -0
  28. package/dist/commands/scan.js.map +1 -0
  29. package/dist/commands/setup.d.ts +533 -0
  30. package/dist/commands/setup.js +2653 -0
  31. package/dist/commands/setup.js.map +1 -0
  32. package/dist/commands/stats.js +3 -1
  33. package/dist/commands/stats.js.map +1 -1
  34. package/dist/commands/status.js +4 -1
  35. package/dist/commands/status.js.map +1 -1
  36. package/dist/commands/tier.js +4 -1
  37. package/dist/commands/tier.js.map +1 -1
  38. package/dist/commands/tools.js +11 -4
  39. package/dist/commands/tools.js.map +1 -1
  40. package/dist/commands/whoami.js +4 -1
  41. package/dist/commands/whoami.js.map +1 -1
  42. package/dist/lib/grade-llm.d.ts +38 -0
  43. package/dist/lib/grade-llm.js +64 -0
  44. package/dist/lib/grade-llm.js.map +1 -0
  45. package/dist/lib/llm.d.ts +34 -0
  46. package/dist/lib/llm.js +101 -0
  47. package/dist/lib/llm.js.map +1 -0
  48. package/dist/lib/policy.d.ts +40 -0
  49. package/dist/lib/policy.js +94 -0
  50. package/dist/lib/policy.js.map +1 -0
  51. package/dist/lib/risk.d.ts +112 -0
  52. package/dist/lib/risk.js +288 -0
  53. package/dist/lib/risk.js.map +1 -0
  54. package/dist/lib/threats-llm.d.ts +39 -0
  55. package/dist/lib/threats-llm.js +72 -0
  56. package/dist/lib/threats-llm.js.map +1 -0
  57. package/dist/shared/atbash-targets.d.ts +49 -0
  58. package/dist/shared/atbash-targets.js +63 -0
  59. package/dist/shared/atbash-targets.js.map +1 -0
  60. package/dist/shared/openclaw-runtime.d.ts +221 -0
  61. package/dist/shared/openclaw-runtime.js +476 -0
  62. package/dist/shared/openclaw-runtime.js.map +1 -0
  63. package/dist/shared/win-exec.d.ts +63 -0
  64. package/dist/shared/win-exec.js +147 -0
  65. package/dist/shared/win-exec.js.map +1 -0
  66. package/package.json +4 -3
@@ -0,0 +1,1054 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.GitHubApiClient = exports.GitHubScanError = void 0;
7
+ exports.resolveGitHubToken = resolveGitHubToken;
8
+ exports.classifyRisks = classifyRisks;
9
+ exports.scanGitHub = scanGitHub;
10
+ exports.executeGitHubScanCommand = executeGitHubScanCommand;
11
+ exports.registerGithubScanCommand = registerGithubScanCommand;
12
+ const chalk_1 = __importDefault(require("chalk"));
13
+ class GitHubScanError extends Error {
14
+ constructor(kind, message, status) {
15
+ super(message);
16
+ this.name = "GitHubScanError";
17
+ this.kind = kind;
18
+ this.status = status;
19
+ }
20
+ }
21
+ exports.GitHubScanError = GitHubScanError;
22
+ const DEFAULT_BASE_URL = "https://api.github.com";
23
+ const DEFAULT_SINCE_DAYS = 30;
24
+ const MAX_PAGES = 1000;
25
+ const API_VERSION = "2022-11-28";
26
+ const DEFAULT_MAX_REPOSITORIES = 1000;
27
+ const DEFAULT_MAX_BRANCHES_PER_REPOSITORY = 1000;
28
+ const DEFAULT_MAX_PULL_REQUESTS_PER_REPOSITORY = 1000;
29
+ const DEFAULT_MAX_COMMITS_PER_BRANCH = 1000;
30
+ const DEFAULT_MAX_FILES_PER_CHANGE = 3000;
31
+ function asRecord(value, context) {
32
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
33
+ throw new GitHubScanError("malformed_response", `GitHub returned a malformed ${context} response.`);
34
+ }
35
+ return value;
36
+ }
37
+ function asArray(value, context) {
38
+ if (!Array.isArray(value)) {
39
+ throw new GitHubScanError("malformed_response", `GitHub returned a malformed ${context} response.`);
40
+ }
41
+ return value;
42
+ }
43
+ function requiredString(record, key, context) {
44
+ const value = record[key];
45
+ if (typeof value !== "string" || value.length === 0) {
46
+ throw new GitHubScanError("malformed_response", `GitHub returned a malformed ${context} response.`);
47
+ }
48
+ return value;
49
+ }
50
+ function requiredNumber(record, key, context) {
51
+ const value = record[key];
52
+ if (typeof value !== "number" || !Number.isFinite(value)) {
53
+ throw new GitHubScanError("malformed_response", `GitHub returned a malformed ${context} response.`);
54
+ }
55
+ return value;
56
+ }
57
+ function requiredBoolean(record, key, context) {
58
+ const value = record[key];
59
+ if (typeof value !== "boolean") {
60
+ throw new GitHubScanError("malformed_response", `GitHub returned a malformed ${context} response.`);
61
+ }
62
+ return value;
63
+ }
64
+ function optionalString(record, key) {
65
+ const value = record[key];
66
+ return typeof value === "string" ? value : null;
67
+ }
68
+ function parseIntegerHeader(value) {
69
+ if (value === null || !/^\d+$/.test(value))
70
+ return null;
71
+ const parsed = Number(value);
72
+ return Number.isSafeInteger(parsed) ? parsed : null;
73
+ }
74
+ function parseResetAt(value) {
75
+ const seconds = parseIntegerHeader(value);
76
+ if (seconds === null)
77
+ return null;
78
+ const date = new Date(seconds * 1000);
79
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
80
+ }
81
+ function encodePathSegment(value) {
82
+ return encodeURIComponent(value);
83
+ }
84
+ function normalizedList(values) {
85
+ return [
86
+ ...new Set((values ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)),
87
+ ].sort((a, b) => a.localeCompare(b));
88
+ }
89
+ function parseLinkHeader(value) {
90
+ const links = new Map();
91
+ if (!value)
92
+ return links;
93
+ for (const part of value.split(",")) {
94
+ const match = part.trim().match(/^<([^>]+)>;\s*rel="([^"]+)"$/);
95
+ if (!match) {
96
+ throw new GitHubScanError("malformed_response", "GitHub returned a malformed pagination link.");
97
+ }
98
+ links.set(match[2], match[1]);
99
+ }
100
+ return links;
101
+ }
102
+ function itemIdentity(value) {
103
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
104
+ return JSON.stringify(value);
105
+ }
106
+ const record = value;
107
+ for (const key of [
108
+ "id",
109
+ "node_id",
110
+ "full_name",
111
+ "filename",
112
+ "sha",
113
+ "name",
114
+ "number",
115
+ ]) {
116
+ const candidate = record[key];
117
+ if (typeof candidate === "string" || typeof candidate === "number") {
118
+ return `${key}:${String(candidate)}`;
119
+ }
120
+ }
121
+ return JSON.stringify(value);
122
+ }
123
+ function recordPageItems(page, pageFingerprints, itemIdentities, context) {
124
+ const fingerprint = JSON.stringify(page);
125
+ if (pageFingerprints.has(fingerprint)) {
126
+ throw new GitHubScanError("malformed_response", `GitHub returned a duplicate ${context} page.`);
127
+ }
128
+ pageFingerprints.add(fingerprint);
129
+ for (const item of page) {
130
+ const identity = itemIdentity(item);
131
+ if (itemIdentities.has(identity)) {
132
+ throw new GitHubScanError("malformed_response", `GitHub returned a duplicate item while paginating ${context}.`);
133
+ }
134
+ itemIdentities.add(identity);
135
+ }
136
+ }
137
+ function enforceItemBound(currentCount, additionalCount, options) {
138
+ if (options.maxItems !== undefined &&
139
+ currentCount + additionalCount > options.maxItems) {
140
+ throw new GitHubScanError("limit", `${options.context ?? "GitHub pagination"} exceeded the configured maximum of ${options.maxItems}.`);
141
+ }
142
+ }
143
+ function repositoryEndpointTail(pathname) {
144
+ const named = pathname.match(/^\/repos\/[^/]+\/[^/]+(\/.+)$/);
145
+ if (named)
146
+ return named[1];
147
+ const numeric = pathname.match(/^\/repositories\/\d+(\/.+)$/);
148
+ return numeric ? numeric[1] : null;
149
+ }
150
+ function isSamePaginationEndpoint(current, next) {
151
+ if (current.pathname === next.pathname)
152
+ return true;
153
+ const currentTail = repositoryEndpointTail(current.pathname);
154
+ const nextTail = repositoryEndpointTail(next.pathname);
155
+ return currentTail !== null && currentTail === nextTail;
156
+ }
157
+ class GitHubApiClient {
158
+ constructor(token, options = {}) {
159
+ this.oauthScopeValues = new Set();
160
+ this.rateLimit = {
161
+ limit: null,
162
+ remaining: null,
163
+ resetAt: null,
164
+ resource: null,
165
+ };
166
+ if (!token.trim()) {
167
+ throw new GitHubScanError("configuration", "GitHub token cannot be empty.");
168
+ }
169
+ this.token = token;
170
+ this.baseUrl = new URL(options.baseUrl ?? DEFAULT_BASE_URL);
171
+ this.fetchImpl = options.fetchImpl ?? fetch;
172
+ this.userAgent = options.userAgent ?? "atbash-cli-github-scanner";
173
+ this.perPage = options.perPage ?? 100;
174
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 30000;
175
+ if (!Number.isInteger(this.perPage) ||
176
+ this.perPage < 1 ||
177
+ this.perPage > 100) {
178
+ throw new GitHubScanError("configuration", "GitHub page size must be an integer from 1 to 100.");
179
+ }
180
+ if (!Number.isInteger(this.requestTimeoutMs) ||
181
+ this.requestTimeoutMs < 1 ||
182
+ this.requestTimeoutMs > 300000) {
183
+ throw new GitHubScanError("configuration", "GitHub request timeout must be an integer from 1 to 300000 milliseconds.");
184
+ }
185
+ }
186
+ get scopes() {
187
+ return [...this.oauthScopeValues].sort((a, b) => a.localeCompare(b));
188
+ }
189
+ async get(path, params = {}) {
190
+ return (await this.request(path, params)).data;
191
+ }
192
+ async paginate(path, params = {}, options = {}) {
193
+ const initial = this.buildUrl(path, { ...params, per_page: this.perPage });
194
+ const visited = new Set();
195
+ const pageFingerprints = new Set();
196
+ const itemIdentities = new Set();
197
+ const items = [];
198
+ let next = initial;
199
+ let pages = 0;
200
+ while (next) {
201
+ if (visited.has(next.href) || pages >= MAX_PAGES) {
202
+ throw new GitHubScanError("malformed_response", "GitHub pagination was cyclic or exceeded the safety limit.");
203
+ }
204
+ visited.add(next.href);
205
+ pages += 1;
206
+ const response = await this.requestUrl(next);
207
+ const page = asArray(response.data, options.context ?? "paginated");
208
+ recordPageItems(page, pageFingerprints, itemIdentities, options.context ?? "items");
209
+ enforceItemBound(items.length, page.length, options);
210
+ items.push(...page);
211
+ next = this.resolveNextPage(next, response.headers, page.length);
212
+ }
213
+ return items;
214
+ }
215
+ async paginateWhile(path, params, shouldContinue, options = {}) {
216
+ const initial = this.buildUrl(path, { ...params, per_page: this.perPage });
217
+ const visited = new Set();
218
+ const pageFingerprints = new Set();
219
+ const itemIdentities = new Set();
220
+ const items = [];
221
+ let next = initial;
222
+ let pages = 0;
223
+ while (next) {
224
+ if (visited.has(next.href) || pages >= MAX_PAGES) {
225
+ throw new GitHubScanError("malformed_response", "GitHub pagination was cyclic or exceeded the safety limit.");
226
+ }
227
+ visited.add(next.href);
228
+ pages += 1;
229
+ const response = await this.requestUrl(next);
230
+ const page = asArray(response.data, options.context ?? "paginated");
231
+ recordPageItems(page, pageFingerprints, itemIdentities, options.context ?? "items");
232
+ enforceItemBound(items.length, page.length, options);
233
+ items.push(...page);
234
+ if (!shouldContinue(page))
235
+ break;
236
+ next = this.resolveNextPage(next, response.headers, page.length);
237
+ }
238
+ return items;
239
+ }
240
+ async paginateObjectField(path, field, params = {}, options = {}) {
241
+ const initial = this.buildUrl(path, { ...params, per_page: this.perPage });
242
+ const visited = new Set();
243
+ const pageFingerprints = new Set();
244
+ const itemIdentities = new Set();
245
+ const items = [];
246
+ let stableSha = null;
247
+ let next = initial;
248
+ let pages = 0;
249
+ while (next) {
250
+ if (visited.has(next.href) || pages >= MAX_PAGES) {
251
+ throw new GitHubScanError("malformed_response", "GitHub pagination was cyclic or exceeded the safety limit.");
252
+ }
253
+ visited.add(next.href);
254
+ pages += 1;
255
+ const response = await this.requestUrl(next);
256
+ const page = asRecord(response.data, `paginated ${field}`);
257
+ if (typeof page.sha === "string") {
258
+ if (stableSha === null)
259
+ stableSha = page.sha;
260
+ if (page.sha !== stableSha) {
261
+ throw new GitHubScanError("malformed_response", `GitHub changed the object identity while paginating ${field}.`);
262
+ }
263
+ }
264
+ const pageItems = asArray(page[field], `paginated ${field}`);
265
+ recordPageItems(pageItems, pageFingerprints, itemIdentities, options.context ?? field);
266
+ enforceItemBound(items.length, pageItems.length, options);
267
+ items.push(...pageItems);
268
+ next = this.resolveNextPage(next, response.headers, pageItems.length);
269
+ }
270
+ return items;
271
+ }
272
+ async paginateInstallationRepositories(params = {}, options = {}) {
273
+ const initial = this.buildUrl("/installation/repositories", {
274
+ ...params,
275
+ per_page: this.perPage,
276
+ });
277
+ const visited = new Set();
278
+ const pageFingerprints = new Set();
279
+ const itemIdentities = new Set();
280
+ const repositories = [];
281
+ let expectedTotal = null;
282
+ let next = initial;
283
+ let pages = 0;
284
+ while (next) {
285
+ if (visited.has(next.href) || pages >= MAX_PAGES) {
286
+ throw new GitHubScanError("malformed_response", "GitHub pagination was cyclic or exceeded the safety limit.");
287
+ }
288
+ visited.add(next.href);
289
+ pages += 1;
290
+ const response = await this.requestUrl(next);
291
+ const page = asRecord(response.data, "installation repositories");
292
+ const total = requiredNumber(page, "total_count", "installation repositories");
293
+ if (expectedTotal === null)
294
+ expectedTotal = total;
295
+ if (expectedTotal !== total) {
296
+ throw new GitHubScanError("malformed_response", "GitHub changed the repository total during pagination.");
297
+ }
298
+ if (options.maxItems !== undefined && total > options.maxItems) {
299
+ throw new GitHubScanError("limit", `Installation repository total ${total} exceeds the configured maximum of ${options.maxItems}.`);
300
+ }
301
+ const pageRepositories = asArray(page.repositories, "installation repositories");
302
+ recordPageItems(pageRepositories, pageFingerprints, itemIdentities, "installation repositories");
303
+ enforceItemBound(repositories.length, pageRepositories.length, options);
304
+ repositories.push(...pageRepositories);
305
+ if (repositories.length > total) {
306
+ throw new GitHubScanError("malformed_response", "GitHub returned more installation repositories than total_count.");
307
+ }
308
+ const links = parseLinkHeader(response.headers.get("link"));
309
+ if (repositories.length === total) {
310
+ if (links.has("next")) {
311
+ throw new GitHubScanError("malformed_response", "GitHub returned a next page after the installation total was reached.");
312
+ }
313
+ next = null;
314
+ }
315
+ else {
316
+ next = this.resolveNextPage(next, response.headers, pageRepositories.length);
317
+ if (!next) {
318
+ throw new GitHubScanError("malformed_response", "GitHub returned a partial installation repository listing.");
319
+ }
320
+ }
321
+ }
322
+ if (expectedTotal === null || repositories.length !== expectedTotal) {
323
+ throw new GitHubScanError("malformed_response", "GitHub returned a partial installation repository listing.");
324
+ }
325
+ return repositories;
326
+ }
327
+ buildUrl(path, params) {
328
+ const url = new URL(path, this.baseUrl);
329
+ for (const key of Object.keys(params).sort((a, b) => a.localeCompare(b))) {
330
+ const value = params[key];
331
+ if (value !== undefined)
332
+ url.searchParams.set(key, String(value));
333
+ }
334
+ return url;
335
+ }
336
+ resolveNextPage(current, headers, pageLength) {
337
+ const rawLink = headers.get("link");
338
+ const links = parseLinkHeader(rawLink);
339
+ const nextValue = links.get("next");
340
+ if (pageLength < this.perPage) {
341
+ if (nextValue) {
342
+ throw new GitHubScanError("malformed_response", "GitHub returned a next link after a short terminal page.");
343
+ }
344
+ return null;
345
+ }
346
+ if (nextValue)
347
+ return this.validateNextUrl(nextValue, current);
348
+ if (rawLink !== null)
349
+ return null;
350
+ const explicit = new URL(current);
351
+ const currentPage = Number(current.searchParams.get("page") ?? "1");
352
+ if (!Number.isSafeInteger(currentPage) || currentPage < 1) {
353
+ throw new GitHubScanError("malformed_response", "GitHub pagination contained an invalid page number.");
354
+ }
355
+ explicit.searchParams.set("page", String(currentPage + 1));
356
+ explicit.searchParams.sort();
357
+ return explicit;
358
+ }
359
+ validateNextUrl(value, current) {
360
+ let url;
361
+ try {
362
+ url = new URL(value);
363
+ }
364
+ catch {
365
+ throw new GitHubScanError("malformed_response", "GitHub returned an invalid pagination URL.");
366
+ }
367
+ if (url.origin !== this.baseUrl.origin) {
368
+ throw new GitHubScanError("malformed_response", "GitHub returned a cross-origin pagination URL.");
369
+ }
370
+ if (!isSamePaginationEndpoint(current, url)) {
371
+ throw new GitHubScanError("malformed_response", "GitHub changed the endpoint during pagination.");
372
+ }
373
+ const currentPage = Number(current.searchParams.get("page") ?? "1");
374
+ const nextPage = Number(url.searchParams.get("page"));
375
+ if (!Number.isSafeInteger(currentPage) ||
376
+ !Number.isSafeInteger(nextPage) ||
377
+ nextPage !== currentPage + 1) {
378
+ throw new GitHubScanError("malformed_response", "GitHub returned a non-sequential pagination link.");
379
+ }
380
+ const parameterNames = new Set([
381
+ ...current.searchParams.keys(),
382
+ ...url.searchParams.keys(),
383
+ ]);
384
+ parameterNames.delete("page");
385
+ for (const name of parameterNames) {
386
+ if (current.searchParams.getAll(name).join("\u0000") !==
387
+ url.searchParams.getAll(name).join("\u0000")) {
388
+ throw new GitHubScanError("malformed_response", "GitHub changed request filters during pagination.");
389
+ }
390
+ }
391
+ return url;
392
+ }
393
+ async request(path, params) {
394
+ return this.requestUrl(this.buildUrl(path, params));
395
+ }
396
+ async requestUrl(url) {
397
+ let response;
398
+ try {
399
+ response = await this.fetchImpl(url, {
400
+ method: "GET",
401
+ redirect: "error",
402
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
403
+ headers: {
404
+ accept: "application/vnd.github+json",
405
+ authorization: `Bearer ${this.token}`,
406
+ "user-agent": this.userAgent,
407
+ "x-github-api-version": API_VERSION,
408
+ },
409
+ });
410
+ }
411
+ catch (error) {
412
+ throw new GitHubScanError("api", `GitHub request failed: ${safeErrorMessage(error, this.token)}`);
413
+ }
414
+ this.captureHeaders(response.headers);
415
+ let data;
416
+ try {
417
+ data = await response.json();
418
+ }
419
+ catch {
420
+ throw new GitHubScanError("malformed_response", `GitHub returned invalid JSON (HTTP ${response.status}).`, response.status);
421
+ }
422
+ if (!response.ok) {
423
+ const remaining = parseIntegerHeader(response.headers.get("x-ratelimit-remaining"));
424
+ const kind = response.status === 401
425
+ ? "auth"
426
+ : response.status === 429 ||
427
+ (response.status === 403 && remaining === 0)
428
+ ? "rate_limit"
429
+ : response.status === 403 || response.status === 404
430
+ ? "permission"
431
+ : "api";
432
+ throw new GitHubScanError(kind, `GitHub ${kind.replace("_", " ")} error (HTTP ${response.status}).`, response.status);
433
+ }
434
+ return { data: data, headers: response.headers };
435
+ }
436
+ captureHeaders(headers) {
437
+ const scopes = headers.get("x-oauth-scopes");
438
+ if (scopes) {
439
+ for (const scope of scopes
440
+ .split(",")
441
+ .map((value) => value.trim())
442
+ .filter(Boolean)) {
443
+ this.oauthScopeValues.add(scope);
444
+ }
445
+ }
446
+ const limit = parseIntegerHeader(headers.get("x-ratelimit-limit"));
447
+ const remaining = parseIntegerHeader(headers.get("x-ratelimit-remaining"));
448
+ const currentRemaining = this.rateLimit.remaining;
449
+ this.rateLimit = {
450
+ limit: limit ?? this.rateLimit.limit,
451
+ remaining: remaining === null
452
+ ? currentRemaining
453
+ : currentRemaining === null
454
+ ? remaining
455
+ : Math.min(currentRemaining, remaining),
456
+ resetAt: parseResetAt(headers.get("x-ratelimit-reset")) ??
457
+ this.rateLimit.resetAt,
458
+ resource: headers.get("x-ratelimit-resource") ?? this.rateLimit.resource,
459
+ };
460
+ }
461
+ }
462
+ exports.GitHubApiClient = GitHubApiClient;
463
+ function resolveGitHubToken(env) {
464
+ const githubToken = env.GITHUB_TOKEN?.trim();
465
+ const ghToken = env.GH_TOKEN?.trim();
466
+ if (githubToken && ghToken && githubToken !== ghToken) {
467
+ throw new GitHubScanError("configuration", "GITHUB_TOKEN and GH_TOKEN contain different values; set only one.");
468
+ }
469
+ if (githubToken)
470
+ return { source: "GITHUB_TOKEN", token: githubToken };
471
+ if (ghToken)
472
+ return { source: "GH_TOKEN", token: ghToken };
473
+ throw new GitHubScanError("configuration", "Set GITHUB_TOKEN or GH_TOKEN to a read-only GitHub token.");
474
+ }
475
+ function redactResolvedToken(value, token) {
476
+ if (!token)
477
+ return value;
478
+ let redacted = value.split(token).join("<redacted>");
479
+ const fragments = new Set();
480
+ for (let length = token.length - 1; length >= 4; length -= 1) {
481
+ for (let start = 0; start + length <= token.length; start += 1) {
482
+ fragments.add(token.slice(start, start + length));
483
+ }
484
+ }
485
+ for (const fragment of fragments) {
486
+ redacted = redacted.split(fragment).join("<redacted>");
487
+ }
488
+ return redacted;
489
+ }
490
+ function safeErrorMessage(error, token) {
491
+ const raw = error instanceof Error ? error.message : String(error);
492
+ return redactResolvedToken(raw, token)
493
+ .replace(/(?:ghp|github_pat|ghs|ghu|gho|ghr)_[A-Za-z0-9_]+/gi, "<redacted>")
494
+ .replace(/Bearer\s+\S+/gi, "Bearer <redacted>")
495
+ .replace(/([?&](?:access_token|api_key|key|secret|token)=)[^&\s]+/gi, "$1<redacted>")
496
+ .slice(0, 300);
497
+ }
498
+ const RISK_RULES = [
499
+ {
500
+ category: "rell_contracts",
501
+ matches: (path) => path.endsWith(".rell") || /(^|\/)(contracts?|rell)(\/|$)/.test(path),
502
+ },
503
+ {
504
+ category: "encryption_keys_auth",
505
+ matches: (path) => /(^|[\/_.-])(auth|oauth|oidc|crypto|cryptography|encrypt|decrypt|cipher|key|keys|secret|signer|signature)([\/_.-]|$)/.test(path),
506
+ },
507
+ {
508
+ category: "decision_semantics",
509
+ matches: (path) => /(^|[\/_.-])(decision|verdict|judge|judgment|policy|guard|allow|deny|hold|block)([\/_.-]|$)/.test(path),
510
+ },
511
+ {
512
+ category: "sdk_plugin_api",
513
+ matches: (path) => /(^|\/)(sdk|plugins?|bindings?|api)(\/|$)/.test(path) ||
514
+ /(^|[_.-])(sdk|plugin|api)([_.-]|$)/.test(path),
515
+ },
516
+ {
517
+ category: "workflows_releases_dependencies",
518
+ matches: (path) => path.startsWith(".github/workflows/") ||
519
+ /(^|\/)(release|releases|changelog|dependencies|deps)([\/_.-]|$)/.test(path) ||
520
+ /(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|cargo\.lock|go\.sum|requirements[^/]*\.txt|pyproject\.toml)$/.test(path),
521
+ },
522
+ ];
523
+ function classifyRisks(input) {
524
+ const files = [
525
+ ...new Set(input.files.map((path) => path.replace(/\\/g, "/").toLowerCase())),
526
+ ].sort((a, b) => a.localeCompare(b));
527
+ const risks = [];
528
+ for (const rule of RISK_RULES) {
529
+ const reasons = files.filter(rule.matches);
530
+ if (reasons.length > 0)
531
+ risks.push({ category: rule.category, reasons });
532
+ }
533
+ if (input.protectedBranch && input.directCommit) {
534
+ risks.push({
535
+ category: "direct_protected_branch_commit",
536
+ reasons: [input.branch],
537
+ });
538
+ }
539
+ return risks;
540
+ }
541
+ function resolvePositiveInteger(value, fallback, label) {
542
+ const resolved = value ?? fallback;
543
+ if (!Number.isSafeInteger(resolved) || resolved < 1) {
544
+ throw new GitHubScanError("configuration", `${label} must be a positive integer.`);
545
+ }
546
+ return resolved;
547
+ }
548
+ function resolveScanLimits(options) {
549
+ return {
550
+ repositories: resolvePositiveInteger(options.maxRepositories, DEFAULT_MAX_REPOSITORIES, "Maximum repositories"),
551
+ branchesPerRepository: resolvePositiveInteger(options.maxBranchesPerRepository, DEFAULT_MAX_BRANCHES_PER_REPOSITORY, "Maximum branches per repository"),
552
+ pullRequestsPerRepository: resolvePositiveInteger(options.maxPullRequestsPerRepository, DEFAULT_MAX_PULL_REQUESTS_PER_REPOSITORY, "Maximum pull requests per repository"),
553
+ commitsPerBranch: resolvePositiveInteger(options.maxCommitsPerBranch, DEFAULT_MAX_COMMITS_PER_BRANCH, "Maximum commits per branch"),
554
+ filesPerChange: resolvePositiveInteger(options.maxFilesPerChange, DEFAULT_MAX_FILES_PER_CHANGE, "Maximum files per change"),
555
+ };
556
+ }
557
+ function parseRepository(value) {
558
+ const record = asRecord(value, "repository");
559
+ const owner = asRecord(record.owner, "repository owner");
560
+ const permissions = record.permissions === undefined
561
+ ? {}
562
+ : asRecord(record.permissions, "repository permissions");
563
+ return {
564
+ name: requiredString(record, "name", "repository"),
565
+ fullName: requiredString(record, "full_name", "repository"),
566
+ owner: requiredString(owner, "login", "repository owner"),
567
+ ownerType: requiredString(owner, "type", "repository owner"),
568
+ private: requiredBoolean(record, "private", "repository"),
569
+ archived: requiredBoolean(record, "archived", "repository"),
570
+ defaultBranch: requiredString(record, "default_branch", "repository"),
571
+ permissions: {
572
+ pull: permissions.pull === true,
573
+ push: permissions.push === true,
574
+ admin: permissions.admin === true,
575
+ },
576
+ };
577
+ }
578
+ function parseBranch(value) {
579
+ const record = asRecord(value, "branch");
580
+ const commit = asRecord(record.commit, "branch commit");
581
+ return {
582
+ name: requiredString(record, "name", "branch"),
583
+ protected: requiredBoolean(record, "protected", "branch"),
584
+ headSha: requiredString(commit, "sha", "branch commit"),
585
+ };
586
+ }
587
+ function actorIsBot(value) {
588
+ if (typeof value !== "object" || value === null || Array.isArray(value))
589
+ return false;
590
+ const actor = value;
591
+ const login = typeof actor.login === "string" ? actor.login : "";
592
+ return actor.type === "Bot" || /\[bot\]$/i.test(login);
593
+ }
594
+ function commitIsBot(record) {
595
+ if (actorIsBot(record.author) || actorIsBot(record.committer))
596
+ return true;
597
+ const commit = asRecord(record.commit, "commit");
598
+ for (const key of ["author", "committer"]) {
599
+ if (typeof commit[key] === "object" &&
600
+ commit[key] !== null &&
601
+ !Array.isArray(commit[key])) {
602
+ const identity = commit[key];
603
+ const email = typeof identity.email === "string" ? identity.email : "";
604
+ const name = typeof identity.name === "string" ? identity.name : "";
605
+ if (/\[bot\]|bot@|noreply\.github\.com$/i.test(`${name} ${email}`))
606
+ return true;
607
+ }
608
+ }
609
+ return false;
610
+ }
611
+ function parseFiles(value) {
612
+ return asArray(value, "files")
613
+ .map((item) => requiredString(asRecord(item, "file"), "filename", "file"))
614
+ .sort((a, b) => a.localeCompare(b));
615
+ }
616
+ async function authenticate(client, maxRepositories) {
617
+ try {
618
+ const user = asRecord(await client.get("/user"), "authenticated user");
619
+ const repositories = await client.paginate("/user/repos", {
620
+ affiliation: "owner,collaborator,organization_member",
621
+ sort: "full_name",
622
+ direction: "asc",
623
+ visibility: "all",
624
+ }, {
625
+ maxItems: maxRepositories,
626
+ context: "user repository inventory",
627
+ });
628
+ return {
629
+ kind: "user",
630
+ login: requiredString(user, "login", "authenticated user"),
631
+ id: requiredNumber(user, "id", "authenticated user"),
632
+ repositories,
633
+ };
634
+ }
635
+ catch (error) {
636
+ if (!(error instanceof GitHubScanError) ||
637
+ error.kind !== "permission" ||
638
+ error.status !== 403) {
639
+ throw error;
640
+ }
641
+ }
642
+ const installation = asRecord(await client.get("/installation"), "GitHub App installation");
643
+ const account = asRecord(installation.account, "GitHub App installation account");
644
+ return {
645
+ kind: "github_app_installation",
646
+ login: optionalString(account, "login"),
647
+ id: requiredNumber(installation, "id", "GitHub App installation"),
648
+ repositories: await client.paginateInstallationRepositories({}, {
649
+ maxItems: maxRepositories,
650
+ context: "installation repository inventory",
651
+ }),
652
+ };
653
+ }
654
+ async function verifyOrganizations(client, repositories) {
655
+ const owners = [
656
+ ...new Set(repositories
657
+ .filter((repo) => repo.ownerType === "Organization")
658
+ .map((repo) => repo.owner)),
659
+ ].sort((a, b) => a.localeCompare(b));
660
+ const organizations = [];
661
+ for (const owner of owners) {
662
+ const record = asRecord(await client.get(`/orgs/${encodePathSegment(owner)}`), "organization");
663
+ organizations.push({
664
+ login: requiredString(record, "login", "organization"),
665
+ id: requiredNumber(record, "id", "organization"),
666
+ access: "repository_metadata_read",
667
+ });
668
+ }
669
+ return organizations;
670
+ }
671
+ async function listBranches(client, repository, branchFilter, maxBranches) {
672
+ const repoPath = `/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.name)}`;
673
+ if (branchFilter) {
674
+ return [
675
+ parseBranch(await client.get(`${repoPath}/branches/${encodePathSegment(branchFilter)}`)),
676
+ ];
677
+ }
678
+ return (await client.paginate(`${repoPath}/branches`, {}, {
679
+ maxItems: maxBranches,
680
+ context: `branches for ${repository.fullName}`,
681
+ }))
682
+ .map(parseBranch)
683
+ .sort((a, b) => a.name.localeCompare(b.name));
684
+ }
685
+ async function listPullRequests(client, repository, options, maxPullRequests) {
686
+ const repoPath = `/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.name)}`;
687
+ const since = options.since.getTime();
688
+ const pulls = await client.paginateWhile(`${repoPath}/pulls`, {
689
+ state: "closed",
690
+ sort: "updated",
691
+ direction: "desc",
692
+ base: options.branch,
693
+ }, (page) => {
694
+ if (page.length === 0)
695
+ return false;
696
+ const last = asRecord(page[page.length - 1], "pull request");
697
+ const updatedAt = Date.parse(requiredString(last, "updated_at", "pull request"));
698
+ if (Number.isNaN(updatedAt)) {
699
+ throw new GitHubScanError("malformed_response", "GitHub returned a malformed pull request timestamp.");
700
+ }
701
+ return updatedAt >= since;
702
+ }, {
703
+ maxItems: maxPullRequests,
704
+ context: `pull requests for ${repository.fullName}`,
705
+ });
706
+ const merged = pulls
707
+ .map((value) => asRecord(value, "pull request"))
708
+ .filter((pull) => {
709
+ const mergedAt = optionalString(pull, "merged_at");
710
+ if (!mergedAt)
711
+ return false;
712
+ const timestamp = Date.parse(mergedAt);
713
+ if (Number.isNaN(timestamp)) {
714
+ throw new GitHubScanError("malformed_response", "GitHub returned a malformed pull request timestamp.");
715
+ }
716
+ return timestamp >= since;
717
+ });
718
+ const filteredBots = options.includeBots
719
+ ? 0
720
+ : merged.filter((pull) => actorIsBot(pull.user)).length;
721
+ return {
722
+ items: options.includeBots
723
+ ? merged
724
+ : merged.filter((pull) => !actorIsBot(pull.user)),
725
+ filteredBots,
726
+ };
727
+ }
728
+ async function scanPullRequest(client, repository, pull, maxFiles) {
729
+ const number = requiredNumber(pull, "number", "pull request");
730
+ const repoPath = `/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.name)}`;
731
+ const files = parseFiles(await client.paginate(`${repoPath}/pulls/${number}/files`, {}, {
732
+ maxItems: maxFiles,
733
+ context: `files for ${repository.fullName} pull request ${number}`,
734
+ }));
735
+ const user = asRecord(pull.user, "pull request user");
736
+ const base = asRecord(pull.base, "pull request base");
737
+ const head = asRecord(pull.head, "pull request head");
738
+ return {
739
+ number,
740
+ title: requiredString(pull, "title", "pull request"),
741
+ author: requiredString(user, "login", "pull request user"),
742
+ mergedAt: requiredString(pull, "merged_at", "pull request"),
743
+ baseBranch: requiredString(base, "ref", "pull request base"),
744
+ headBranch: requiredString(head, "ref", "pull request head"),
745
+ mergeCommitSha: optionalString(pull, "merge_commit_sha"),
746
+ files,
747
+ risks: classifyRisks({
748
+ branch: requiredString(base, "ref", "pull request base"),
749
+ protectedBranch: false,
750
+ directCommit: false,
751
+ files,
752
+ }),
753
+ };
754
+ }
755
+ async function associatedWithMergedPullRequest(client, repoPath, sha, maxPullRequests) {
756
+ const pulls = await client.paginate(`${repoPath}/commits/${encodePathSegment(sha)}/pulls`, {}, {
757
+ maxItems: maxPullRequests,
758
+ context: "pull requests associated with a commit",
759
+ });
760
+ return pulls
761
+ .map((value) => asRecord(value, "associated pull request"))
762
+ .some((pull) => optionalString(pull, "merged_at") !== null);
763
+ }
764
+ async function scanCommit(client, repository, branch, commit, limits) {
765
+ const sha = requiredString(commit, "sha", "commit");
766
+ const repoPath = `/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.name)}`;
767
+ const files = parseFiles(await client.paginateObjectField(`${repoPath}/commits/${encodePathSegment(sha)}`, "files", {}, {
768
+ maxItems: limits.filesPerChange,
769
+ context: `files for ${repository.fullName} commit ${sha}`,
770
+ }));
771
+ const commitData = asRecord(commit.commit, "commit");
772
+ const authorData = asRecord(commitData.author, "commit author");
773
+ const actor = typeof commit.author === "object" &&
774
+ commit.author !== null &&
775
+ !Array.isArray(commit.author)
776
+ ? commit.author
777
+ : null;
778
+ const directCommit = branch.protected &&
779
+ !(await associatedWithMergedPullRequest(client, repoPath, sha, limits.pullRequestsPerRepository));
780
+ return {
781
+ sha,
782
+ branch: branch.name,
783
+ message: requiredString(commitData, "message", "commit").split(/\r?\n/, 1)[0],
784
+ authoredAt: requiredString(authorData, "date", "commit author"),
785
+ author: actor
786
+ ? optionalString(actor, "login")
787
+ : optionalString(authorData, "name"),
788
+ url: optionalString(commit, "html_url"),
789
+ directCommit,
790
+ files,
791
+ risks: classifyRisks({
792
+ branch: branch.name,
793
+ protectedBranch: branch.protected,
794
+ directCommit,
795
+ files,
796
+ }),
797
+ };
798
+ }
799
+ async function scanGitHub(client, options) {
800
+ if (Number.isNaN(options.since.getTime())) {
801
+ throw new GitHubScanError("configuration", "The --since value is invalid.");
802
+ }
803
+ if (options.expectRepositories !== undefined &&
804
+ (!Number.isSafeInteger(options.expectRepositories) ||
805
+ options.expectRepositories < 0)) {
806
+ throw new GitHubScanError("configuration", "--expect-repos must be a non-negative integer.");
807
+ }
808
+ const limits = resolveScanLimits(options);
809
+ const auth = await authenticate(client, limits.repositories);
810
+ const ownerFilters = normalizedList(options.owners);
811
+ const repositoryFilters = normalizedList(options.repositories);
812
+ const allRepositories = auth.repositories.map(parseRepository);
813
+ for (const owner of ownerFilters) {
814
+ if (!allRepositories.some((repository) => repository.owner.toLowerCase() === owner)) {
815
+ throw new GitHubScanError("permission", "An explicitly requested GitHub owner is not accessible to this token.");
816
+ }
817
+ }
818
+ for (const filter of repositoryFilters) {
819
+ if (!allRepositories.some((repository) => repository.name.toLowerCase() === filter ||
820
+ repository.fullName.toLowerCase() === filter)) {
821
+ throw new GitHubScanError("permission", "An explicitly requested GitHub repository is not accessible to this token.");
822
+ }
823
+ }
824
+ const repositories = allRepositories
825
+ .filter((repository) => ownerFilters.length === 0
826
+ ? true
827
+ : ownerFilters.includes(repository.owner.toLowerCase()))
828
+ .filter((repository) => repositoryFilters.length === 0
829
+ ? true
830
+ : repositoryFilters.includes(repository.name.toLowerCase()) ||
831
+ repositoryFilters.includes(repository.fullName.toLowerCase()))
832
+ .sort((a, b) => a.fullName.localeCompare(b.fullName));
833
+ if (repositories.length === 0 &&
834
+ (ownerFilters.length > 0 || repositoryFilters.length > 0)) {
835
+ throw new GitHubScanError("permission", "The requested GitHub owner/repository filter combination is not accessible.");
836
+ }
837
+ if (options.expectRepositories !== undefined &&
838
+ repositories.length !== options.expectRepositories) {
839
+ throw new GitHubScanError("limit", `Expected ${options.expectRepositories} repositories but verified ${repositories.length}.`);
840
+ }
841
+ const organizations = await verifyOrganizations(client, repositories);
842
+ const outputRepositories = [];
843
+ let filteredBots = 0;
844
+ for (const repository of repositories) {
845
+ const branches = await listBranches(client, repository, options.branch, limits.branchesPerRepository);
846
+ const pulls = await listPullRequests(client, repository, options, limits.pullRequestsPerRepository);
847
+ filteredBots += pulls.filteredBots;
848
+ const pullRequests = [];
849
+ for (const pull of pulls.items) {
850
+ pullRequests.push(await scanPullRequest(client, repository, pull, limits.filesPerChange));
851
+ }
852
+ pullRequests.sort((a, b) => {
853
+ const dateOrder = String(a.mergedAt).localeCompare(String(b.mergedAt));
854
+ return dateOrder || Number(a.number) - Number(b.number);
855
+ });
856
+ const commits = [];
857
+ for (const branch of branches) {
858
+ const repoPath = `/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.name)}`;
859
+ const values = await client.paginate(`${repoPath}/commits`, {
860
+ sha: branch.name,
861
+ since: options.since.toISOString(),
862
+ }, {
863
+ maxItems: limits.commitsPerBranch,
864
+ context: `commits for ${repository.fullName} branch ${branch.name}`,
865
+ });
866
+ for (const value of values) {
867
+ const commit = asRecord(value, "commit");
868
+ if (!options.includeBots && commitIsBot(commit)) {
869
+ filteredBots += 1;
870
+ continue;
871
+ }
872
+ commits.push(await scanCommit(client, repository, branch, commit, limits));
873
+ }
874
+ }
875
+ commits.sort((a, b) => {
876
+ const branchOrder = String(a.branch).localeCompare(String(b.branch));
877
+ const dateOrder = String(a.authoredAt).localeCompare(String(b.authoredAt));
878
+ return (branchOrder || dateOrder || String(a.sha).localeCompare(String(b.sha)));
879
+ });
880
+ outputRepositories.push({
881
+ name: repository.name,
882
+ fullName: repository.fullName,
883
+ owner: repository.owner,
884
+ private: repository.private,
885
+ archived: repository.archived,
886
+ defaultBranch: repository.defaultBranch,
887
+ permissions: repository.permissions,
888
+ branches,
889
+ pullRequests,
890
+ commits,
891
+ });
892
+ }
893
+ const privateRepositories = repositories.filter((repository) => repository.private).length;
894
+ return {
895
+ schemaVersion: 1,
896
+ classifier: {
897
+ kind: "deterministic_rule_based",
898
+ fullSast: false,
899
+ note: "Path-based triage only; this is not a full static application security test.",
900
+ },
901
+ auth: {
902
+ kind: auth.kind,
903
+ login: auth.login,
904
+ id: auth.id,
905
+ scopes: client.scopes,
906
+ scopeVerification: client.scopes.length > 0 ? "x_oauth_scopes" : "endpoint_access",
907
+ organizations,
908
+ },
909
+ filters: {
910
+ owners: ownerFilters,
911
+ repositories: repositoryFilters,
912
+ branch: options.branch ?? null,
913
+ since: options.since.toISOString(),
914
+ includeBots: options.includeBots,
915
+ expectRepositories: options.expectRepositories ?? null,
916
+ },
917
+ completeness: {
918
+ status: "complete_for_authenticated_api_view",
919
+ userRepositoryInventory: "explicit_page_probe_to_verified_terminal",
920
+ installationRepositoryInventory: "exact_total_count",
921
+ duplicateDetection: "page_and_item_identity",
922
+ snapshotConsistency: "not_guaranteed_by_github_rest_during_concurrent_mutation",
923
+ maxPagesPerEndpoint: MAX_PAGES,
924
+ limits,
925
+ },
926
+ summary: {
927
+ repositories: repositories.length,
928
+ privateRepositories,
929
+ publicRepositories: repositories.length - privateRepositories,
930
+ branches: outputRepositories.reduce((count, repository) => count + repository.branches.length, 0),
931
+ mergedPullRequests: outputRepositories.reduce((count, repository) => count + repository.pullRequests.length, 0),
932
+ commits: outputRepositories.reduce((count, repository) => count + repository.commits.length, 0),
933
+ filteredBots,
934
+ },
935
+ rateLimit: client.rateLimit,
936
+ repositories: outputRepositories,
937
+ };
938
+ }
939
+ function collectOption(value, previous) {
940
+ previous.push(value);
941
+ return previous;
942
+ }
943
+ function parseSince(value) {
944
+ if (!value) {
945
+ return new Date(Date.now() - DEFAULT_SINCE_DAYS * 24 * 60 * 60 * 1000);
946
+ }
947
+ const date = new Date(value);
948
+ if (Number.isNaN(date.getTime())) {
949
+ throw new GitHubScanError("configuration", "--since must be an ISO-8601 date or timestamp.");
950
+ }
951
+ return date;
952
+ }
953
+ function parseIntegerOption(value, label, allowZero = false) {
954
+ if (!/^\d+$/.test(value)) {
955
+ throw new GitHubScanError("configuration", `${label} must be ${allowZero ? "a non-negative" : "a positive"} integer.`);
956
+ }
957
+ const parsed = Number(value);
958
+ if (!Number.isSafeInteger(parsed) || (allowZero ? parsed < 0 : parsed < 1)) {
959
+ throw new GitHubScanError("configuration", `${label} must be ${allowZero ? "a non-negative" : "a positive"} integer.`);
960
+ }
961
+ return parsed;
962
+ }
963
+ function printHuman(result, write = (message) => console.log(message)) {
964
+ const auth = result.auth;
965
+ const summary = result.summary;
966
+ const rateLimit = result.rateLimit;
967
+ write(chalk_1.default.bold("GitHub Repository Scan"));
968
+ write(` Identity: ${chalk_1.default.cyan(String(auth.login ?? auth.kind))} (${String(auth.kind)})`);
969
+ write(` Repositories: ${summary.repositories} (${summary.privateRepositories} private, ${summary.publicRepositories} public)`);
970
+ write(` Surface: ${summary.branches} branches, ${summary.mergedPullRequests} merged PRs, ${summary.commits} commits`);
971
+ write(` Bots filtered: ${summary.filteredBots}`);
972
+ write(" Completeness: terminal pages verified; GitHub snapshot isolation is not guaranteed");
973
+ write(` Rate limit: ${String(rateLimit.remaining ?? "unknown")}/${String(rateLimit.limit ?? "unknown")} remaining`);
974
+ write("");
975
+ for (const value of result.repositories) {
976
+ const visibility = value.private
977
+ ? chalk_1.default.yellow("private")
978
+ : chalk_1.default.green("public");
979
+ write(chalk_1.default.bold(`${String(value.fullName)} `) + visibility);
980
+ write(chalk_1.default.dim(` ${value.branches.length} branches · ${value.pullRequests.length} merged PRs · ${value.commits.length} commits`));
981
+ const risks = [
982
+ ...value.pullRequests.flatMap((item) => item.risks),
983
+ ...value.commits.flatMap((item) => item.risks),
984
+ ];
985
+ const categories = [...new Set(risks.map((risk) => risk.category))].sort();
986
+ if (categories.length > 0) {
987
+ write(` Risk flags: ${chalk_1.default.yellow(categories.join(", "))}`);
988
+ }
989
+ }
990
+ write("");
991
+ write(chalk_1.default.dim("Classifier is deterministic path-based triage, not a full SAST analysis. No repository file contents were fetched."));
992
+ }
993
+ async function executeGitHubScanCommand(opts, runtime = {
994
+ env: process.env,
995
+ createClient: (token) => new GitHubApiClient(token),
996
+ scan: scanGitHub,
997
+ stdout: (message) => console.log(message),
998
+ stderr: (message) => console.error(message),
999
+ }) {
1000
+ let credential;
1001
+ try {
1002
+ credential = resolveGitHubToken(runtime.env);
1003
+ const client = runtime.createClient(credential.token);
1004
+ const result = await runtime.scan(client, {
1005
+ owners: opts.owner,
1006
+ repositories: opts.repo,
1007
+ branch: opts.branch,
1008
+ since: parseSince(opts.since),
1009
+ includeBots: opts.includeBots === true,
1010
+ expectRepositories: opts.expectRepos,
1011
+ maxRepositories: opts.maxRepos,
1012
+ maxBranchesPerRepository: opts.maxBranches,
1013
+ maxPullRequestsPerRepository: opts.maxPrs,
1014
+ maxCommitsPerBranch: opts.maxCommits,
1015
+ maxFilesPerChange: opts.maxFiles,
1016
+ });
1017
+ if (opts.json) {
1018
+ runtime.stdout(JSON.stringify(result, null, 2));
1019
+ }
1020
+ else {
1021
+ printHuman(result, runtime.stdout);
1022
+ }
1023
+ return 0;
1024
+ }
1025
+ catch (error) {
1026
+ const message = safeErrorMessage(error, credential?.token ?? "");
1027
+ runtime.stderr(chalk_1.default.red(`GitHub scan failed: ${message}`));
1028
+ return 1;
1029
+ }
1030
+ }
1031
+ function registerGithubScanCommand(program) {
1032
+ program
1033
+ .command("github-scan")
1034
+ .description("Scan accessible GitHub repository metadata without modifying GitHub")
1035
+ .option("--owner <owner>", "Limit to an owner (repeatable)", collectOption, [])
1036
+ .option("--repo <repository>", "Limit to a repository name or owner/name (repeatable)", collectOption, [])
1037
+ .option("--branch <branch>", "Limit branch, commits, and merged PR base")
1038
+ .option("--since <date>", `Include merged PRs and commits since ISO date (default: ${DEFAULT_SINCE_DAYS} days)`)
1039
+ .option("--expect-repos <count>", "Fail unless the filtered repository count matches", (value) => parseIntegerOption(value, "--expect-repos", true))
1040
+ .option("--max-repos <count>", "Fail above repository bound", (value) => parseIntegerOption(value, "--max-repos"))
1041
+ .option("--max-branches <count>", "Fail above branches per repository bound", (value) => parseIntegerOption(value, "--max-branches"))
1042
+ .option("--max-prs <count>", "Fail above recent PRs per repository bound", (value) => parseIntegerOption(value, "--max-prs"))
1043
+ .option("--max-commits <count>", "Fail above commits per branch bound", (value) => parseIntegerOption(value, "--max-commits"))
1044
+ .option("--max-files <count>", "Fail above files per PR or commit bound", (value) => parseIntegerOption(value, "--max-files"))
1045
+ .option("--include-bots", "Include bot-authored PRs and commits", false)
1046
+ .option("--json", "Output deterministic machine-readable JSON")
1047
+ .action(async (opts) => {
1048
+ const exitCode = await executeGitHubScanCommand(opts);
1049
+ if (exitCode !== 0) {
1050
+ process.exitCode = 1;
1051
+ }
1052
+ });
1053
+ }
1054
+ //# sourceMappingURL=github-scan.js.map