@owasp-aghast/aghast 0.8.0-beta.0

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 (178) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +127 -0
  3. package/assets/img/aghastowaspcaption.png +0 -0
  4. package/assets/txt/logo.txt +52 -0
  5. package/config/pricing.json +42 -0
  6. package/config/prompts/false-positive-validation.md +78 -0
  7. package/config/prompts/general-vuln-discovery.md +99 -0
  8. package/config/prompts/generic-instructions.md +59 -0
  9. package/config/prompts/test-cheaper-instructions.md +57 -0
  10. package/dist/budget.d.ts +62 -0
  11. package/dist/budget.d.ts.map +1 -0
  12. package/dist/budget.js +137 -0
  13. package/dist/budget.js.map +1 -0
  14. package/dist/build-config.d.ts +15 -0
  15. package/dist/build-config.d.ts.map +1 -0
  16. package/dist/build-config.js +568 -0
  17. package/dist/build-config.js.map +1 -0
  18. package/dist/check-library.d.ts +88 -0
  19. package/dist/check-library.d.ts.map +1 -0
  20. package/dist/check-library.js +461 -0
  21. package/dist/check-library.js.map +1 -0
  22. package/dist/check-types.d.ts +35 -0
  23. package/dist/check-types.d.ts.map +1 -0
  24. package/dist/check-types.js +66 -0
  25. package/dist/check-types.js.map +1 -0
  26. package/dist/claude-code-provider.d.ts +29 -0
  27. package/dist/claude-code-provider.d.ts.map +1 -0
  28. package/dist/claude-code-provider.js +347 -0
  29. package/dist/claude-code-provider.js.map +1 -0
  30. package/dist/cli.d.ts +13 -0
  31. package/dist/cli.d.ts.map +1 -0
  32. package/dist/cli.js +109 -0
  33. package/dist/cli.js.map +1 -0
  34. package/dist/colors.d.ts +7 -0
  35. package/dist/colors.d.ts.map +1 -0
  36. package/dist/colors.js +18 -0
  37. package/dist/colors.js.map +1 -0
  38. package/dist/cost-calculator.d.ts +80 -0
  39. package/dist/cost-calculator.d.ts.map +1 -0
  40. package/dist/cost-calculator.js +226 -0
  41. package/dist/cost-calculator.js.map +1 -0
  42. package/dist/defaults.d.ts +21 -0
  43. package/dist/defaults.d.ts.map +1 -0
  44. package/dist/defaults.js +21 -0
  45. package/dist/defaults.js.map +1 -0
  46. package/dist/diff-filter.d.ts +50 -0
  47. package/dist/diff-filter.d.ts.map +1 -0
  48. package/dist/diff-filter.js +130 -0
  49. package/dist/diff-filter.js.map +1 -0
  50. package/dist/diff-parser.d.ts +40 -0
  51. package/dist/diff-parser.d.ts.map +1 -0
  52. package/dist/diff-parser.js +112 -0
  53. package/dist/diff-parser.js.map +1 -0
  54. package/dist/diff-unit-matcher.d.ts +53 -0
  55. package/dist/diff-unit-matcher.d.ts.map +1 -0
  56. package/dist/diff-unit-matcher.js +156 -0
  57. package/dist/diff-unit-matcher.js.map +1 -0
  58. package/dist/discoveries/openant-discovery.d.ts +10 -0
  59. package/dist/discoveries/openant-discovery.d.ts.map +1 -0
  60. package/dist/discoveries/openant-discovery.js +56 -0
  61. package/dist/discoveries/openant-discovery.js.map +1 -0
  62. package/dist/discoveries/sarif-discovery.d.ts +9 -0
  63. package/dist/discoveries/sarif-discovery.d.ts.map +1 -0
  64. package/dist/discoveries/sarif-discovery.js +56 -0
  65. package/dist/discoveries/sarif-discovery.js.map +1 -0
  66. package/dist/discoveries/semgrep-discovery.d.ts +9 -0
  67. package/dist/discoveries/semgrep-discovery.d.ts.map +1 -0
  68. package/dist/discoveries/semgrep-discovery.js +61 -0
  69. package/dist/discoveries/semgrep-discovery.js.map +1 -0
  70. package/dist/discovery.d.ts +100 -0
  71. package/dist/discovery.d.ts.map +1 -0
  72. package/dist/discovery.js +49 -0
  73. package/dist/discovery.js.map +1 -0
  74. package/dist/error-codes.d.ts +46 -0
  75. package/dist/error-codes.d.ts.map +1 -0
  76. package/dist/error-codes.js +70 -0
  77. package/dist/error-codes.js.map +1 -0
  78. package/dist/formatters/index.d.ts +10 -0
  79. package/dist/formatters/index.d.ts.map +1 -0
  80. package/dist/formatters/index.js +23 -0
  81. package/dist/formatters/index.js.map +1 -0
  82. package/dist/formatters/json-formatter.d.ts +11 -0
  83. package/dist/formatters/json-formatter.d.ts.map +1 -0
  84. package/dist/formatters/json-formatter.js +11 -0
  85. package/dist/formatters/json-formatter.js.map +1 -0
  86. package/dist/formatters/sarif-formatter.d.ts +18 -0
  87. package/dist/formatters/sarif-formatter.d.ts.map +1 -0
  88. package/dist/formatters/sarif-formatter.js +103 -0
  89. package/dist/formatters/sarif-formatter.js.map +1 -0
  90. package/dist/formatters/types.d.ts +11 -0
  91. package/dist/formatters/types.d.ts.map +1 -0
  92. package/dist/formatters/types.js +6 -0
  93. package/dist/formatters/types.js.map +1 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.d.ts.map +1 -0
  96. package/dist/index.js +754 -0
  97. package/dist/index.js.map +1 -0
  98. package/dist/logging.d.ts +140 -0
  99. package/dist/logging.d.ts.map +1 -0
  100. package/dist/logging.js +344 -0
  101. package/dist/logging.js.map +1 -0
  102. package/dist/mock-agent-provider.d.ts +24 -0
  103. package/dist/mock-agent-provider.d.ts.map +1 -0
  104. package/dist/mock-agent-provider.js +38 -0
  105. package/dist/mock-agent-provider.js.map +1 -0
  106. package/dist/mock-ai-provider.d.ts +21 -0
  107. package/dist/mock-ai-provider.d.ts.map +1 -0
  108. package/dist/mock-ai-provider.js +31 -0
  109. package/dist/mock-ai-provider.js.map +1 -0
  110. package/dist/new-check.d.ts +13 -0
  111. package/dist/new-check.d.ts.map +1 -0
  112. package/dist/new-check.js +515 -0
  113. package/dist/new-check.js.map +1 -0
  114. package/dist/openant-loader.d.ts +105 -0
  115. package/dist/openant-loader.d.ts.map +1 -0
  116. package/dist/openant-loader.js +135 -0
  117. package/dist/openant-loader.js.map +1 -0
  118. package/dist/openant-runner.d.ts +30 -0
  119. package/dist/openant-runner.d.ts.map +1 -0
  120. package/dist/openant-runner.js +113 -0
  121. package/dist/openant-runner.js.map +1 -0
  122. package/dist/opencode-provider.d.ts +63 -0
  123. package/dist/opencode-provider.d.ts.map +1 -0
  124. package/dist/opencode-provider.js +719 -0
  125. package/dist/opencode-provider.js.map +1 -0
  126. package/dist/prompt-template.d.ts +12 -0
  127. package/dist/prompt-template.d.ts.map +1 -0
  128. package/dist/prompt-template.js +36 -0
  129. package/dist/prompt-template.js.map +1 -0
  130. package/dist/provider-registry.d.ts +15 -0
  131. package/dist/provider-registry.d.ts.map +1 -0
  132. package/dist/provider-registry.js +29 -0
  133. package/dist/provider-registry.js.map +1 -0
  134. package/dist/provider-utils.d.ts +52 -0
  135. package/dist/provider-utils.d.ts.map +1 -0
  136. package/dist/provider-utils.js +40 -0
  137. package/dist/provider-utils.js.map +1 -0
  138. package/dist/repository-analyzer.d.ts +68 -0
  139. package/dist/repository-analyzer.d.ts.map +1 -0
  140. package/dist/repository-analyzer.js +238 -0
  141. package/dist/repository-analyzer.js.map +1 -0
  142. package/dist/response-parser.d.ts +14 -0
  143. package/dist/response-parser.d.ts.map +1 -0
  144. package/dist/response-parser.js +111 -0
  145. package/dist/response-parser.js.map +1 -0
  146. package/dist/runtime-config.d.ts +15 -0
  147. package/dist/runtime-config.d.ts.map +1 -0
  148. package/dist/runtime-config.js +187 -0
  149. package/dist/runtime-config.js.map +1 -0
  150. package/dist/sarif-parser.d.ts +20 -0
  151. package/dist/sarif-parser.d.ts.map +1 -0
  152. package/dist/sarif-parser.js +76 -0
  153. package/dist/sarif-parser.js.map +1 -0
  154. package/dist/scan-history.d.ts +82 -0
  155. package/dist/scan-history.d.ts.map +1 -0
  156. package/dist/scan-history.js +127 -0
  157. package/dist/scan-history.js.map +1 -0
  158. package/dist/scan-runner.d.ts +122 -0
  159. package/dist/scan-runner.d.ts.map +1 -0
  160. package/dist/scan-runner.js +928 -0
  161. package/dist/scan-runner.js.map +1 -0
  162. package/dist/semgrep-runner.d.ts +25 -0
  163. package/dist/semgrep-runner.d.ts.map +1 -0
  164. package/dist/semgrep-runner.js +100 -0
  165. package/dist/semgrep-runner.js.map +1 -0
  166. package/dist/snippet-extractor.d.ts +25 -0
  167. package/dist/snippet-extractor.d.ts.map +1 -0
  168. package/dist/snippet-extractor.js +56 -0
  169. package/dist/snippet-extractor.js.map +1 -0
  170. package/dist/stats.d.ts +11 -0
  171. package/dist/stats.d.ts.map +1 -0
  172. package/dist/stats.js +197 -0
  173. package/dist/stats.js.map +1 -0
  174. package/dist/types.d.ts +321 -0
  175. package/dist/types.d.ts.map +1 -0
  176. package/dist/types.js +19 -0
  177. package/dist/types.js.map +1 -0
  178. package/package.json +61 -0
@@ -0,0 +1,928 @@
1
+ /**
2
+ * Scan runner (orchestrator).
3
+ * Runs security checks against a repository and produces ScanResults.
4
+ * Implements the core workflow from spec Section 2.2.
5
+ */
6
+ import { readFile } from 'node:fs/promises';
7
+ import { randomBytes } from 'node:crypto';
8
+ import { resolve, relative, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { buildPrompt } from './prompt-template.js';
11
+ import { parseAgentResponse } from './response-parser.js';
12
+ import { extractSnippet } from './snippet-extractor.js';
13
+ import { analyzeRepository } from './repository-analyzer.js';
14
+ import { logProgress, logDebug, logWarn, createTimer } from './logging.js';
15
+ import { CHECK_TYPE } from './check-types.js';
16
+ import { getDiscovery, registerDiscovery } from './discovery.js';
17
+ import { DEFAULT_PROVIDER_NAME } from './provider-registry.js';
18
+ import { semgrepDiscovery } from './discoveries/semgrep-discovery.js';
19
+ import { openantDiscovery } from './discoveries/openant-discovery.js';
20
+ import { sarifDiscovery } from './discoveries/sarif-discovery.js';
21
+ import { applyDiffFilter } from './diff-filter.js';
22
+ import { runOpenAnt } from './openant-runner.js';
23
+ import { DEFAULT_MODEL, FatalProviderError, } from './types.js';
24
+ import { calculateCost } from './cost-calculator.js';
25
+ import { checkBudget, BudgetExceededError } from './budget.js';
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const TAG = 'scan';
28
+ const DEFAULT_CONCURRENCY = 5;
29
+ // Per-target AI call timeout. Without this, a single hung provider request stalls
30
+ // the worker indefinitely; with concurrency=N hangs, the entire check freezes.
31
+ const DEFAULT_TARGET_TIMEOUT_MS = 5 * 60 * 1000;
32
+ // --- Register built-in discovery implementations ---
33
+ registerDiscovery(semgrepDiscovery);
34
+ registerDiscovery(openantDiscovery);
35
+ registerDiscovery(sarifDiscovery);
36
+ /**
37
+ * Decide whether to apply the diff filter to a check's discovered targets.
38
+ *
39
+ * Rule: filter iff the discovery supports diff filtering, the check hasn't
40
+ * opted out via `diffFilter: false`, and a diff source is available — either
41
+ * at runtime (CLI/env) or baked into the check's own `diffRef`.
42
+ *
43
+ * Exported for unit testing.
44
+ */
45
+ export function shouldApplyDiffFilter(check, discovery, runtimeDiffRef, runtimeDiffFile) {
46
+ if (!discovery.supportsDiffFilter)
47
+ return false;
48
+ if (check.checkTarget?.diffFilter === false)
49
+ return false;
50
+ const sourceAvailable = Boolean(runtimeDiffRef || runtimeDiffFile || check.checkTarget?.diffRef);
51
+ return sourceAvailable;
52
+ }
53
+ /**
54
+ * Plan the OpenAnt invocation and diff-filter mode for a single check.
55
+ *
56
+ * Returns the full decision tree the check execution needs:
57
+ * - `willApplyDiffFilter`: run the filter post-discovery?
58
+ * - `useDepthZeroFilter`: if so, skip OpenAnt and use overlap-only fallback?
59
+ * - `sharedOpenant`: a preloaded dataset + cleanup handle when we've run
60
+ * OpenAnt upfront (to share across discovery and filter).
61
+ *
62
+ * Also emits the "why OpenAnt is running" log line with wording that
63
+ * reflects the actual caller set (discovery-only, filter-only, or both),
64
+ * so operators debugging output aren't misled.
65
+ */
66
+ async function prepareCheckExecution(check, discovery, repositoryPath, diffRef, diffFile, openantAvailable) {
67
+ const willApplyDiffFilter = shouldApplyDiffFilter(check, discovery, diffRef, diffFile);
68
+ const useDepthZeroFilter = willApplyDiffFilter && !openantAvailable && discovery.name !== 'openant';
69
+ const discoveryUsesOpenant = discovery.name === 'openant';
70
+ const filterUsesOpenant = willApplyDiffFilter && !useDepthZeroFilter;
71
+ const needsOpenantDataset = discoveryUsesOpenant || filterUsesOpenant;
72
+ let sharedOpenant;
73
+ if (needsOpenantDataset) {
74
+ if (discoveryUsesOpenant && filterUsesOpenant) {
75
+ logProgress(TAG, 'Running OpenAnt once (shared between discovery and diff filter)');
76
+ }
77
+ else if (discoveryUsesOpenant) {
78
+ logProgress(TAG, 'Running OpenAnt for openant discovery');
79
+ }
80
+ else {
81
+ logProgress(TAG, 'Running OpenAnt for diff-filter call-graph computation');
82
+ }
83
+ sharedOpenant = await runOpenAnt(repositoryPath);
84
+ }
85
+ return { willApplyDiffFilter, useDepthZeroFilter, sharedOpenant };
86
+ }
87
+ /**
88
+ * Sum multiple TokenUsage values into one aggregate.
89
+ * Returns undefined if no inputs have token usage.
90
+ *
91
+ * reportedCost is aggregated only when every contributing call has it — a
92
+ * single missing cost means we cannot produce an accurate total, so we fall
93
+ * back to undefined (which triggers rate-based estimation later).
94
+ */
95
+ export function sumTokenUsage(usages) {
96
+ const defined = usages.filter((u) => u !== undefined);
97
+ if (defined.length === 0)
98
+ return undefined;
99
+ // Optional fields: sum when at least one is present; preserve undefined when all absent.
100
+ const sumOptional = (getter) => {
101
+ const values = defined.map(getter).filter((v) => v !== undefined);
102
+ return values.length > 0 ? values.reduce((a, b) => a + b, 0) : undefined;
103
+ };
104
+ // reportedCost: only aggregate when every item has it (uniform provider, single source).
105
+ let reportedCost;
106
+ if (defined.every((u) => u.reportedCost !== undefined)) {
107
+ const total = defined.reduce((sum, u) => sum + u.reportedCost.amountUsd, 0);
108
+ reportedCost = { amountUsd: total, source: defined[0].reportedCost.source };
109
+ }
110
+ return {
111
+ inputTokens: defined.reduce((sum, u) => sum + u.inputTokens, 0),
112
+ outputTokens: defined.reduce((sum, u) => sum + u.outputTokens, 0),
113
+ cacheCreationInputTokens: sumOptional((u) => u.cacheCreationInputTokens),
114
+ cacheReadInputTokens: sumOptional((u) => u.cacheReadInputTokens),
115
+ reasoningTokens: sumOptional((u) => u.reasoningTokens),
116
+ totalTokens: defined.reduce((sum, u) => sum + u.totalTokens, 0),
117
+ ...(reportedCost !== undefined ? { reportedCost } : {}),
118
+ };
119
+ }
120
+ /**
121
+ * Get the version from package.json.
122
+ */
123
+ async function getVersion() {
124
+ try {
125
+ const pkgPath = resolve(__dirname, '..', 'package.json');
126
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));
127
+ return pkg.version ?? '0.0.0';
128
+ }
129
+ catch {
130
+ return '0.0.0';
131
+ }
132
+ }
133
+ /**
134
+ * Run an async function over items with bounded concurrency.
135
+ * Spawns min(concurrency, items.length) workers that pull from a shared index.
136
+ * Results are written to a pre-allocated array to preserve input order.
137
+ *
138
+ * If abortHandle is provided, workers stop picking up new items once
139
+ * abortHandle.aborted is set to true. In-flight items complete naturally.
140
+ */
141
+ async function mapWithConcurrency(items, concurrency, fn, abortHandle) {
142
+ const results = new Array(items.length);
143
+ let nextIndex = 0;
144
+ async function worker() {
145
+ while (nextIndex < items.length) {
146
+ if (abortHandle?.aborted)
147
+ break;
148
+ // Safe without atomics: Node.js is single-threaded, so nextIndex++ is
149
+ // not interleaved — each worker awaits before looping, yielding to the
150
+ // event loop where the next worker reads and increments the same variable.
151
+ const i = nextIndex++;
152
+ results[i] = await fn(items[i], i);
153
+ }
154
+ }
155
+ const workerCount = Math.min(concurrency, items.length);
156
+ const workers = [];
157
+ for (let w = 0; w < workerCount; w++) {
158
+ workers.push(worker());
159
+ }
160
+ // Use allSettled so in-flight items complete before we propagate errors
161
+ const settled = await Promise.allSettled(workers);
162
+ const firstRejection = settled.find((r) => r.status === 'rejected');
163
+ if (firstRejection && firstRejection.status === 'rejected') {
164
+ throw firstRejection.reason;
165
+ }
166
+ return results;
167
+ }
168
+ function createCostTracker(options) {
169
+ return {
170
+ pricing: options.pricing,
171
+ budgetLimits: options.budgetLimits,
172
+ scanHistory: options.scanHistory,
173
+ totalTokens: 0,
174
+ totalCostUsd: 0,
175
+ warned: false,
176
+ lastAction: 'continue',
177
+ };
178
+ }
179
+ /**
180
+ * Record an AI call's token usage against the tracker. Called after each
181
+ * successful executeCheck().
182
+ */
183
+ function recordUsage(tracker, usage, model) {
184
+ if (!usage || !tracker.pricing)
185
+ return;
186
+ const cost = calculateCost(usage, model, tracker.pricing);
187
+ tracker.totalTokens += usage.totalTokens;
188
+ tracker.totalCostUsd += cost.totalCost;
189
+ tracker.lastCostSource = cost.source;
190
+ tracker.lastCostReportedBy = cost.reportedBy;
191
+ tracker.lastCostCoveredBySubscription = cost.coveredBySubscription;
192
+ }
193
+ /**
194
+ * Check the budget before an AI call. Logs a warning the first time the warn
195
+ * threshold is crossed; throws BudgetExceededError when the abort threshold is
196
+ * crossed.
197
+ */
198
+ function preflightBudget(tracker) {
199
+ if (!tracker.budgetLimits)
200
+ return;
201
+ const status = checkBudget({
202
+ currentScanCostUsd: tracker.totalCostUsd,
203
+ currentScanTokens: tracker.totalTokens,
204
+ history: tracker.scanHistory,
205
+ }, tracker.budgetLimits);
206
+ tracker.lastAction = status.action;
207
+ tracker.lastReason = status.reason;
208
+ if (status.action === 'abort') {
209
+ throw new BudgetExceededError(status.reason ?? 'Budget limit exceeded');
210
+ }
211
+ if (status.action === 'warn' && !tracker.warned) {
212
+ tracker.warned = true;
213
+ logProgress(TAG, `Budget warning: ${status.reason ?? 'approaching limit'}`);
214
+ }
215
+ }
216
+ /**
217
+ * Generate a scanId in the format: scan-<timestamp>-<hash>
218
+ */
219
+ export function generateScanId() {
220
+ const now = new Date();
221
+ const ts = now.toISOString().replace(/[-:T]/g, '').replace(/\..+/, '');
222
+ const hash = randomBytes(3).toString('hex');
223
+ return `scan-${ts}-${hash}`;
224
+ }
225
+ /**
226
+ * Normalize a file path to be relative to the repository root.
227
+ * Handles:
228
+ * - AI-generated paths that include the parent directory name(s) of the target repo
229
+ * (e.g., "test-codebases/test-2-importantvalidations-easy/routes/execute.py" when
230
+ * repo is at "checks-config/test-codebases/test-2-importantvalidations-easy")
231
+ * - Absolute paths: resolves and makes relative
232
+ * - Paths with repository prefix: strips the prefix
233
+ * - Relative paths: returns as-is with forward slashes
234
+ *
235
+ * This ensures consistent path formatting across all findings regardless
236
+ * of how the AI or discovery provider reported the path.
237
+ */
238
+ function normalizeFilePath(filePath, repositoryPath) {
239
+ const normalizedFile = filePath.replace(/\\/g, '/');
240
+ // If the path already looks relative and doesn't contain obvious parent refs,
241
+ // check if it starts with path segments that could be the parent of the repo.
242
+ // AI providers often return paths relative to a working directory that is
243
+ // different from the actual repository root, so we need to strip any
244
+ // prefix that leads to the repo path.
245
+ const normalizedRepo = resolve(repositoryPath);
246
+ const repoParts = normalizedRepo.replace(/\\/g, '/').split('/').filter(Boolean);
247
+ // Try stripping progressively longer trailing suffixes of the repo path from
248
+ // the front of the file path. AI providers often return paths relative to an
249
+ // ancestor directory, e.g. "test-codebases/test-2-easy/routes/run.py" when
250
+ // the repo is at "/abs/path/to/test-codebases/test-2-easy".
251
+ for (let i = 1; i <= repoParts.length; i++) {
252
+ const suffix = repoParts.slice(repoParts.length - i).join('/');
253
+ if (normalizedFile.startsWith(suffix + '/')) {
254
+ const candidate = normalizedFile.slice(suffix.length + 1);
255
+ const candidateResolved = resolve(normalizedRepo, candidate);
256
+ const candidateRelative = relative(normalizedRepo, candidateResolved).replace(/\\/g, '/');
257
+ if (!candidateRelative.startsWith('..')) {
258
+ return candidate;
259
+ }
260
+ }
261
+ }
262
+ // Fallback: resolve against repo and make relative
263
+ const resolved = resolve(normalizedRepo, normalizedFile);
264
+ const rel = relative(normalizedRepo, resolved).replace(/\\/g, '/');
265
+ if (!rel.startsWith('..')) {
266
+ return rel;
267
+ }
268
+ // Last resort: just normalize slashes
269
+ return normalizedFile;
270
+ }
271
+ /**
272
+ * Enrich a raw AI issue into a full SecurityIssue.
273
+ * Extracts code snippets, applies check metadata, and normalizes paths.
274
+ */
275
+ async function enrichIssue(aiIssue, checkId, checkName, repositoryPath, checkMetadata) {
276
+ const codeSnippet = await extractSnippet(repositoryPath, aiIssue.file, aiIssue.startLine, aiIssue.endLine);
277
+ const issue = {
278
+ checkId,
279
+ checkName,
280
+ file: normalizeFilePath(aiIssue.file, repositoryPath),
281
+ startLine: aiIssue.startLine,
282
+ endLine: aiIssue.endLine,
283
+ description: aiIssue.description,
284
+ };
285
+ if (codeSnippet !== undefined) {
286
+ issue.codeSnippet = codeSnippet;
287
+ }
288
+ if (checkMetadata?.severity !== undefined) {
289
+ issue.severity = checkMetadata.severity;
290
+ }
291
+ if (checkMetadata?.confidence !== undefined) {
292
+ issue.confidence = checkMetadata.confidence;
293
+ }
294
+ if (aiIssue.dataFlow !== undefined) {
295
+ issue.dataFlow = aiIssue.dataFlow.map((step) => ({
296
+ ...step,
297
+ file: normalizeFilePath(step.file, repositoryPath),
298
+ }));
299
+ }
300
+ return issue;
301
+ }
302
+ /**
303
+ * If the check specifies a per-check model, switch the provider to that model
304
+ * and return the previous model name so it can be restored after execution.
305
+ */
306
+ function applyPerCheckModel(check, agentProvider, globalModelName) {
307
+ if (!check.model || !agentProvider?.setModel)
308
+ return undefined;
309
+ const previousModel = agentProvider.getModelName?.() ?? globalModelName;
310
+ agentProvider.setModel(check.model);
311
+ logProgress(TAG, `Using per-check model: ${check.model} (check: ${check.id})`);
312
+ return previousModel;
313
+ }
314
+ /**
315
+ * Restore the provider's model to the previous value after per-check override.
316
+ */
317
+ function restoreModel(agentProvider, previousModel) {
318
+ if (previousModel !== undefined && agentProvider?.setModel) {
319
+ agentProvider.setModel(previousModel);
320
+ }
321
+ }
322
+ /**
323
+ * Map a discovered target directly to a SecurityIssue (for static checks).
324
+ * Extracts code snippet from source file via extractSnippet().
325
+ */
326
+ async function mapTargetToIssue(target, checkId, checkName, repositoryPath, checkMetadata) {
327
+ const codeSnippet = await extractSnippet(repositoryPath, target.file, target.startLine, target.endLine);
328
+ const issue = {
329
+ checkId,
330
+ checkName,
331
+ file: normalizeFilePath(target.file, repositoryPath),
332
+ startLine: target.startLine,
333
+ endLine: target.endLine,
334
+ description: target.message || 'Static finding',
335
+ };
336
+ if (codeSnippet !== undefined) {
337
+ issue.codeSnippet = codeSnippet;
338
+ }
339
+ if (checkMetadata?.severity !== undefined) {
340
+ issue.severity = checkMetadata.severity;
341
+ }
342
+ if (checkMetadata?.confidence !== undefined) {
343
+ issue.confidence = checkMetadata.confidence;
344
+ }
345
+ return issue;
346
+ }
347
+ /**
348
+ * Execute a single check against a repository.
349
+ * Routes to the appropriate execution path based on check type.
350
+ */
351
+ async function executeSingleCheck(check, checkName, checkInstructions, repositoryPath, agentProvider, costTracker, checkMetadata, concurrency, configDir, genericPrompt, diffRef, diffFile, openantAvailable = true) {
352
+ const checkId = check.id;
353
+ // Route to targeted execution (discovery + AI analysis)
354
+ if (check.checkTarget?.type === CHECK_TYPE.TARGETED) {
355
+ if (!agentProvider) {
356
+ throw new Error(`Check "${checkId}" requires an agent provider but none was configured`);
357
+ }
358
+ return executeTargetedCheck(check, checkName, checkInstructions, repositoryPath, agentProvider, costTracker, checkMetadata, concurrency, configDir, genericPrompt, diffRef, diffFile, openantAvailable);
359
+ }
360
+ // Route to static execution (discovery + direct mapping, no AI)
361
+ if (check.checkTarget?.type === CHECK_TYPE.STATIC) {
362
+ return executeStaticCheck(check, checkName, repositoryPath, checkMetadata, diffRef, diffFile, openantAvailable);
363
+ }
364
+ // Repository check (no discovery, AI analyzes whole repo)
365
+ if (!agentProvider) {
366
+ throw new Error(`Check "${checkId}" requires an agent provider but none was configured`);
367
+ }
368
+ logProgress(TAG, `Running check: ${checkName}`);
369
+ const prompt = await buildPrompt(checkInstructions, configDir, genericPrompt);
370
+ logDebug(TAG, `Prompt built: ${prompt.length} chars`);
371
+ let issues = [];
372
+ let summary;
373
+ const checkTimer = createTimer();
374
+ try {
375
+ preflightBudget(costTracker);
376
+ const agentResponse = await agentProvider.executeCheck(prompt, repositoryPath);
377
+ const model = agentProvider.getModelName?.() ?? DEFAULT_MODEL;
378
+ recordUsage(costTracker, agentResponse.tokenUsage, model);
379
+ const executionTime = checkTimer.elapsed();
380
+ logDebug(TAG, `Agent response: ${agentResponse.raw.length} chars`);
381
+ const parsed = agentResponse.parsed ?? parseAgentResponse(agentResponse.raw);
382
+ if (!parsed) {
383
+ logProgress(TAG, 'Result: ERROR (malformed response)');
384
+ summary = {
385
+ checkId,
386
+ checkName,
387
+ status: 'ERROR',
388
+ issuesFound: 0,
389
+ executionTime,
390
+ error: 'Agent provider returned malformed response',
391
+ rawAiResponse: agentResponse.raw,
392
+ tokenUsage: agentResponse.tokenUsage,
393
+ };
394
+ }
395
+ else if (parsed.issues.length > 0) {
396
+ logProgress(TAG, `Result: FAIL (${parsed.issues.length} issues)`);
397
+ issues = await Promise.all(parsed.issues.map((aiIssue) => enrichIssue(aiIssue, checkId, checkName, repositoryPath, checkMetadata)));
398
+ summary = {
399
+ checkId,
400
+ checkName,
401
+ status: 'FAIL',
402
+ issuesFound: issues.length,
403
+ executionTime,
404
+ tokenUsage: agentResponse.tokenUsage,
405
+ };
406
+ }
407
+ else if (parsed.flagged) {
408
+ logProgress(TAG, 'Result: FLAG (AI flagged for review)');
409
+ summary = {
410
+ checkId,
411
+ checkName,
412
+ status: 'FLAG',
413
+ issuesFound: 0,
414
+ executionTime,
415
+ tokenUsage: agentResponse.tokenUsage,
416
+ };
417
+ }
418
+ else {
419
+ logProgress(TAG, 'Result: PASS');
420
+ summary = {
421
+ checkId,
422
+ checkName,
423
+ status: 'PASS',
424
+ issuesFound: 0,
425
+ executionTime,
426
+ tokenUsage: agentResponse.tokenUsage,
427
+ };
428
+ }
429
+ }
430
+ catch (err) {
431
+ // Fatal errors and budget aborts must propagate up to stop the scan
432
+ if (err instanceof FatalProviderError || err instanceof BudgetExceededError) {
433
+ throw err;
434
+ }
435
+ const executionTime = checkTimer.elapsed();
436
+ const errorMsg = err instanceof Error ? err.message : String(err);
437
+ logProgress(TAG, `Result: ERROR (${errorMsg})`);
438
+ summary = {
439
+ checkId,
440
+ checkName,
441
+ status: 'ERROR',
442
+ issuesFound: 0,
443
+ executionTime,
444
+ error: errorMsg,
445
+ };
446
+ }
447
+ return { summary, issues };
448
+ }
449
+ /**
450
+ * Execute a targeted check: discovery finds targets, AI analyzes each.
451
+ * The execution pipeline is generic — all discovery-specific behavior is
452
+ * encapsulated in the DiscoveredTarget data from the discovery implementation.
453
+ */
454
+ async function executeTargetedCheck(check, checkName, checkInstructions, repositoryPath, agentProvider, costTracker, checkMetadata, optionsConcurrency, configDir, genericPromptOverride, diffRef, diffFile, openantAvailable = true) {
455
+ const checkId = check.id;
456
+ const checkTarget = check.checkTarget;
457
+ const discoveryName = checkTarget.discovery;
458
+ if (!discoveryName) {
459
+ throw new Error(`Check "${checkId}" is targeted but has no "discovery" specified`);
460
+ }
461
+ const discovery = getDiscovery(discoveryName);
462
+ logProgress(TAG, `Running targeted check: ${checkName} (discovery: ${discoveryName})`);
463
+ const checkTimer = createTimer();
464
+ const { willApplyDiffFilter, useDepthZeroFilter, sharedOpenant } = await prepareCheckExecution(check, discovery, repositoryPath, diffRef, diffFile, openantAvailable);
465
+ try {
466
+ // 1. Discover targets
467
+ let targets = await discovery.discover(check, repositoryPath, {
468
+ repositoryPath,
469
+ openantDatasetPath: sharedOpenant?.datasetPath,
470
+ });
471
+ // 2. Apply diff filter automatically when a diff source is available, the
472
+ // discovery supports it, and the check hasn't opted out via diffFilter: false.
473
+ if (willApplyDiffFilter) {
474
+ targets = await applyDiffFilter(check, targets, repositoryPath, {
475
+ diffRef,
476
+ diffFile,
477
+ depthZero: useDepthZeroFilter,
478
+ openant: checkTarget.openant,
479
+ openantDatasetPath: sharedOpenant?.datasetPath,
480
+ });
481
+ }
482
+ // 3. Apply maxTargets limit
483
+ if (checkTarget.maxTargets !== undefined && targets.length > checkTarget.maxTargets) {
484
+ targets = targets.slice(0, checkTarget.maxTargets);
485
+ logProgress(TAG, `Limited to ${targets.length} targets (maxTargets: ${checkTarget.maxTargets})`);
486
+ }
487
+ // 4. If no targets, return PASS
488
+ if (targets.length === 0) {
489
+ logProgress(TAG, 'Result: PASS (no targets found)');
490
+ return {
491
+ summary: {
492
+ checkId,
493
+ checkName,
494
+ status: 'PASS',
495
+ issuesFound: 0,
496
+ executionTime: checkTimer.elapsed(),
497
+ targetsAnalyzed: 0,
498
+ },
499
+ issues: [],
500
+ };
501
+ }
502
+ // 5. Resolve effective concurrency: per-check > options > default
503
+ const effectiveConcurrency = checkTarget.concurrency ?? optionsConcurrency ?? DEFAULT_CONCURRENCY;
504
+ logProgress(TAG, `Found ${targets.length} targets to analyze (concurrency: ${effectiveConcurrency})`);
505
+ // Progress summary timer — logs a periodic overview at info level (every 15s)
506
+ const PROGRESS_INTERVAL_MS = 15000;
507
+ const progressTimer = createTimer();
508
+ let inProgressCount = 0;
509
+ // 5. Resolve generic prompt: CLI override > analysisMode prompt > discovery default
510
+ const analysisModePrompts = {
511
+ 'false-positive-validation': 'false-positive-validation.md',
512
+ 'general-vuln-discovery': 'general-vuln-discovery.md',
513
+ };
514
+ const analysisModePrompt = checkTarget.analysisMode
515
+ ? analysisModePrompts[checkTarget.analysisMode]
516
+ : undefined;
517
+ const effectiveGenericPrompt = genericPromptOverride ?? analysisModePrompt ?? discovery.defaultGenericPrompt;
518
+ const basePrompt = await buildPrompt(checkInstructions, configDir, effectiveGenericPrompt);
519
+ let completedCount = 0;
520
+ const abortHandle = { aborted: false };
521
+ // 6. Analyze targets concurrently — pipeline is generic, no discovery conditionals
522
+ const logProgressSummary = () => {
523
+ const pending = targets.length - completedCount - inProgressCount;
524
+ logProgress(TAG, `AI progress [${targets.length} targets]: ${completedCount} complete, ${inProgressCount} in progress, ${pending} pending (${progressTimer.elapsedStr()})`);
525
+ };
526
+ logProgressSummary();
527
+ const progressInterval = setInterval(logProgressSummary, PROGRESS_INTERVAL_MS);
528
+ let targetResults;
529
+ try {
530
+ targetResults = await mapWithConcurrency(targets, effectiveConcurrency, async (target, _idx) => {
531
+ inProgressCount++;
532
+ try {
533
+ try {
534
+ preflightBudget(costTracker);
535
+ }
536
+ catch (budgetErr) {
537
+ if (budgetErr instanceof BudgetExceededError) {
538
+ abortHandle.aborted = true;
539
+ abortHandle.reason = budgetErr;
540
+ throw budgetErr;
541
+ }
542
+ throw budgetErr;
543
+ }
544
+ const prompt = basePrompt + (target.promptEnrichment ?? '');
545
+ logDebug(TAG, `${target.label} Analyzing: ${target.file}:${target.startLine}-${target.endLine}`);
546
+ let timeoutHandle;
547
+ const agentResponse = await Promise.race([
548
+ agentProvider.executeCheck(prompt, repositoryPath, target.label, target.agentOptions),
549
+ new Promise((_, reject) => {
550
+ timeoutHandle = setTimeout(() => reject(new Error(`Agent provider timed out after ${DEFAULT_TARGET_TIMEOUT_MS / 1000}s on target ${target.label}`)), DEFAULT_TARGET_TIMEOUT_MS);
551
+ }),
552
+ ]).finally(() => {
553
+ if (timeoutHandle)
554
+ clearTimeout(timeoutHandle);
555
+ });
556
+ const model = agentProvider.getModelName?.() ?? DEFAULT_MODEL;
557
+ recordUsage(costTracker, agentResponse.tokenUsage, model);
558
+ const parsed = agentResponse.parsed ?? parseAgentResponse(agentResponse.raw);
559
+ if (!parsed) {
560
+ logDebug(TAG, `${target.label} Returned malformed response`);
561
+ return { issues: [], error: true, flagged: false, tokenUsage: agentResponse.tokenUsage };
562
+ }
563
+ // Apply optional per-target issue cap (checkTarget.maxIssuesPerTarget).
564
+ // Useful for checks whose prompt expects one combined issue per target;
565
+ // most checks should leave it unset and allow unlimited issues per target.
566
+ const maxIssues = checkTarget.maxIssuesPerTarget;
567
+ const cappedIssues = typeof maxIssues === 'number' && maxIssues >= 0
568
+ ? parsed.issues.slice(0, maxIssues)
569
+ : parsed.issues;
570
+ if (cappedIssues.length < parsed.issues.length) {
571
+ logDebug(TAG, `${target.label} Capping ${parsed.issues.length} issues to ${maxIssues} (maxIssuesPerTarget)`);
572
+ }
573
+ const issues = await Promise.all(cappedIssues.map((aiIssue) => enrichIssue(aiIssue, checkId, checkName, repositoryPath, checkMetadata)));
574
+ return { issues, error: false, flagged: parsed.flagged === true, tokenUsage: agentResponse.tokenUsage };
575
+ }
576
+ catch (err) {
577
+ // Fatal errors and budget aborts: signal abort and re-throw to stop other workers
578
+ if (err instanceof FatalProviderError || err instanceof BudgetExceededError) {
579
+ abortHandle.aborted = true;
580
+ abortHandle.reason = err;
581
+ throw err;
582
+ }
583
+ const errorMsg = err instanceof Error ? err.message : String(err);
584
+ logDebug(TAG, `${target.label} Error: ${errorMsg}`);
585
+ return { issues: [], error: true, flagged: false, tokenUsage: undefined };
586
+ }
587
+ finally {
588
+ inProgressCount--;
589
+ completedCount++;
590
+ logDebug(TAG, `Progress: ${completedCount}/${targets.length} targets analyzed`);
591
+ }
592
+ }, abortHandle);
593
+ }
594
+ finally {
595
+ clearInterval(progressInterval);
596
+ }
597
+ // 7. Aggregate results
598
+ const allIssues = [];
599
+ let hasErrors = false;
600
+ let hasFlagged = false;
601
+ const targetTokenUsages = [];
602
+ for (const result of targetResults) {
603
+ allIssues.push(...result.issues);
604
+ if (result.error)
605
+ hasErrors = true;
606
+ if (result.flagged)
607
+ hasFlagged = true;
608
+ targetTokenUsages.push(result.tokenUsage);
609
+ }
610
+ // 8. Determine status: FAIL > FLAG > ERROR > PASS
611
+ const executionTime = checkTimer.elapsed();
612
+ let status;
613
+ if (allIssues.length > 0) {
614
+ status = 'FAIL';
615
+ }
616
+ else if (hasFlagged) {
617
+ status = 'FLAG';
618
+ }
619
+ else if (hasErrors) {
620
+ status = 'ERROR';
621
+ }
622
+ else {
623
+ status = 'PASS';
624
+ }
625
+ logProgress(TAG, `Result: ${status} (${allIssues.length} issues, ${targets.length} targets)`);
626
+ return {
627
+ summary: {
628
+ checkId,
629
+ checkName,
630
+ status,
631
+ issuesFound: allIssues.length,
632
+ executionTime,
633
+ targetsAnalyzed: targets.length,
634
+ tokenUsage: sumTokenUsage(targetTokenUsages),
635
+ },
636
+ issues: allIssues,
637
+ };
638
+ }
639
+ catch (err) {
640
+ // Fatal errors and budget aborts must propagate up to stop the scan
641
+ if (err instanceof FatalProviderError || err instanceof BudgetExceededError) {
642
+ throw err;
643
+ }
644
+ const executionTime = checkTimer.elapsed();
645
+ const errorMsg = err instanceof Error ? err.message : String(err);
646
+ logProgress(TAG, `Result: ERROR (${errorMsg})`);
647
+ return {
648
+ summary: {
649
+ checkId,
650
+ checkName,
651
+ status: 'ERROR',
652
+ issuesFound: 0,
653
+ executionTime,
654
+ error: errorMsg,
655
+ },
656
+ issues: [],
657
+ };
658
+ }
659
+ finally {
660
+ if (sharedOpenant) {
661
+ await sharedOpenant.cleanup();
662
+ logDebug(TAG, 'Cleaned up shared OpenAnt output');
663
+ }
664
+ }
665
+ }
666
+ /**
667
+ * Execute a static check: discovery finds targets, mapped directly to issues (no AI).
668
+ */
669
+ async function executeStaticCheck(check, checkName, repositoryPath, checkMetadata, diffRef, diffFile, openantAvailable = true) {
670
+ const checkId = check.id;
671
+ const checkTarget = check.checkTarget;
672
+ const discoveryName = checkTarget.discovery;
673
+ if (!discoveryName) {
674
+ throw new Error(`Check "${checkId}" is static but has no "discovery" specified`);
675
+ }
676
+ const discovery = getDiscovery(discoveryName);
677
+ logProgress(TAG, `Running static check: ${checkName} (discovery: ${discoveryName})`);
678
+ const checkTimer = createTimer();
679
+ const { willApplyDiffFilter, useDepthZeroFilter, sharedOpenant } = await prepareCheckExecution(check, discovery, repositoryPath, diffRef, diffFile, openantAvailable);
680
+ try {
681
+ // 1. Discover targets
682
+ let targets = await discovery.discover(check, repositoryPath, {
683
+ repositoryPath,
684
+ openantDatasetPath: sharedOpenant?.datasetPath,
685
+ });
686
+ // 2. Apply diff filter automatically when a diff source is available, the
687
+ // discovery supports it, and the check hasn't opted out via diffFilter: false.
688
+ if (willApplyDiffFilter) {
689
+ targets = await applyDiffFilter(check, targets, repositoryPath, {
690
+ diffRef,
691
+ diffFile,
692
+ depthZero: useDepthZeroFilter,
693
+ openant: checkTarget.openant,
694
+ openantDatasetPath: sharedOpenant?.datasetPath,
695
+ });
696
+ }
697
+ // 3. Apply maxTargets limit
698
+ if (checkTarget.maxTargets !== undefined && targets.length > checkTarget.maxTargets) {
699
+ targets = targets.slice(0, checkTarget.maxTargets);
700
+ }
701
+ // 3. If no targets, return PASS
702
+ if (targets.length === 0) {
703
+ logProgress(TAG, 'Result: PASS (no findings)');
704
+ return {
705
+ summary: {
706
+ checkId,
707
+ checkName,
708
+ status: 'PASS',
709
+ issuesFound: 0,
710
+ executionTime: checkTimer.elapsed(),
711
+ targetsAnalyzed: 0,
712
+ },
713
+ issues: [],
714
+ };
715
+ }
716
+ // 4. Map each target directly to a SecurityIssue (no AI)
717
+ const issues = await Promise.all(targets.map((target) => mapTargetToIssue(target, checkId, checkName, repositoryPath, checkMetadata)));
718
+ const executionTime = checkTimer.elapsed();
719
+ logProgress(TAG, `Result: FAIL (${issues.length} findings, ${targets.length} targets)`);
720
+ return {
721
+ summary: {
722
+ checkId,
723
+ checkName,
724
+ status: 'FAIL',
725
+ issuesFound: issues.length,
726
+ executionTime,
727
+ targetsAnalyzed: targets.length,
728
+ },
729
+ issues,
730
+ };
731
+ }
732
+ catch (err) {
733
+ const executionTime = checkTimer.elapsed();
734
+ const errorMsg = err instanceof Error ? err.message : String(err);
735
+ logProgress(TAG, `Result: ERROR (${errorMsg})`);
736
+ return {
737
+ summary: {
738
+ checkId,
739
+ checkName,
740
+ status: 'ERROR',
741
+ issuesFound: 0,
742
+ executionTime,
743
+ error: errorMsg,
744
+ },
745
+ issues: [],
746
+ };
747
+ }
748
+ finally {
749
+ if (sharedOpenant) {
750
+ await sharedOpenant.cleanup();
751
+ logDebug(TAG, 'Cleaned up shared OpenAnt output');
752
+ }
753
+ }
754
+ }
755
+ /**
756
+ * Run multiple security checks and return aggregated ScanResults.
757
+ */
758
+ export async function runMultiScan(options) {
759
+ const outcome = await runMultiScanWithCost(options);
760
+ return outcome.results;
761
+ }
762
+ /**
763
+ * Same as runMultiScan but also returns the computed cost summary.
764
+ * Used by the CLI to record scan history.
765
+ */
766
+ export async function runMultiScanWithCost(options) {
767
+ const { repositoryPath, checks, agentProvider, modelName, agentProviderName, concurrency, configDir, genericPrompt, diffRef, diffFile } = options;
768
+ const openantAvailable = options.openantAvailable ?? true;
769
+ const scanTimer = createTimer();
770
+ const scanId = generateScanId();
771
+ const startTime = new Date();
772
+ const version = await getVersion();
773
+ logProgress(TAG, `Starting scan ${scanId} (${checks.length} ${checks.length === 1 ? 'check' : 'checks'})`);
774
+ logDebug(TAG, `Repository: ${repositoryPath}`);
775
+ if (options.isLocalClaude && options.budgetLimits) {
776
+ logWarn(TAG, 'Budget limits in local-Claude mode apply to equivalent API cost, not subscription usage.');
777
+ }
778
+ // Use pre-analyzed repository info if provided, otherwise analyze here
779
+ let repositoryInfo;
780
+ if (options.repositoryInfo) {
781
+ repositoryInfo = options.repositoryInfo;
782
+ }
783
+ else {
784
+ const repoAnalysis = await analyzeRepository(repositoryPath);
785
+ repositoryInfo = repoAnalysis.repository;
786
+ }
787
+ const allCheckSummaries = [];
788
+ const allIssues = [];
789
+ let budgetAborted = false;
790
+ let budgetAbortReason;
791
+ // Cost / budget tracking spans all checks in the scan.
792
+ const costTracker = createCostTracker(options);
793
+ // Track all models used during the scan
794
+ const modelsUsed = new Set();
795
+ if (modelName)
796
+ modelsUsed.add(modelName);
797
+ // Execute checks sequentially
798
+ for (let ci = 0; ci < checks.length; ci++) {
799
+ const { check, details } = checks[ci];
800
+ const checkMetadata = {
801
+ severity: check.severity,
802
+ confidence: check.confidence,
803
+ };
804
+ // Apply per-check model override if specified
805
+ const previousModel = applyPerCheckModel(check, agentProvider, modelName);
806
+ if (check.model)
807
+ modelsUsed.add(check.model);
808
+ try {
809
+ const { summary: checkSummary, issues } = await executeSingleCheck(check, details.name, details.content, repositoryPath, agentProvider, costTracker, checkMetadata, concurrency, configDir, genericPrompt, diffRef, diffFile, openantAvailable);
810
+ allCheckSummaries.push(checkSummary);
811
+ allIssues.push(...issues);
812
+ }
813
+ catch (err) {
814
+ if (err instanceof BudgetExceededError) {
815
+ budgetAborted = true;
816
+ budgetAbortReason = err.message;
817
+ logProgress(TAG, `Budget exceeded during check "${check.id}": ${err.message}`);
818
+ allCheckSummaries.push({
819
+ checkId: check.id,
820
+ checkName: details.name,
821
+ status: 'ERROR',
822
+ issuesFound: 0,
823
+ executionTime: 0,
824
+ error: `Budget exceeded: ${err.message}`,
825
+ });
826
+ for (let ri = ci + 1; ri < checks.length; ri++) {
827
+ const remaining = checks[ri];
828
+ logProgress(TAG, `Skipping check "${remaining.check.id}" due to budget abort`);
829
+ allCheckSummaries.push({
830
+ checkId: remaining.check.id,
831
+ checkName: remaining.details.name,
832
+ status: 'ERROR',
833
+ issuesFound: 0,
834
+ executionTime: 0,
835
+ error: `Scan aborted: budget limit exceeded`,
836
+ });
837
+ }
838
+ restoreModel(agentProvider, previousModel);
839
+ break;
840
+ }
841
+ if (err instanceof FatalProviderError) {
842
+ // Record the failing check as ERROR
843
+ logProgress(TAG, `Fatal error during check "${check.id}": ${err.message}`);
844
+ allCheckSummaries.push({
845
+ checkId: check.id,
846
+ checkName: details.name,
847
+ status: 'ERROR',
848
+ issuesFound: 0,
849
+ executionTime: 0,
850
+ error: err.message,
851
+ });
852
+ // Record remaining checks as ERROR (aborted)
853
+ for (let ri = ci + 1; ri < checks.length; ri++) {
854
+ const remaining = checks[ri];
855
+ logProgress(TAG, `Skipping check "${remaining.check.id}" due to fatal error`);
856
+ allCheckSummaries.push({
857
+ checkId: remaining.check.id,
858
+ checkName: remaining.details.name,
859
+ status: 'ERROR',
860
+ issuesFound: 0,
861
+ executionTime: 0,
862
+ error: `Scan aborted: ${err.message}`,
863
+ });
864
+ }
865
+ logProgress(TAG, `Scan aborted due to fatal error: ${err.message}`);
866
+ break;
867
+ }
868
+ // Non-fatal errors should not reach here (executeSingleCheck catches them),
869
+ // but handle gracefully just in case.
870
+ throw err;
871
+ }
872
+ finally {
873
+ // Restore the global model after per-check override
874
+ restoreModel(agentProvider, previousModel);
875
+ }
876
+ }
877
+ const endTime = new Date();
878
+ const executionTime = endTime.getTime() - startTime.getTime();
879
+ const summary = {
880
+ totalChecks: allCheckSummaries.length,
881
+ passedChecks: allCheckSummaries.filter((c) => c.status === 'PASS').length,
882
+ failedChecks: allCheckSummaries.filter((c) => c.status === 'FAIL').length,
883
+ flaggedChecks: allCheckSummaries.filter((c) => c.status === 'FLAG').length,
884
+ errorChecks: allCheckSummaries.filter((c) => c.status === 'ERROR').length,
885
+ totalIssues: allIssues.length,
886
+ };
887
+ logProgress(TAG, `Scan completed in ${scanTimer.elapsedStr()}`);
888
+ // Aggregate token usage across all checks
889
+ const aggregateTokenUsage = sumTokenUsage(allCheckSummaries.map((c) => c.tokenUsage));
890
+ const results = {
891
+ scanId,
892
+ timestamp: startTime.toISOString(),
893
+ version,
894
+ repository: repositoryInfo,
895
+ issues: allIssues,
896
+ checks: allCheckSummaries,
897
+ summary,
898
+ executionTime,
899
+ startTime: startTime.toISOString(),
900
+ endTime: endTime.toISOString(),
901
+ agentProvider: agentProvider
902
+ ? { name: agentProviderName ?? DEFAULT_PROVIDER_NAME, models: modelsUsed.size > 0 ? [...modelsUsed] : [DEFAULT_MODEL] }
903
+ : { name: 'none', models: [] },
904
+ tokenUsage: aggregateTokenUsage,
905
+ };
906
+ // Attach cost metadata when pricing was provided.
907
+ if (options.pricing) {
908
+ results.metadata = {
909
+ ...(results.metadata ?? {}),
910
+ cost: {
911
+ totalCostUsd: costTracker.totalCostUsd,
912
+ currency: options.pricing.currency ?? 'USD',
913
+ },
914
+ };
915
+ }
916
+ return {
917
+ results,
918
+ totalCostUsd: costTracker.totalCostUsd,
919
+ currency: options.pricing?.currency ?? 'USD',
920
+ models: [...modelsUsed],
921
+ costSource: costTracker.lastCostSource,
922
+ costReportedBy: costTracker.lastCostReportedBy,
923
+ costCoveredBySubscription: costTracker.lastCostCoveredBySubscription,
924
+ budgetAborted,
925
+ budgetAbortReason,
926
+ };
927
+ }
928
+ //# sourceMappingURL=scan-runner.js.map