@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.
- package/LICENSE +661 -0
- package/README.md +127 -0
- package/assets/img/aghastowaspcaption.png +0 -0
- package/assets/txt/logo.txt +52 -0
- package/config/pricing.json +42 -0
- package/config/prompts/false-positive-validation.md +78 -0
- package/config/prompts/general-vuln-discovery.md +99 -0
- package/config/prompts/generic-instructions.md +59 -0
- package/config/prompts/test-cheaper-instructions.md +57 -0
- package/dist/budget.d.ts +62 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +137 -0
- package/dist/budget.js.map +1 -0
- package/dist/build-config.d.ts +15 -0
- package/dist/build-config.d.ts.map +1 -0
- package/dist/build-config.js +568 -0
- package/dist/build-config.js.map +1 -0
- package/dist/check-library.d.ts +88 -0
- package/dist/check-library.d.ts.map +1 -0
- package/dist/check-library.js +461 -0
- package/dist/check-library.js.map +1 -0
- package/dist/check-types.d.ts +35 -0
- package/dist/check-types.d.ts.map +1 -0
- package/dist/check-types.js +66 -0
- package/dist/check-types.js.map +1 -0
- package/dist/claude-code-provider.d.ts +29 -0
- package/dist/claude-code-provider.d.ts.map +1 -0
- package/dist/claude-code-provider.js +347 -0
- package/dist/claude-code-provider.js.map +1 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +109 -0
- package/dist/cli.js.map +1 -0
- package/dist/colors.d.ts +7 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +18 -0
- package/dist/colors.js.map +1 -0
- package/dist/cost-calculator.d.ts +80 -0
- package/dist/cost-calculator.d.ts.map +1 -0
- package/dist/cost-calculator.js +226 -0
- package/dist/cost-calculator.js.map +1 -0
- package/dist/defaults.d.ts +21 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +21 -0
- package/dist/defaults.js.map +1 -0
- package/dist/diff-filter.d.ts +50 -0
- package/dist/diff-filter.d.ts.map +1 -0
- package/dist/diff-filter.js +130 -0
- package/dist/diff-filter.js.map +1 -0
- package/dist/diff-parser.d.ts +40 -0
- package/dist/diff-parser.d.ts.map +1 -0
- package/dist/diff-parser.js +112 -0
- package/dist/diff-parser.js.map +1 -0
- package/dist/diff-unit-matcher.d.ts +53 -0
- package/dist/diff-unit-matcher.d.ts.map +1 -0
- package/dist/diff-unit-matcher.js +156 -0
- package/dist/diff-unit-matcher.js.map +1 -0
- package/dist/discoveries/openant-discovery.d.ts +10 -0
- package/dist/discoveries/openant-discovery.d.ts.map +1 -0
- package/dist/discoveries/openant-discovery.js +56 -0
- package/dist/discoveries/openant-discovery.js.map +1 -0
- package/dist/discoveries/sarif-discovery.d.ts +9 -0
- package/dist/discoveries/sarif-discovery.d.ts.map +1 -0
- package/dist/discoveries/sarif-discovery.js +56 -0
- package/dist/discoveries/sarif-discovery.js.map +1 -0
- package/dist/discoveries/semgrep-discovery.d.ts +9 -0
- package/dist/discoveries/semgrep-discovery.d.ts.map +1 -0
- package/dist/discoveries/semgrep-discovery.js +61 -0
- package/dist/discoveries/semgrep-discovery.js.map +1 -0
- package/dist/discovery.d.ts +100 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +49 -0
- package/dist/discovery.js.map +1 -0
- package/dist/error-codes.d.ts +46 -0
- package/dist/error-codes.d.ts.map +1 -0
- package/dist/error-codes.js +70 -0
- package/dist/error-codes.js.map +1 -0
- package/dist/formatters/index.d.ts +10 -0
- package/dist/formatters/index.d.ts.map +1 -0
- package/dist/formatters/index.js +23 -0
- package/dist/formatters/index.js.map +1 -0
- package/dist/formatters/json-formatter.d.ts +11 -0
- package/dist/formatters/json-formatter.d.ts.map +1 -0
- package/dist/formatters/json-formatter.js +11 -0
- package/dist/formatters/json-formatter.js.map +1 -0
- package/dist/formatters/sarif-formatter.d.ts +18 -0
- package/dist/formatters/sarif-formatter.d.ts.map +1 -0
- package/dist/formatters/sarif-formatter.js +103 -0
- package/dist/formatters/sarif-formatter.js.map +1 -0
- package/dist/formatters/types.d.ts +11 -0
- package/dist/formatters/types.d.ts.map +1 -0
- package/dist/formatters/types.js +6 -0
- package/dist/formatters/types.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +754 -0
- package/dist/index.js.map +1 -0
- package/dist/logging.d.ts +140 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +344 -0
- package/dist/logging.js.map +1 -0
- package/dist/mock-agent-provider.d.ts +24 -0
- package/dist/mock-agent-provider.d.ts.map +1 -0
- package/dist/mock-agent-provider.js +38 -0
- package/dist/mock-agent-provider.js.map +1 -0
- package/dist/mock-ai-provider.d.ts +21 -0
- package/dist/mock-ai-provider.d.ts.map +1 -0
- package/dist/mock-ai-provider.js +31 -0
- package/dist/mock-ai-provider.js.map +1 -0
- package/dist/new-check.d.ts +13 -0
- package/dist/new-check.d.ts.map +1 -0
- package/dist/new-check.js +515 -0
- package/dist/new-check.js.map +1 -0
- package/dist/openant-loader.d.ts +105 -0
- package/dist/openant-loader.d.ts.map +1 -0
- package/dist/openant-loader.js +135 -0
- package/dist/openant-loader.js.map +1 -0
- package/dist/openant-runner.d.ts +30 -0
- package/dist/openant-runner.d.ts.map +1 -0
- package/dist/openant-runner.js +113 -0
- package/dist/openant-runner.js.map +1 -0
- package/dist/opencode-provider.d.ts +63 -0
- package/dist/opencode-provider.d.ts.map +1 -0
- package/dist/opencode-provider.js +719 -0
- package/dist/opencode-provider.js.map +1 -0
- package/dist/prompt-template.d.ts +12 -0
- package/dist/prompt-template.d.ts.map +1 -0
- package/dist/prompt-template.js +36 -0
- package/dist/prompt-template.js.map +1 -0
- package/dist/provider-registry.d.ts +15 -0
- package/dist/provider-registry.d.ts.map +1 -0
- package/dist/provider-registry.js +29 -0
- package/dist/provider-registry.js.map +1 -0
- package/dist/provider-utils.d.ts +52 -0
- package/dist/provider-utils.d.ts.map +1 -0
- package/dist/provider-utils.js +40 -0
- package/dist/provider-utils.js.map +1 -0
- package/dist/repository-analyzer.d.ts +68 -0
- package/dist/repository-analyzer.d.ts.map +1 -0
- package/dist/repository-analyzer.js +238 -0
- package/dist/repository-analyzer.js.map +1 -0
- package/dist/response-parser.d.ts +14 -0
- package/dist/response-parser.d.ts.map +1 -0
- package/dist/response-parser.js +111 -0
- package/dist/response-parser.js.map +1 -0
- package/dist/runtime-config.d.ts +15 -0
- package/dist/runtime-config.d.ts.map +1 -0
- package/dist/runtime-config.js +187 -0
- package/dist/runtime-config.js.map +1 -0
- package/dist/sarif-parser.d.ts +20 -0
- package/dist/sarif-parser.d.ts.map +1 -0
- package/dist/sarif-parser.js +76 -0
- package/dist/sarif-parser.js.map +1 -0
- package/dist/scan-history.d.ts +82 -0
- package/dist/scan-history.d.ts.map +1 -0
- package/dist/scan-history.js +127 -0
- package/dist/scan-history.js.map +1 -0
- package/dist/scan-runner.d.ts +122 -0
- package/dist/scan-runner.d.ts.map +1 -0
- package/dist/scan-runner.js +928 -0
- package/dist/scan-runner.js.map +1 -0
- package/dist/semgrep-runner.d.ts +25 -0
- package/dist/semgrep-runner.d.ts.map +1 -0
- package/dist/semgrep-runner.js +100 -0
- package/dist/semgrep-runner.js.map +1 -0
- package/dist/snippet-extractor.d.ts +25 -0
- package/dist/snippet-extractor.d.ts.map +1 -0
- package/dist/snippet-extractor.js +56 -0
- package/dist/snippet-extractor.js.map +1 -0
- package/dist/stats.d.ts +11 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +197 -0
- package/dist/stats.js.map +1 -0
- package/dist/types.d.ts +321 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- 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
|