@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI entry point for aghast.
|
|
3
|
+
* Usage: aghast scan <repository-path> --config-dir <path> [options]
|
|
4
|
+
*/
|
|
5
|
+
import 'dotenv/config';
|
|
6
|
+
import { readFile, writeFile, stat, mkdir, readdir } from 'node:fs/promises';
|
|
7
|
+
import { resolve, dirname } from 'node:path';
|
|
8
|
+
import { runMultiScanWithCost } from './scan-runner.js';
|
|
9
|
+
import { loadDefaultPricing, mergePricing, formatCostSourceLabel } from './cost-calculator.js';
|
|
10
|
+
import { saveScanRecord, queryScanHistory } from './scan-history.js';
|
|
11
|
+
import { createProviderByName, getProviderNames, DEFAULT_PROVIDER_NAME } from './provider-registry.js';
|
|
12
|
+
import { loadCheckRegistry, discoverCheckFolders, resolveChecks, filterChecksForRepository, validateCheck, loadCheckDetails, } from './check-library.js';
|
|
13
|
+
import { analyzeRepository } from './repository-analyzer.js';
|
|
14
|
+
import { loadRuntimeConfig } from './runtime-config.js';
|
|
15
|
+
import { logProgress, logDebug, setLogLevel, createTimer, isValidLogLevel, initFileHandler, closeAllHandlers, getAvailableLogTypes } from './logging.js';
|
|
16
|
+
import { MOCK_MODEL_NAME, DEFAULT_MODEL } from './types.js';
|
|
17
|
+
import { getFormatter } from './formatters/index.js';
|
|
18
|
+
import { verifySemgrepInstalled } from './semgrep-runner.js';
|
|
19
|
+
import { verifyOpenAntInstalled } from './openant-runner.js';
|
|
20
|
+
import { MockAgentProvider } from './mock-agent-provider.js';
|
|
21
|
+
import { ERROR_CODES, formatError, formatFatalError } from './error-codes.js';
|
|
22
|
+
import { DEFAULT_OUTPUT_FORMAT, DEFAULT_LOG_LEVEL, DEFAULT_LOG_TYPE } from './defaults.js';
|
|
23
|
+
import { colorStatus } from './colors.js';
|
|
24
|
+
import { getCheckType } from './check-types.js';
|
|
25
|
+
import { getDiscovery, getRegisteredDiscoveries } from './discovery.js';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
const TAG = 'aghast';
|
|
28
|
+
async function createMockProvider() {
|
|
29
|
+
// AGHAST_MOCK_AI='true' → default empty response; AGHAST_MOCK_AI=<path> → read from that file
|
|
30
|
+
const mockAiValue = process.env.AGHAST_MOCK_AI;
|
|
31
|
+
let rawResponse = '{"issues": []}';
|
|
32
|
+
if (mockAiValue && mockAiValue !== 'true') {
|
|
33
|
+
try {
|
|
34
|
+
rawResponse = await readFile(resolve(mockAiValue), 'utf-8');
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new Error(`Failed to read AGHAST_MOCK_AI response file: ${mockAiValue}`, { cause: err });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Optional mock token usage for testing cost/budget pipelines.
|
|
41
|
+
// Format: AGHAST_MOCK_TOKENS="<input>,<output>" (e.g. "1000,500")
|
|
42
|
+
// When AGHAST_LOCAL_CLAUDE=true, inject a mock reportedCost so that the
|
|
43
|
+
// coveredBySubscription path (banner "equivalent", label) is exercisable in tests.
|
|
44
|
+
let tokenUsage;
|
|
45
|
+
const mockTokensRaw = process.env.AGHAST_MOCK_TOKENS;
|
|
46
|
+
if (mockTokensRaw) {
|
|
47
|
+
const parts = mockTokensRaw.split(',').map((s) => Number(s.trim()));
|
|
48
|
+
if (parts.length === 2 && parts.every((n) => Number.isFinite(n) && n >= 0)) {
|
|
49
|
+
const useLocalClaude = process.env.AGHAST_LOCAL_CLAUDE === 'true';
|
|
50
|
+
tokenUsage = {
|
|
51
|
+
inputTokens: parts[0],
|
|
52
|
+
outputTokens: parts[1],
|
|
53
|
+
totalTokens: parts[0] + parts[1],
|
|
54
|
+
...(useLocalClaude ? {
|
|
55
|
+
reportedCost: {
|
|
56
|
+
amountUsd: (parts[0] + parts[1]) / 1_000_000,
|
|
57
|
+
source: 'claude-agent-sdk',
|
|
58
|
+
coveredBySubscription: true,
|
|
59
|
+
},
|
|
60
|
+
} : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const provider = new MockAgentProvider({ rawResponse, tokenUsage });
|
|
65
|
+
await provider.initialize({});
|
|
66
|
+
return provider;
|
|
67
|
+
}
|
|
68
|
+
const SCAN_HELP = `Usage: aghast scan <repo-path> --config-dir <path> [options]
|
|
69
|
+
|
|
70
|
+
Run security checks against a repository.
|
|
71
|
+
|
|
72
|
+
Arguments:
|
|
73
|
+
<repo-path> Path to the repository to scan
|
|
74
|
+
|
|
75
|
+
General options:
|
|
76
|
+
--config-dir <path> Config directory containing checks-config.json,
|
|
77
|
+
checks/ folder, and optionally runtime-config.json.
|
|
78
|
+
Required unless AGHAST_CONFIG_DIR is set.
|
|
79
|
+
--output <path> Output file path for results
|
|
80
|
+
(default: <repo-path>/security_checks_results.<ext>)
|
|
81
|
+
--output-format json|sarif Output format (default: json)
|
|
82
|
+
--fail-on-check-failure Exit with code 1 if any check FAILs or ERRORs
|
|
83
|
+
--debug Shorthand for --log-level debug
|
|
84
|
+
--log-level <level> Console log level: error, warn, info, debug, trace
|
|
85
|
+
(default: info)
|
|
86
|
+
--log-file <path> Write all log output to a file (always at trace level
|
|
87
|
+
unless overridden by --log-type)
|
|
88
|
+
--log-type <type> Log file handler type (default: file).
|
|
89
|
+
Available types: file
|
|
90
|
+
--model <model> AI model override (e.g. claude-sonnet-4-20250514)
|
|
91
|
+
--agent-provider <name> Agent provider name (default: claude-code)
|
|
92
|
+
--generic-prompt <file> Generic prompt template filename in prompts/ dir
|
|
93
|
+
--diff-ref <ref> Git ref to diff against (e.g. HEAD~1, main, SHA).
|
|
94
|
+
Auto-activates diff filtering on every check whose
|
|
95
|
+
discovery supports it, unless the check opts out
|
|
96
|
+
via checkTarget.diffFilter: false.
|
|
97
|
+
--diff-file <path> Path to pre-generated unified diff file
|
|
98
|
+
(alternative to --diff-ref)
|
|
99
|
+
--budget-limit-cost <usd> Abort the scan when accumulated cost exceeds this
|
|
100
|
+
USD value. Warns at 80%, aborts at 100%
|
|
101
|
+
--budget-limit-tokens <n> Abort the scan when accumulated tokens exceed n.
|
|
102
|
+
Warns at 80%, aborts at 100%
|
|
103
|
+
|
|
104
|
+
Environment variables:
|
|
105
|
+
ANTHROPIC_API_KEY API key for Claude (required for AI-based checks)
|
|
106
|
+
AGHAST_CONFIG_DIR Default config directory (CLI --config-dir takes precedence)
|
|
107
|
+
AGHAST_AI_MODEL AI model override (CLI --model takes precedence)
|
|
108
|
+
AGHAST_GENERIC_PROMPT Generic prompt template filename (CLI --generic-prompt takes precedence)
|
|
109
|
+
AGHAST_DEBUG Set to "true" to enable debug output (same as --debug)
|
|
110
|
+
AGHAST_LOG_LEVEL Console log level (CLI --log-level takes precedence)
|
|
111
|
+
AGHAST_LOG_FILE Log file path (CLI --log-file takes precedence)
|
|
112
|
+
AGHAST_LOG_TYPE Log file handler type (CLI --log-type takes precedence)
|
|
113
|
+
AGHAST_DIFF_REF Git ref to diff against (CLI --diff-ref takes precedence)
|
|
114
|
+
|
|
115
|
+
Examples:
|
|
116
|
+
aghast scan ./my-repo --config-dir ./my-checks
|
|
117
|
+
aghast scan ./my-repo --config-dir ./my-checks --output results.sarif --output-format sarif
|
|
118
|
+
aghast scan ./my-repo --config-dir ./my-checks --fail-on-check-failure --debug
|
|
119
|
+
aghast scan ./my-repo --config-dir ./my-checks --log-file scan.log --log-level warn
|
|
120
|
+
aghast scan ./my-repo --config-dir ./my-checks --model claude-sonnet-4-20250514`;
|
|
121
|
+
function parseArgs(args) {
|
|
122
|
+
if (args.length < 1 || args[0] === '--help' || args[0] === '-h') {
|
|
123
|
+
console.log(SCAN_HELP);
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
126
|
+
// First positional arg is repo-path
|
|
127
|
+
const firstArg = args[0];
|
|
128
|
+
const repositoryPath = firstArg && !firstArg.startsWith('--') ? resolve(firstArg) : undefined;
|
|
129
|
+
const startIdx = repositoryPath ? 1 : 0;
|
|
130
|
+
let configDir;
|
|
131
|
+
let outputFormat;
|
|
132
|
+
let outputPath;
|
|
133
|
+
const failOnCheckFailure = args.includes('--fail-on-check-failure');
|
|
134
|
+
const debug = args.includes('--debug');
|
|
135
|
+
let logLevel;
|
|
136
|
+
let logFile;
|
|
137
|
+
let logType;
|
|
138
|
+
let runtimeConfigPath;
|
|
139
|
+
let model;
|
|
140
|
+
let agentProvider;
|
|
141
|
+
let genericPrompt;
|
|
142
|
+
let diffRef;
|
|
143
|
+
let diffFile;
|
|
144
|
+
let budgetLimitCost;
|
|
145
|
+
let budgetLimitTokens;
|
|
146
|
+
for (let i = startIdx; i < args.length; i++) {
|
|
147
|
+
switch (args[i]) {
|
|
148
|
+
case '--config-dir': {
|
|
149
|
+
configDir = args[i + 1];
|
|
150
|
+
if (!configDir) {
|
|
151
|
+
console.error(formatError(ERROR_CODES.E1001, '--config-dir requires a path argument'));
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
configDir = resolve(configDir);
|
|
155
|
+
i++;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case '--output': {
|
|
159
|
+
outputPath = args[i + 1];
|
|
160
|
+
if (!outputPath) {
|
|
161
|
+
console.error(formatError(ERROR_CODES.E1001, '--output requires a path argument'));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
outputPath = resolve(outputPath);
|
|
165
|
+
i++;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case '--output-format': {
|
|
169
|
+
outputFormat = args[i + 1];
|
|
170
|
+
if (!outputFormat) {
|
|
171
|
+
console.error(formatError(ERROR_CODES.E1001, '--output-format requires a format argument'));
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
i++;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case '--runtime-config': {
|
|
178
|
+
runtimeConfigPath = args[i + 1];
|
|
179
|
+
if (!runtimeConfigPath) {
|
|
180
|
+
console.error(formatError(ERROR_CODES.E1001, '--runtime-config requires a path argument'));
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
runtimeConfigPath = resolve(runtimeConfigPath);
|
|
184
|
+
i++;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case '--model': {
|
|
188
|
+
model = args[i + 1];
|
|
189
|
+
if (!model) {
|
|
190
|
+
console.error(formatError(ERROR_CODES.E1001, '--model requires a model name argument'));
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
i++;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case '--agent-provider': {
|
|
197
|
+
agentProvider = args[i + 1];
|
|
198
|
+
if (!agentProvider) {
|
|
199
|
+
console.error(formatError(ERROR_CODES.E1001, '--agent-provider requires a provider name argument'));
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
i++;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case '--generic-prompt': {
|
|
206
|
+
genericPrompt = args[i + 1];
|
|
207
|
+
if (!genericPrompt) {
|
|
208
|
+
console.error(formatError(ERROR_CODES.E1001, '--generic-prompt requires a filename argument'));
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
i++;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case '--log-level': {
|
|
215
|
+
logLevel = args[i + 1];
|
|
216
|
+
if (!logLevel) {
|
|
217
|
+
console.error(formatError(ERROR_CODES.E1001, '--log-level requires a level argument (error, warn, info, debug, trace)'));
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
i++;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case '--log-file': {
|
|
224
|
+
logFile = args[i + 1];
|
|
225
|
+
if (!logFile) {
|
|
226
|
+
console.error(formatError(ERROR_CODES.E1001, '--log-file requires a path argument'));
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
logFile = resolve(logFile);
|
|
230
|
+
i++;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case '--log-type': {
|
|
234
|
+
logType = args[i + 1];
|
|
235
|
+
if (!logType) {
|
|
236
|
+
console.error(formatError(ERROR_CODES.E1001, '--log-type requires a type argument'));
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
i++;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case '--diff-ref': {
|
|
243
|
+
diffRef = args[i + 1];
|
|
244
|
+
if (!diffRef) {
|
|
245
|
+
console.error(formatError(ERROR_CODES.E1001, '--diff-ref requires a git ref argument'));
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
i++;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
case '--diff-file': {
|
|
252
|
+
diffFile = args[i + 1];
|
|
253
|
+
if (!diffFile) {
|
|
254
|
+
console.error(formatError(ERROR_CODES.E1001, '--diff-file requires a path argument'));
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
diffFile = resolve(diffFile);
|
|
258
|
+
i++;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
case '--budget-limit-cost': {
|
|
262
|
+
const raw = args[i + 1];
|
|
263
|
+
if (!raw) {
|
|
264
|
+
console.error(formatError(ERROR_CODES.E1001, '--budget-limit-cost requires a number argument (USD)'));
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
const n = Number(raw);
|
|
268
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
269
|
+
console.error(formatError(ERROR_CODES.E1001, `--budget-limit-cost must be a positive number (got "${raw}")`));
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
budgetLimitCost = n;
|
|
273
|
+
i++;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case '--budget-limit-tokens': {
|
|
277
|
+
const raw = args[i + 1];
|
|
278
|
+
if (!raw) {
|
|
279
|
+
console.error(formatError(ERROR_CODES.E1001, '--budget-limit-tokens requires a number argument'));
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
const n = Number(raw);
|
|
283
|
+
if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) {
|
|
284
|
+
console.error(formatError(ERROR_CODES.E1001, `--budget-limit-tokens must be a positive integer (got "${raw}")`));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
budgetLimitTokens = n;
|
|
288
|
+
i++;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
// --fail-on-check-failure and --debug are handled above via includes()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
repositoryPath, configDir, outputPath, outputFormat,
|
|
296
|
+
failOnCheckFailure, debug, logLevel, logFile, logType,
|
|
297
|
+
runtimeConfigPath, model, agentProvider, genericPrompt,
|
|
298
|
+
diffRef, diffFile,
|
|
299
|
+
budgetLimitCost, budgetLimitTokens,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
async function createProvider(useMock, agentProviderName, modelOverride) {
|
|
303
|
+
if (useMock) {
|
|
304
|
+
logProgress(TAG, `Mock provider enabled via AGHAST_MOCK_AI=${process.env.AGHAST_MOCK_AI}`);
|
|
305
|
+
const provider = await createMockProvider();
|
|
306
|
+
// Honour --model in mock mode so cost-calculation tests can target a known
|
|
307
|
+
// pricing entry. Defaults to MOCK_MODEL_NAME.
|
|
308
|
+
const effectiveModel = modelOverride ?? MOCK_MODEL_NAME;
|
|
309
|
+
provider.setModel?.(effectiveModel);
|
|
310
|
+
return { provider, modelName: effectiveModel };
|
|
311
|
+
}
|
|
312
|
+
const provider = createProviderByName(agentProviderName);
|
|
313
|
+
await provider.initialize({
|
|
314
|
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
315
|
+
model: modelOverride,
|
|
316
|
+
});
|
|
317
|
+
const modelName = provider.getModelName?.() ?? DEFAULT_MODEL;
|
|
318
|
+
return { provider, modelName };
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Validate that the config directory has the required structure.
|
|
322
|
+
* Returns early with a clear error message if anything is missing.
|
|
323
|
+
*/
|
|
324
|
+
async function validateConfigDir(configDir) {
|
|
325
|
+
// Check checks-config.json exists
|
|
326
|
+
const registryPath = resolve(configDir, 'checks-config.json');
|
|
327
|
+
try {
|
|
328
|
+
await stat(registryPath);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
console.error(formatError(ERROR_CODES.E2002, `Config directory is missing checks-config.json: ${registryPath}`));
|
|
332
|
+
console.error(` Use 'aghast new-check --config-dir ${configDir}' to bootstrap a config directory.`);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
// Check checks/ directory exists
|
|
336
|
+
const checksPath = resolve(configDir, 'checks');
|
|
337
|
+
try {
|
|
338
|
+
const checksStat = await stat(checksPath);
|
|
339
|
+
if (!checksStat.isDirectory()) {
|
|
340
|
+
console.error(formatError(ERROR_CODES.E2002, `${checksPath} exists but is not a directory`));
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
console.error(formatError(ERROR_CODES.E2002, `Config directory is missing checks/ folder: ${checksPath}`));
|
|
346
|
+
console.error(` Use 'aghast new-check --config-dir ${configDir}' to add checks.`);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
// Check that checks/ has at least one subfolder
|
|
350
|
+
const entries = await readdir(checksPath);
|
|
351
|
+
if (entries.length === 0) {
|
|
352
|
+
console.error(formatError(ERROR_CODES.E2003, `No checks found in ${checksPath}`));
|
|
353
|
+
console.error(` Use 'aghast new-check --config-dir ${configDir}' to add checks.`);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
export async function runScan(args) {
|
|
358
|
+
const globalTimer = createTimer();
|
|
359
|
+
const parsed = parseArgs(args);
|
|
360
|
+
// --config-dir is required (CLI flag > AGHAST_CONFIG_DIR env var)
|
|
361
|
+
const rawConfigDir = parsed.configDir || process.env.AGHAST_CONFIG_DIR;
|
|
362
|
+
if (!rawConfigDir) {
|
|
363
|
+
console.error(formatError(ERROR_CODES.E2001, "--config-dir is required (or set AGHAST_CONFIG_DIR). Use 'aghast new-check --config-dir <path>' to create a config directory."));
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
const configDir = resolve(rawConfigDir);
|
|
367
|
+
// Validate config directory structure
|
|
368
|
+
await validateConfigDir(configDir);
|
|
369
|
+
// Load runtime configuration
|
|
370
|
+
let runtimeConfig;
|
|
371
|
+
try {
|
|
372
|
+
runtimeConfig = await loadRuntimeConfig(configDir, parsed.runtimeConfigPath);
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
console.error(formatError(ERROR_CODES.E2005, err instanceof Error ? err.message : String(err)));
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
// Resolve log level: --log-level > AGHAST_LOG_LEVEL > runtime config > --debug/AGHAST_DEBUG > default
|
|
379
|
+
const debug = parsed.debug || process.env.AGHAST_DEBUG === 'true';
|
|
380
|
+
const resolvedLogLevel = parsed.logLevel ?? (process.env.AGHAST_LOG_LEVEL || undefined) ?? runtimeConfig.logging?.level ?? (debug ? 'debug' : DEFAULT_LOG_LEVEL);
|
|
381
|
+
if (resolvedLogLevel !== 'silent' && !isValidLogLevel(resolvedLogLevel)) {
|
|
382
|
+
console.error(formatError(ERROR_CODES.E1001, `Invalid log level "${resolvedLogLevel}". Valid levels: error, warn, info, debug, trace`));
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
setLogLevel(resolvedLogLevel);
|
|
386
|
+
// Resolve log file: --log-file > AGHAST_LOG_FILE > runtime config
|
|
387
|
+
const resolvedLogFile = parsed.logFile ?? (process.env.AGHAST_LOG_FILE || undefined) ?? (runtimeConfig.logging?.logFile ? resolve(runtimeConfig.logging.logFile) : undefined);
|
|
388
|
+
if (resolvedLogFile) {
|
|
389
|
+
const resolvedLogType = parsed.logType ?? (process.env.AGHAST_LOG_TYPE || undefined) ?? runtimeConfig.logging?.logType ?? DEFAULT_LOG_TYPE;
|
|
390
|
+
const availableTypes = getAvailableLogTypes();
|
|
391
|
+
if (!availableTypes.includes(resolvedLogType)) {
|
|
392
|
+
console.error(formatError(ERROR_CODES.E1001, `Unknown log type "${resolvedLogType}". Available types: ${availableTypes.join(', ')}`));
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
initFileHandler(resolve(resolvedLogFile), resolvedLogType);
|
|
396
|
+
}
|
|
397
|
+
// Resolve repository path — required
|
|
398
|
+
if (!parsed.repositoryPath) {
|
|
399
|
+
console.error(formatError(ERROR_CODES.E1003, '<repo-path> is required'));
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
// Validate repository path exists and is a directory
|
|
403
|
+
try {
|
|
404
|
+
const repoStat = await stat(parsed.repositoryPath);
|
|
405
|
+
if (!repoStat.isDirectory()) {
|
|
406
|
+
console.error(formatError(ERROR_CODES.E4001, `Repository path is not a directory: ${parsed.repositoryPath}`));
|
|
407
|
+
process.exit(1);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
412
|
+
console.error(formatError(ERROR_CODES.E4001, `Repository path does not exist: ${parsed.repositoryPath}`));
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
// Resolve output format: CLI > runtime config > default
|
|
418
|
+
const resolvedOutputFormat = parsed.outputFormat ?? runtimeConfig.reporting?.outputFormat ?? DEFAULT_OUTPUT_FORMAT;
|
|
419
|
+
// Resolve formatter early — fail fast on unknown format
|
|
420
|
+
const formatter = getFormatter(resolvedOutputFormat);
|
|
421
|
+
// Treat AGHAST_MOCK_AI=false (or empty) as disabled; any other truthy value enables mock mode
|
|
422
|
+
const mockAiEnv = process.env.AGHAST_MOCK_AI;
|
|
423
|
+
const useMock = !!(mockAiEnv && mockAiEnv !== 'false');
|
|
424
|
+
// ─── Load and filter checks BEFORE AI validation ───
|
|
425
|
+
// Checks always live in <config-dir>/checks/
|
|
426
|
+
const checksDirs = [resolve(configDir, 'checks')];
|
|
427
|
+
// Resolve generic prompt: CLI > env > runtime config > default (handled in buildPrompt)
|
|
428
|
+
const genericPrompt = parsed.genericPrompt ?? process.env.AGHAST_GENERIC_PROMPT ?? runtimeConfig.genericPrompt;
|
|
429
|
+
logDebug(TAG, `Config dir: ${configDir}, checks dir: ${checksDirs[0]}`);
|
|
430
|
+
// Two-layer config loading
|
|
431
|
+
const registry = await loadCheckRegistry(configDir);
|
|
432
|
+
const checkFolders = await discoverCheckFolders(checksDirs);
|
|
433
|
+
if (checkFolders.size === 0) {
|
|
434
|
+
console.error(formatError(ERROR_CODES.E2003, `No valid checks found in ${checksDirs[0]}. Each check needs a <id>/<id>.json file.`));
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
const allChecks = await resolveChecks(registry, checkFolders);
|
|
438
|
+
// Analyze repository to get remote URL for check filtering
|
|
439
|
+
const effectiveRepoPath = parsed.repositoryPath;
|
|
440
|
+
const repoAnalysis = await analyzeRepository(effectiveRepoPath);
|
|
441
|
+
const repoUrl = repoAnalysis?.repository.remoteUrl ?? effectiveRepoPath;
|
|
442
|
+
const matchingChecks = filterChecksForRepository(allChecks, repoUrl);
|
|
443
|
+
logProgress(TAG, `Found ${matchingChecks.length} matching checks (of ${allChecks.length} total)`);
|
|
444
|
+
if (matchingChecks.length === 0) {
|
|
445
|
+
logProgress(TAG, 'No matching checks found for this repository');
|
|
446
|
+
}
|
|
447
|
+
// Validate and load check details
|
|
448
|
+
const checksWithDetails = [];
|
|
449
|
+
for (const check of matchingChecks) {
|
|
450
|
+
// instructionsFile is already absolute from resolveChecks — validate against ''
|
|
451
|
+
const validation = await validateCheck(check, '');
|
|
452
|
+
if (!validation.valid) {
|
|
453
|
+
logProgress(TAG, `Skipping invalid check "${check.id}": ${validation.errors.join(', ')}`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
// checkTarget rules already resolved by resolveChecks — no additional path resolution needed
|
|
457
|
+
// Checks that don't require instructions use synthetic details (unless they provided one optionally).
|
|
458
|
+
// Built-in analysis modes (false-positive-validation, general-vuln-discovery) also don't need instructions.
|
|
459
|
+
const builtInMode = check.checkTarget?.analysisMode === 'false-positive-validation'
|
|
460
|
+
|| check.checkTarget?.analysisMode === 'general-vuln-discovery';
|
|
461
|
+
if ((!getCheckType(check.checkTarget?.type).needsInstructions || builtInMode) && !check.instructionsFile) {
|
|
462
|
+
checksWithDetails.push({
|
|
463
|
+
check,
|
|
464
|
+
details: { id: check.id, name: check.name, overview: '', content: '' },
|
|
465
|
+
});
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const details = await loadCheckDetails(check, '');
|
|
469
|
+
// Fall back to JSON definition name if markdown has no ### heading
|
|
470
|
+
if (details.name === 'Unknown Check') {
|
|
471
|
+
details.name = check.name;
|
|
472
|
+
}
|
|
473
|
+
checksWithDetails.push({ check, details });
|
|
474
|
+
}
|
|
475
|
+
// ─── Validate --generic-prompt with mixed discovery types ───
|
|
476
|
+
if (genericPrompt) {
|
|
477
|
+
const discoveryTypes = new Set();
|
|
478
|
+
for (const c of checksWithDetails) {
|
|
479
|
+
const d = c.check.checkTarget?.discovery;
|
|
480
|
+
if (d)
|
|
481
|
+
discoveryTypes.add(d);
|
|
482
|
+
}
|
|
483
|
+
if (discoveryTypes.size > 1) {
|
|
484
|
+
console.error(formatError(ERROR_CODES.E2004, `--generic-prompt cannot be used when checks have different discovery types (found: ${[...discoveryTypes].join(', ')}). Each discovery type uses its own default generic prompt.`));
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// ─── Determine which prerequisites are needed ───
|
|
489
|
+
const needsAI = checksWithDetails.some(c => getCheckType(c.check.checkTarget?.type).needsAI);
|
|
490
|
+
const needsSemgrep = checksWithDetails.some(c => c.check.checkTarget?.discovery === 'semgrep');
|
|
491
|
+
const needsOpenant = checksWithDetails.some(c => c.check.checkTarget?.discovery === 'openant');
|
|
492
|
+
// ─── Resolve diff source ───
|
|
493
|
+
// Precedence: CLI --diff-ref/--diff-file > AGHAST_DIFF_REF > runtime config diffRef.
|
|
494
|
+
// (Check-level diffRef is applied per-check inside the scan runner.)
|
|
495
|
+
const resolvedDiffRef = parsed.diffRef ?? (process.env.AGHAST_DIFF_REF || undefined) ?? runtimeConfig.diffRef;
|
|
496
|
+
const resolvedDiffFile = parsed.diffFile;
|
|
497
|
+
// OpenAnt is needed for diff filtering. Required whenever a diff source is
|
|
498
|
+
// available and at least one check has a discovery that supports the filter
|
|
499
|
+
// and hasn't opted out.
|
|
500
|
+
const hasRuntimeDiffSource = resolvedDiffRef !== undefined || resolvedDiffFile !== undefined;
|
|
501
|
+
const hasAnyCheckLevelDiffRef = checksWithDetails.some(c => c.check.checkTarget?.diffRef !== undefined);
|
|
502
|
+
const diffSourceAvailable = hasRuntimeDiffSource || hasAnyCheckLevelDiffRef;
|
|
503
|
+
// Drive supportedDiscovery off the registry rather than a hardcoded list —
|
|
504
|
+
// new discoveries pick up their own behaviour via supportsDiffFilter without
|
|
505
|
+
// this gate having to be kept in sync.
|
|
506
|
+
const registeredDiscoveries = new Set(getRegisteredDiscoveries());
|
|
507
|
+
const needsDiffFilter = diffSourceAvailable && checksWithDetails.some(c => {
|
|
508
|
+
const ct = c.check.checkTarget;
|
|
509
|
+
if (!ct?.discovery || !registeredDiscoveries.has(ct.discovery))
|
|
510
|
+
return false;
|
|
511
|
+
const supportedDiscovery = getDiscovery(ct.discovery).supportsDiffFilter;
|
|
512
|
+
const optedIn = ct.diffFilter !== false;
|
|
513
|
+
const hasSource = hasRuntimeDiffSource || ct.diffRef !== undefined;
|
|
514
|
+
return supportedDiscovery && optedIn && hasSource;
|
|
515
|
+
});
|
|
516
|
+
// ─── Conditional agent provider setup ───
|
|
517
|
+
const agentProviderName = parsed.agentProvider ?? runtimeConfig.agentProvider?.name ?? DEFAULT_PROVIDER_NAME;
|
|
518
|
+
if (needsAI && !useMock) {
|
|
519
|
+
// Validate agent provider name before checking credentials (config errors before auth errors)
|
|
520
|
+
if (!getProviderNames().includes(agentProviderName)) {
|
|
521
|
+
console.error(formatError(ERROR_CODES.E3002, `Unknown agent provider "${agentProviderName}". Supported providers: ${getProviderNames().join(', ')}`));
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
// Validate provider-specific prerequisites (API keys, binaries, etc.)
|
|
525
|
+
try {
|
|
526
|
+
const tempProvider = createProviderByName(agentProviderName);
|
|
527
|
+
tempProvider.checkPrerequisites?.();
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
console.error(formatError(ERROR_CODES.E3001, err instanceof Error ? err.message : String(err)));
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// Resolve model precedence: CLI --model > env AGHAST_AI_MODEL > runtime config > default
|
|
535
|
+
const modelOverride = parsed.model ?? process.env.AGHAST_AI_MODEL ?? runtimeConfig.agentProvider?.model;
|
|
536
|
+
let provider;
|
|
537
|
+
let modelName;
|
|
538
|
+
if (needsAI) {
|
|
539
|
+
({ provider, modelName } = await createProvider(useMock, agentProviderName, modelOverride));
|
|
540
|
+
logProgress(TAG, `Using model: ${modelName}`);
|
|
541
|
+
}
|
|
542
|
+
// ─── Conditional Semgrep verification (needed for semgrep discovery) ───
|
|
543
|
+
if (needsSemgrep && !process.env.AGHAST_MOCK_SEMGREP) {
|
|
544
|
+
try {
|
|
545
|
+
await verifySemgrepInstalled();
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
console.error(formatError(ERROR_CODES.E5001, err instanceof Error ? err.message : String(err)));
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// ─── Conditional OpenAnt verification ───
|
|
553
|
+
// `openant` discovery is a hard requirement: without OpenAnt there are no
|
|
554
|
+
// targets. Diff filtering on other discoveries is a soft requirement: if
|
|
555
|
+
// OpenAnt is missing we fall back to a depth-0 filter (file+line overlap
|
|
556
|
+
// only, no call-graph flow) and log a clear warning so the mode is visible.
|
|
557
|
+
let openantAvailable = true;
|
|
558
|
+
if (needsOpenant && !process.env.AGHAST_OPENANT_DATASET) {
|
|
559
|
+
try {
|
|
560
|
+
await verifyOpenAntInstalled();
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
console.error(formatError(ERROR_CODES.E6001, err instanceof Error ? err.message : String(err)));
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
else if (needsDiffFilter && !process.env.AGHAST_OPENANT_DATASET) {
|
|
568
|
+
try {
|
|
569
|
+
await verifyOpenAntInstalled();
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
openantAvailable = false;
|
|
573
|
+
logProgress(TAG, 'OpenAnt is not installed — diff filter will run in depth-0 mode ' +
|
|
574
|
+
'(findings kept only if their file and line range appear in the diff; ' +
|
|
575
|
+
'direct callers/callees of changed code are NOT included). ' +
|
|
576
|
+
'Install OpenAnt (https://github.com/knostic/OpenAnt/) or set ' +
|
|
577
|
+
'AGHAST_OPENANT_DATASET to a prebuilt dataset for depth-1 filtering with call-graph flow.');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// ─── Pricing + budget setup ───
|
|
581
|
+
const defaultPricing = await loadDefaultPricing();
|
|
582
|
+
const pricing = mergePricing(defaultPricing, runtimeConfig.pricing);
|
|
583
|
+
const budgetLimits = (() => {
|
|
584
|
+
const cliCost = parsed.budgetLimitCost;
|
|
585
|
+
const cliTokens = parsed.budgetLimitTokens;
|
|
586
|
+
const cfg = runtimeConfig.budget;
|
|
587
|
+
if (cliCost === undefined && cliTokens === undefined && !cfg)
|
|
588
|
+
return undefined;
|
|
589
|
+
const out = {};
|
|
590
|
+
const perScan = { ...(cfg?.perScan ?? {}) };
|
|
591
|
+
if (cliCost !== undefined)
|
|
592
|
+
perScan.maxCostUsd = cliCost;
|
|
593
|
+
if (cliTokens !== undefined)
|
|
594
|
+
perScan.maxTokens = cliTokens;
|
|
595
|
+
if (perScan.maxCostUsd !== undefined || perScan.maxTokens !== undefined) {
|
|
596
|
+
out.perScan = perScan;
|
|
597
|
+
}
|
|
598
|
+
if (cfg?.perPeriod && cfg.perPeriod.window && cfg.perPeriod.maxCostUsd !== undefined) {
|
|
599
|
+
out.perPeriod = { window: cfg.perPeriod.window, maxCostUsd: cfg.perPeriod.maxCostUsd };
|
|
600
|
+
}
|
|
601
|
+
if (cfg?.thresholds)
|
|
602
|
+
out.thresholds = cfg.thresholds;
|
|
603
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
604
|
+
})();
|
|
605
|
+
// Pre-load history for period budget checks (skip when no period limit set)
|
|
606
|
+
let scanHistoryForBudget;
|
|
607
|
+
if (budgetLimits?.perPeriod) {
|
|
608
|
+
try {
|
|
609
|
+
scanHistoryForBudget = await queryScanHistory();
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
logDebug(TAG, `Could not load scan history for budget check: ${err instanceof Error ? err.message : String(err)}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const outcome = await runMultiScanWithCost({
|
|
617
|
+
repositoryPath: effectiveRepoPath,
|
|
618
|
+
checks: checksWithDetails,
|
|
619
|
+
agentProvider: provider,
|
|
620
|
+
modelName: needsAI ? modelName : undefined,
|
|
621
|
+
repositoryInfo: repoAnalysis?.repository,
|
|
622
|
+
agentProviderName: needsAI ? (useMock ? 'mock' : agentProviderName) : undefined,
|
|
623
|
+
configDir,
|
|
624
|
+
genericPrompt,
|
|
625
|
+
diffRef: resolvedDiffRef,
|
|
626
|
+
diffFile: resolvedDiffFile,
|
|
627
|
+
openantAvailable,
|
|
628
|
+
pricing,
|
|
629
|
+
budgetLimits,
|
|
630
|
+
scanHistory: scanHistoryForBudget,
|
|
631
|
+
isLocalClaude: process.env.AGHAST_LOCAL_CLAUDE === 'true',
|
|
632
|
+
});
|
|
633
|
+
const results = outcome.results;
|
|
634
|
+
// Resolve output path: --output flag > runtime config dir > default
|
|
635
|
+
let resolvedOutputPath;
|
|
636
|
+
if (parsed.outputPath) {
|
|
637
|
+
resolvedOutputPath = parsed.outputPath;
|
|
638
|
+
}
|
|
639
|
+
else if (runtimeConfig.reporting?.outputDirectory) {
|
|
640
|
+
const dir = resolve(runtimeConfig.reporting.outputDirectory);
|
|
641
|
+
resolvedOutputPath = resolve(dir, 'security_checks_results' + formatter.fileExtension);
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
resolvedOutputPath = resolve(effectiveRepoPath, 'security_checks_results' + formatter.fileExtension);
|
|
645
|
+
}
|
|
646
|
+
await mkdir(dirname(resolvedOutputPath), { recursive: true });
|
|
647
|
+
await writeFile(resolvedOutputPath, formatter.format(results), 'utf-8');
|
|
648
|
+
// Summary output
|
|
649
|
+
const statusIcon = results.summary.failedChecks > 0
|
|
650
|
+
? 'ISSUES DETECTED'
|
|
651
|
+
: results.summary.flaggedChecks > 0
|
|
652
|
+
? 'REVIEW REQUIRED'
|
|
653
|
+
: results.summary.errorChecks > 0
|
|
654
|
+
? 'SCAN ERROR'
|
|
655
|
+
: 'NO ISSUES DETECTED';
|
|
656
|
+
console.log('');
|
|
657
|
+
console.log('='.repeat(60));
|
|
658
|
+
console.log(`AGHAST Scan Complete: ${colorStatus(statusIcon)}`);
|
|
659
|
+
console.log('='.repeat(60));
|
|
660
|
+
console.log(` Total checks: ${results.summary.totalChecks}`);
|
|
661
|
+
console.log(` Passed: ${results.summary.passedChecks}`);
|
|
662
|
+
console.log(` Failed: ${results.summary.failedChecks}`);
|
|
663
|
+
console.log(` Flagged: ${results.summary.flaggedChecks}`);
|
|
664
|
+
console.log(` Errors: ${results.summary.errorChecks}`);
|
|
665
|
+
console.log(` Total issues: ${results.summary.totalIssues}`);
|
|
666
|
+
if (results.tokenUsage) {
|
|
667
|
+
const tu = results.tokenUsage;
|
|
668
|
+
const cacheSegments = [
|
|
669
|
+
tu.cacheReadInputTokens !== undefined ? ` cache-read: ${tu.cacheReadInputTokens.toLocaleString()}` : '',
|
|
670
|
+
tu.cacheCreationInputTokens !== undefined ? ` cache-write: ${tu.cacheCreationInputTokens.toLocaleString()}` : '',
|
|
671
|
+
tu.reasoningTokens !== undefined ? ` reasoning: ${tu.reasoningTokens.toLocaleString()}` : '',
|
|
672
|
+
].filter(Boolean).join(',');
|
|
673
|
+
const tokenDetail = `(in: ${tu.inputTokens.toLocaleString()}, out: ${tu.outputTokens.toLocaleString()}${cacheSegments ? ',' + cacheSegments : ''})`;
|
|
674
|
+
console.log(` Tokens: ${tu.totalTokens.toLocaleString()} ${tokenDetail}`);
|
|
675
|
+
}
|
|
676
|
+
if (outcome.totalCostUsd > 0 || outcome.costSource === 'estimated-unpriced') {
|
|
677
|
+
const label = formatCostSourceLabel(outcome.costSource, outcome.costReportedBy, outcome.costCoveredBySubscription);
|
|
678
|
+
const equiv = outcome.costCoveredBySubscription ? ' equivalent' : '';
|
|
679
|
+
console.log(` Cost: $${outcome.totalCostUsd.toFixed(4)}${equiv} ${label}`);
|
|
680
|
+
}
|
|
681
|
+
console.log(` Duration: ${globalTimer.elapsedStr()}`);
|
|
682
|
+
console.log(` Results: ${resolvedOutputPath}`);
|
|
683
|
+
console.log('='.repeat(60));
|
|
684
|
+
// Persist the scan record to history (best-effort — never blocks exit).
|
|
685
|
+
// We DO save the record even when the scan was aborted by budget: per-period
|
|
686
|
+
// budgets aggregate over historical scans, so the partial cost incurred
|
|
687
|
+
// before the abort must be recorded — otherwise a user could repeatedly
|
|
688
|
+
// hit the budget abort and still consume more total spend than the period
|
|
689
|
+
// limit allows.
|
|
690
|
+
try {
|
|
691
|
+
const record = {
|
|
692
|
+
scanId: results.scanId,
|
|
693
|
+
startedAt: results.startTime,
|
|
694
|
+
endedAt: results.endTime,
|
|
695
|
+
durationMs: results.executionTime,
|
|
696
|
+
repository: effectiveRepoPath,
|
|
697
|
+
repositoryUrl: repoAnalysis?.repository.remoteUrl,
|
|
698
|
+
models: outcome.models.length > 0 ? outcome.models : (modelName ? [modelName] : []),
|
|
699
|
+
tokenUsage: results.tokenUsage,
|
|
700
|
+
totalCost: outcome.totalCostUsd,
|
|
701
|
+
currency: outcome.currency,
|
|
702
|
+
costSource: outcome.costSource,
|
|
703
|
+
costReportedBy: outcome.costReportedBy,
|
|
704
|
+
costCoveredBySubscription: outcome.costCoveredBySubscription,
|
|
705
|
+
checks: results.summary.totalChecks,
|
|
706
|
+
issues: results.summary.totalIssues,
|
|
707
|
+
};
|
|
708
|
+
await saveScanRecord(record);
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
logDebug(TAG, `Failed to save scan history: ${err instanceof Error ? err.message : String(err)}`);
|
|
712
|
+
}
|
|
713
|
+
// A budget abort is a deliberate failure mode the user opted into via
|
|
714
|
+
// --budget-limit-* / runtime config — it must always exit non-zero (and
|
|
715
|
+
// surface E7001 to stderr) regardless of --fail-on-check-failure. Doing
|
|
716
|
+
// otherwise would silently drop the abort signal in CI pipelines that
|
|
717
|
+
// rely on the exit code as a guardrail.
|
|
718
|
+
//
|
|
719
|
+
// Emit through both stderr (for terminal users / CI logs) AND the logging
|
|
720
|
+
// system (so --log-file captures the abort reason — console.error bypasses
|
|
721
|
+
// registered log handlers).
|
|
722
|
+
if (outcome.budgetAborted) {
|
|
723
|
+
const reason = outcome.budgetAbortReason ?? 'Budget limit exceeded';
|
|
724
|
+
console.error(formatError(ERROR_CODES.E7001, reason));
|
|
725
|
+
logProgress(TAG, `Scan aborted by budget: ${reason}`);
|
|
726
|
+
}
|
|
727
|
+
// Exit code based on --fail-on-check-failure flag or runtime config (spec Section 9.3),
|
|
728
|
+
// OR a budget abort.
|
|
729
|
+
const failOnCheckFailure = parsed.failOnCheckFailure || runtimeConfig.failOnCheckFailure === true;
|
|
730
|
+
const shouldFail = outcome.budgetAborted ||
|
|
731
|
+
(failOnCheckFailure && (results.summary.failedChecks > 0 || results.summary.errorChecks > 0));
|
|
732
|
+
await closeAllHandlers();
|
|
733
|
+
process.exit(shouldFail ? 1 : 0);
|
|
734
|
+
}
|
|
735
|
+
finally {
|
|
736
|
+
// Clean up provider resources (e.g. OpenCode server process)
|
|
737
|
+
if (provider && 'cleanup' in provider && typeof provider.cleanup === 'function') {
|
|
738
|
+
await provider.cleanup();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// Auto-run when executed directly (npm run scan / tsx src/index.ts), but not when imported by cli.ts.
|
|
743
|
+
if (!process.env._AGHAST_CLI) {
|
|
744
|
+
runScan(process.argv.slice(2)).catch(async (err) => {
|
|
745
|
+
const require = createRequire(import.meta.url);
|
|
746
|
+
const pkg = require('../package.json');
|
|
747
|
+
console.error('');
|
|
748
|
+
console.error(formatFatalError(err instanceof Error ? err.message : String(err), pkg.version));
|
|
749
|
+
logDebug(TAG, 'Error details', err);
|
|
750
|
+
await closeAllHandlers();
|
|
751
|
+
process.exit(1);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
//# sourceMappingURL=index.js.map
|