opensecurity 0.1.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 +21 -0
- package/README.md +268 -0
- package/dist/analysis/ast.js +20 -0
- package/dist/analysis/graphs.js +295 -0
- package/dist/analysis/patterns.js +230 -0
- package/dist/analysis/rules.js +48 -0
- package/dist/analysis/taint.js +199 -0
- package/dist/cli.js +396 -0
- package/dist/config.js +71 -0
- package/dist/deps/cve.js +102 -0
- package/dist/deps/engine.js +27 -0
- package/dist/deps/patch.js +11 -0
- package/dist/deps/scanners.js +114 -0
- package/dist/deps/scoring.js +46 -0
- package/dist/deps/simulate.js +9 -0
- package/dist/deps/types.js +1 -0
- package/dist/fileWalker.js +27 -0
- package/dist/login.js +583 -0
- package/dist/oauthStore.js +48 -0
- package/dist/pr-comment.js +118 -0
- package/dist/progress.js +150 -0
- package/dist/proxy.js +93 -0
- package/dist/rules/defaultRules.js +177 -0
- package/dist/rules/loadRules.js +14 -0
- package/dist/scan.js +1129 -0
- package/dist/telemetry.js +72 -0
- package/package.json +44 -0
package/dist/scan.js
ADDED
|
@@ -0,0 +1,1129 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import traverseImport from "@babel/traverse";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { loadGlobalConfig, loadProjectConfig, resolveProjectFilters } from "./config.js";
|
|
8
|
+
import { getOAuthProfile, isTokenExpired, saveOAuthProfile } from "./oauthStore.js";
|
|
9
|
+
import { walkFiles } from "./fileWalker.js";
|
|
10
|
+
import { parseSource } from "./analysis/ast.js";
|
|
11
|
+
import { runRuleEngine } from "./analysis/rules.js";
|
|
12
|
+
import { runPatternDetectors } from "./analysis/patterns.js";
|
|
13
|
+
import { loadRules } from "./rules/loadRules.js";
|
|
14
|
+
import { scanDependenciesWithCves } from "./deps/engine.js";
|
|
15
|
+
export const SCHEMA_VERSION = "1.0.0";
|
|
16
|
+
const DEFAULT_MAX_CHARS = 4000;
|
|
17
|
+
const DEFAULT_CONCURRENCY = 2;
|
|
18
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
19
|
+
const DEFAULT_RETRY_DELAY_MS = 500;
|
|
20
|
+
const MAX_ESTIMATED_TOKENS = 50000; // Guardrail: reject massive scans
|
|
21
|
+
const CHARS_PER_TOKEN = 4; // Simple heuristic for estimation
|
|
22
|
+
export async function scan(options = {}) {
|
|
23
|
+
const resolvedRoot = options.targetPath
|
|
24
|
+
? path.resolve(options.cwd ?? process.cwd(), options.targetPath)
|
|
25
|
+
: (options.cwd ?? process.cwd());
|
|
26
|
+
const rootStats = await safeStat(resolvedRoot);
|
|
27
|
+
const cwd = rootStats?.isDirectory() ? resolvedRoot : path.dirname(resolvedRoot);
|
|
28
|
+
const targetFile = rootStats?.isFile() ? resolvedRoot : undefined;
|
|
29
|
+
const { filters, globalConfig, projectConfig } = await resolveScanContext(options, cwd, targetFile);
|
|
30
|
+
const rules = await loadRules(options.rulesPath ?? projectConfig.rulesPath, cwd);
|
|
31
|
+
const provider = options.provider ?? globalConfig.provider ?? "openai";
|
|
32
|
+
const baseUrl = globalConfig.baseUrl ?? "https://api.openai.com/v1/responses";
|
|
33
|
+
const authMode = globalConfig.authMode;
|
|
34
|
+
const oauthProvider = globalConfig.oauthProvider ?? "proxy";
|
|
35
|
+
const apiType = globalConfig.apiType ?? "responses";
|
|
36
|
+
const model = options.model ?? globalConfig.model ?? "gpt-4o-mini";
|
|
37
|
+
const maxChars = options.maxChars ?? projectConfig.maxChars ?? DEFAULT_MAX_CHARS;
|
|
38
|
+
const concurrency = Math.max(1, options.concurrency ?? projectConfig.concurrency ?? DEFAULT_CONCURRENCY);
|
|
39
|
+
const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES);
|
|
40
|
+
const retryDelayMs = Math.max(0, options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS);
|
|
41
|
+
let files = await walkFiles(cwd, filters);
|
|
42
|
+
if (options.diffOnly) {
|
|
43
|
+
const changed = await getChangedFiles(cwd, options.diffBase ?? "HEAD");
|
|
44
|
+
const changedSet = new Set(changed.map((p) => path.resolve(cwd, p)));
|
|
45
|
+
files = files.filter((filePath) => changedSet.has(path.resolve(filePath)));
|
|
46
|
+
}
|
|
47
|
+
if (options.dryRun) {
|
|
48
|
+
return { findings: [] };
|
|
49
|
+
}
|
|
50
|
+
const useCodexCli = provider === "openai" && authMode === "oauth" && oauthProvider === "codex-cli";
|
|
51
|
+
const apiKey = useCodexCli ? undefined : await resolveProviderAuthToken(globalConfig, provider);
|
|
52
|
+
const findings = [];
|
|
53
|
+
if (!apiKey && !useCodexCli && provider !== "openai" && !options.noAi) {
|
|
54
|
+
const envKey = getProviderEnvKey(provider);
|
|
55
|
+
throw new Error(`Missing API key for ${provider}. Set ${envKey} or run login with --provider ${provider} to continue.`);
|
|
56
|
+
}
|
|
57
|
+
if (provider !== "openai" && authMode === "oauth") {
|
|
58
|
+
throw new Error("OAuth mode is only supported for OpenAI. Use api_key for other providers.");
|
|
59
|
+
}
|
|
60
|
+
if (provider === "openai" && authMode === "oauth" && oauthProvider !== "codex-cli" && baseUrl.includes("api.openai.com")) {
|
|
61
|
+
throw new Error("OAuth mode requires a backend/proxy. Set OPENSECURITY_PROXY_URL or configure baseUrl to your backend.");
|
|
62
|
+
}
|
|
63
|
+
const tasks = [];
|
|
64
|
+
const cacheEnabled = options.aiCache ?? projectConfig.aiCache ?? true;
|
|
65
|
+
const cachePath = resolveCachePath(cwd, options.aiCachePath ?? projectConfig.aiCachePath);
|
|
66
|
+
const aiCache = cacheEnabled ? await loadAiCache(cachePath) : { entries: new Map() };
|
|
67
|
+
const aiFindingsByFile = new Map();
|
|
68
|
+
if (!options.dependencyOnly) {
|
|
69
|
+
const totalCodeFiles = files.filter((filePath) => isAnalyzableFile(filePath)).length;
|
|
70
|
+
let codeFileIndex = 0;
|
|
71
|
+
let totalEstimatedTokens = 0;
|
|
72
|
+
const aiEligibleFiles = (options.aiAllText ?? true)
|
|
73
|
+
? files.filter((filePath) => isLikelyTextFile(filePath))
|
|
74
|
+
: files.filter((filePath) => isAnalyzableFile(filePath));
|
|
75
|
+
const codeFiles = [];
|
|
76
|
+
for (const filePath of aiEligibleFiles) {
|
|
77
|
+
if (!isAnalyzableFile(filePath))
|
|
78
|
+
continue;
|
|
79
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
80
|
+
totalEstimatedTokens += Math.ceil(content.length / CHARS_PER_TOKEN);
|
|
81
|
+
const relPath = path.relative(cwd, filePath);
|
|
82
|
+
const parsed = parseSource(content, relPath);
|
|
83
|
+
codeFiles.push({ absPath: filePath, relPath, content, parsed });
|
|
84
|
+
}
|
|
85
|
+
if (options.aiAllText ?? true) {
|
|
86
|
+
for (const filePath of aiEligibleFiles) {
|
|
87
|
+
if (isAnalyzableFile(filePath))
|
|
88
|
+
continue;
|
|
89
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
90
|
+
totalEstimatedTokens += Math.ceil(content.length / CHARS_PER_TOKEN);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (apiKey && !options.noAi && totalEstimatedTokens > MAX_ESTIMATED_TOKENS) {
|
|
94
|
+
throw new Error(`Scan size too large: Estimated ${totalEstimatedTokens} tokens exceeds guardrail limit of ${MAX_ESTIMATED_TOKENS}. Use --no-ai or narrow your scope.`);
|
|
95
|
+
}
|
|
96
|
+
const codeByPath = new Map();
|
|
97
|
+
for (const file of codeFiles) {
|
|
98
|
+
codeByPath.set(file.absPath, { relPath: file.relPath, content: file.content, parsed: file.parsed });
|
|
99
|
+
}
|
|
100
|
+
for (const file of codeFiles) {
|
|
101
|
+
codeFileIndex += 1;
|
|
102
|
+
// Static Rule Engine (Babel/AST)
|
|
103
|
+
const ruleFindings = runRuleEngine(file.parsed.ast, file.relPath, rules);
|
|
104
|
+
for (const finding of ruleFindings) {
|
|
105
|
+
findings.push({
|
|
106
|
+
id: finding.ruleId,
|
|
107
|
+
severity: finding.severity,
|
|
108
|
+
title: finding.ruleName,
|
|
109
|
+
description: `${finding.message} [${finding.owasp}]`,
|
|
110
|
+
file: finding.file,
|
|
111
|
+
line: finding.line,
|
|
112
|
+
column: finding.column,
|
|
113
|
+
owasp: finding.owasp,
|
|
114
|
+
category: "code"
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const patternFindings = runPatternDetectors(file.parsed.ast, file.relPath);
|
|
118
|
+
for (const finding of patternFindings) {
|
|
119
|
+
findings.push({
|
|
120
|
+
id: finding.id,
|
|
121
|
+
severity: finding.severity,
|
|
122
|
+
title: finding.title,
|
|
123
|
+
description: `${finding.description} [${finding.owasp}]`,
|
|
124
|
+
file: finding.file,
|
|
125
|
+
line: finding.line,
|
|
126
|
+
column: finding.column,
|
|
127
|
+
owasp: finding.owasp,
|
|
128
|
+
category: "code"
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if ((apiKey || useCodexCli) && !options.noAi) {
|
|
133
|
+
if (options.aiMultiAgent) {
|
|
134
|
+
const batchSize = Math.max(1, options.aiBatchSize ?? 25);
|
|
135
|
+
const batchDepth = Math.max(1, options.aiBatchDepth ?? 2);
|
|
136
|
+
const leaderContext = await buildLeaderContext(cwd);
|
|
137
|
+
const relPaths = aiEligibleFiles.map((filePath) => path.relative(cwd, filePath));
|
|
138
|
+
const grouped = groupFilesByModule(relPaths, batchDepth);
|
|
139
|
+
const batches = createBatches(grouped, batchSize);
|
|
140
|
+
let fileCounter = 0;
|
|
141
|
+
for (const batch of batches) {
|
|
142
|
+
tasks.push(async () => {
|
|
143
|
+
for (const relPath of batch.files) {
|
|
144
|
+
fileCounter += 1;
|
|
145
|
+
const absPath = path.resolve(cwd, relPath);
|
|
146
|
+
const cached = codeByPath.get(absPath);
|
|
147
|
+
const content = cached?.content ?? await fs.readFile(absPath, "utf8");
|
|
148
|
+
const cacheKey = cacheEnabled ? computeCacheKey(provider, model, content) : undefined;
|
|
149
|
+
if (cacheKey) {
|
|
150
|
+
const cachedEntry = aiCache.entries.get(relPath);
|
|
151
|
+
if (cachedEntry && cachedEntry.hash === cacheKey) {
|
|
152
|
+
const cachedFindings = cachedEntry.findings.map((f) => ({ ...f }));
|
|
153
|
+
for (const finding of cachedFindings) {
|
|
154
|
+
findings.push(finding);
|
|
155
|
+
}
|
|
156
|
+
aiFindingsByFile.set(relPath, cachedFindings);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const chunks = cached?.parsed
|
|
161
|
+
? chunkCodeByBoundary(content, cached.parsed.ast, maxChars)
|
|
162
|
+
: chunkText(content, maxChars);
|
|
163
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
164
|
+
const prompt = buildPromptWithContext(relPath, chunks[i], i + 1, chunks.length, leaderContext);
|
|
165
|
+
if (options.onProgress) {
|
|
166
|
+
options.onProgress({
|
|
167
|
+
file: relPath,
|
|
168
|
+
fileIndex: fileCounter,
|
|
169
|
+
totalFiles: relPaths.length,
|
|
170
|
+
chunkIndex: i + 1,
|
|
171
|
+
totalChunks: chunks.length
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const parsed = useCodexCli
|
|
175
|
+
? await callCodexCliWithRetry(prompt, maxRetries, retryDelayMs, options.liveOutput ? options.onOutputChunk : undefined)
|
|
176
|
+
: await (async () => {
|
|
177
|
+
const responseText = await callModelWithRetry({
|
|
178
|
+
provider,
|
|
179
|
+
apiKey: apiKey,
|
|
180
|
+
baseUrl: provider === "openai" ? baseUrl : globalConfig.providerBaseUrl,
|
|
181
|
+
apiType,
|
|
182
|
+
model,
|
|
183
|
+
prompt
|
|
184
|
+
}, maxRetries, retryDelayMs);
|
|
185
|
+
return extractJson(responseText);
|
|
186
|
+
})();
|
|
187
|
+
if (!parsed?.findings)
|
|
188
|
+
continue;
|
|
189
|
+
const bucket = aiFindingsByFile.get(relPath) ?? [];
|
|
190
|
+
for (const finding of parsed.findings) {
|
|
191
|
+
const normalized = { ...finding, file: finding.file ?? relPath };
|
|
192
|
+
findings.push(normalized);
|
|
193
|
+
bucket.push(normalized);
|
|
194
|
+
}
|
|
195
|
+
aiFindingsByFile.set(relPath, bucket);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
for (const file of codeFiles) {
|
|
203
|
+
const chunks = chunkCodeByBoundary(file.content, file.parsed.ast, maxChars);
|
|
204
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
205
|
+
const prompt = buildPrompt(file.relPath, chunks[i], i + 1, chunks.length);
|
|
206
|
+
const fileIndex = codeFileIndex;
|
|
207
|
+
const chunkIndex = i + 1;
|
|
208
|
+
const totalChunks = chunks.length;
|
|
209
|
+
tasks.push(async () => {
|
|
210
|
+
const cacheKey = cacheEnabled ? computeCacheKey(provider, model, file.content) : undefined;
|
|
211
|
+
if (cacheKey) {
|
|
212
|
+
const cachedEntry = aiCache.entries.get(file.relPath);
|
|
213
|
+
if (cachedEntry && cachedEntry.hash === cacheKey) {
|
|
214
|
+
const cachedFindings = cachedEntry.findings.map((f) => ({ ...f }));
|
|
215
|
+
for (const finding of cachedFindings) {
|
|
216
|
+
findings.push(finding);
|
|
217
|
+
}
|
|
218
|
+
aiFindingsByFile.set(file.relPath, cachedFindings);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (options.onProgress) {
|
|
223
|
+
options.onProgress({
|
|
224
|
+
file: file.relPath,
|
|
225
|
+
fileIndex,
|
|
226
|
+
totalFiles: totalCodeFiles,
|
|
227
|
+
chunkIndex,
|
|
228
|
+
totalChunks
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const parsed = useCodexCli
|
|
232
|
+
? await callCodexCliWithRetry(prompt, maxRetries, retryDelayMs, options.liveOutput ? options.onOutputChunk : undefined)
|
|
233
|
+
: await (async () => {
|
|
234
|
+
const responseText = await callModelWithRetry({
|
|
235
|
+
provider,
|
|
236
|
+
apiKey: apiKey,
|
|
237
|
+
baseUrl: provider === "openai" ? baseUrl : globalConfig.providerBaseUrl,
|
|
238
|
+
apiType,
|
|
239
|
+
model,
|
|
240
|
+
prompt
|
|
241
|
+
}, maxRetries, retryDelayMs);
|
|
242
|
+
return extractJson(responseText);
|
|
243
|
+
})();
|
|
244
|
+
if (!parsed?.findings)
|
|
245
|
+
return;
|
|
246
|
+
const bucket = aiFindingsByFile.get(file.relPath) ?? [];
|
|
247
|
+
for (const finding of parsed.findings) {
|
|
248
|
+
const normalized = { ...finding, file: finding.file ?? file.relPath };
|
|
249
|
+
findings.push(normalized);
|
|
250
|
+
bucket.push(normalized);
|
|
251
|
+
}
|
|
252
|
+
aiFindingsByFile.set(file.relPath, bucket);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (options.aiAllText ?? true) {
|
|
257
|
+
const nonJsFiles = aiEligibleFiles.filter((filePath) => !isAnalyzableFile(filePath));
|
|
258
|
+
for (const filePath of nonJsFiles) {
|
|
259
|
+
const relPath = path.relative(cwd, filePath);
|
|
260
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
261
|
+
const chunks = chunkText(content, maxChars);
|
|
262
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
263
|
+
const prompt = buildPrompt(relPath, chunks[i], i + 1, chunks.length);
|
|
264
|
+
const fileIndex = totalCodeFiles + 1;
|
|
265
|
+
const chunkIndex = i + 1;
|
|
266
|
+
const totalChunks = chunks.length;
|
|
267
|
+
tasks.push(async () => {
|
|
268
|
+
const cacheKey = cacheEnabled ? computeCacheKey(provider, model, content) : undefined;
|
|
269
|
+
if (cacheKey) {
|
|
270
|
+
const cachedEntry = aiCache.entries.get(relPath);
|
|
271
|
+
if (cachedEntry && cachedEntry.hash === cacheKey) {
|
|
272
|
+
const cachedFindings = cachedEntry.findings.map((f) => ({ ...f }));
|
|
273
|
+
for (const finding of cachedFindings) {
|
|
274
|
+
findings.push(finding);
|
|
275
|
+
}
|
|
276
|
+
aiFindingsByFile.set(relPath, cachedFindings);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (options.onProgress) {
|
|
281
|
+
options.onProgress({
|
|
282
|
+
file: relPath,
|
|
283
|
+
fileIndex,
|
|
284
|
+
totalFiles: totalCodeFiles,
|
|
285
|
+
chunkIndex,
|
|
286
|
+
totalChunks
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
const parsed = useCodexCli
|
|
290
|
+
? await callCodexCliWithRetry(prompt, maxRetries, retryDelayMs, options.liveOutput ? options.onOutputChunk : undefined)
|
|
291
|
+
: await (async () => {
|
|
292
|
+
const responseText = await callModelWithRetry({
|
|
293
|
+
provider,
|
|
294
|
+
apiKey: apiKey,
|
|
295
|
+
baseUrl: provider === "openai" ? baseUrl : globalConfig.providerBaseUrl,
|
|
296
|
+
apiType,
|
|
297
|
+
model,
|
|
298
|
+
prompt
|
|
299
|
+
}, maxRetries, retryDelayMs);
|
|
300
|
+
return extractJson(responseText);
|
|
301
|
+
})();
|
|
302
|
+
if (!parsed?.findings)
|
|
303
|
+
return;
|
|
304
|
+
const bucket = aiFindingsByFile.get(relPath) ?? [];
|
|
305
|
+
for (const finding of parsed.findings) {
|
|
306
|
+
const normalized = { ...finding, file: finding.file ?? relPath };
|
|
307
|
+
findings.push(normalized);
|
|
308
|
+
bucket.push(normalized);
|
|
309
|
+
}
|
|
310
|
+
aiFindingsByFile.set(relPath, bucket);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const dependencyFindings = await scanDependenciesWithCves({
|
|
319
|
+
cwd,
|
|
320
|
+
cveLookup: {
|
|
321
|
+
cachePath: options.cveCachePath ?? projectConfig.cveCachePath,
|
|
322
|
+
apiUrl: options.cveApiUrl ?? projectConfig.cveApiUrl
|
|
323
|
+
},
|
|
324
|
+
simulate: options.simulate,
|
|
325
|
+
dataSensitivity: options.dataSensitivity ?? projectConfig.dataSensitivity
|
|
326
|
+
});
|
|
327
|
+
for (const finding of dependencyFindings) {
|
|
328
|
+
findings.push({
|
|
329
|
+
id: finding.cve.id,
|
|
330
|
+
severity: finding.risk.severity,
|
|
331
|
+
title: `Dependency ${finding.dependency.name} vulnerable`,
|
|
332
|
+
description: finding.cve.description ?? `Vulnerability in ${finding.dependency.name}`,
|
|
333
|
+
file: path.relative(cwd, finding.dependency.source),
|
|
334
|
+
category: "dependency",
|
|
335
|
+
packageName: finding.dependency.name,
|
|
336
|
+
packageVersion: finding.dependency.version ?? finding.dependency.spec,
|
|
337
|
+
cveId: finding.cve.id,
|
|
338
|
+
riskScore: finding.risk.score,
|
|
339
|
+
recommendation: finding.recommendation,
|
|
340
|
+
simulation: finding.simulation
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
await runWithConcurrency(tasks, concurrency);
|
|
344
|
+
if (cacheEnabled) {
|
|
345
|
+
for (const [relPath, fileFindings] of aiFindingsByFile.entries()) {
|
|
346
|
+
const absPath = path.resolve(cwd, relPath);
|
|
347
|
+
try {
|
|
348
|
+
const content = await fs.readFile(absPath, "utf8");
|
|
349
|
+
const hash = computeCacheKey(provider, model, content);
|
|
350
|
+
aiCache.entries.set(relPath, { hash, findings: fileFindings });
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// ignore missing files
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
await saveAiCache(cachePath, aiCache);
|
|
357
|
+
}
|
|
358
|
+
return { findings: dedupeFindings(findings) };
|
|
359
|
+
}
|
|
360
|
+
export async function listMatchedFiles(options = {}) {
|
|
361
|
+
const resolvedRoot = options.targetPath
|
|
362
|
+
? path.resolve(options.cwd ?? process.cwd(), options.targetPath)
|
|
363
|
+
: (options.cwd ?? process.cwd());
|
|
364
|
+
const rootStats = await safeStat(resolvedRoot);
|
|
365
|
+
const cwd = rootStats?.isDirectory() ? resolvedRoot : path.dirname(resolvedRoot);
|
|
366
|
+
const targetFile = rootStats?.isFile() ? resolvedRoot : undefined;
|
|
367
|
+
const { filters } = await resolveScanContext(options, cwd, targetFile);
|
|
368
|
+
return walkFiles(cwd, filters);
|
|
369
|
+
}
|
|
370
|
+
async function resolveScanContext(options, cwd, targetFile) {
|
|
371
|
+
const globalConfig = await loadGlobalConfig();
|
|
372
|
+
const projectConfig = await loadProjectConfig(cwd);
|
|
373
|
+
const baseFilters = resolveProjectFilters({
|
|
374
|
+
include: options.include ?? projectConfig.include,
|
|
375
|
+
exclude: options.exclude ?? projectConfig.exclude
|
|
376
|
+
});
|
|
377
|
+
const filters = targetFile
|
|
378
|
+
? {
|
|
379
|
+
include: [path.relative(cwd, targetFile).split(path.sep).join("/")],
|
|
380
|
+
exclude: []
|
|
381
|
+
}
|
|
382
|
+
: baseFilters;
|
|
383
|
+
const mergedGlobals = {
|
|
384
|
+
...globalConfig,
|
|
385
|
+
authMode: options.authMode ?? globalConfig.authMode
|
|
386
|
+
};
|
|
387
|
+
return { globalConfig: mergedGlobals, projectConfig, filters };
|
|
388
|
+
}
|
|
389
|
+
export function chunkText(text, maxChars) {
|
|
390
|
+
if (text.length <= maxChars)
|
|
391
|
+
return [text];
|
|
392
|
+
const chunks = [];
|
|
393
|
+
let offset = 0;
|
|
394
|
+
while (offset < text.length) {
|
|
395
|
+
chunks.push(text.slice(offset, offset + maxChars));
|
|
396
|
+
offset += maxChars;
|
|
397
|
+
}
|
|
398
|
+
return chunks;
|
|
399
|
+
}
|
|
400
|
+
export function chunkCodeByBoundary(code, ast, maxChars) {
|
|
401
|
+
const segments = [];
|
|
402
|
+
const functionSegments = collectFunctionSegments(ast);
|
|
403
|
+
const body = ast.program.body ?? [];
|
|
404
|
+
for (const node of body) {
|
|
405
|
+
const range = getNodeRange(node);
|
|
406
|
+
if (!range)
|
|
407
|
+
continue;
|
|
408
|
+
if (functionSegments.some((seg) => isRangeInside(range, seg)))
|
|
409
|
+
continue;
|
|
410
|
+
segments.push(range);
|
|
411
|
+
}
|
|
412
|
+
segments.push(...functionSegments);
|
|
413
|
+
segments.sort((a, b) => a.start - b.start);
|
|
414
|
+
if (!segments.length) {
|
|
415
|
+
return chunkText(code, maxChars);
|
|
416
|
+
}
|
|
417
|
+
const chunks = [];
|
|
418
|
+
for (const seg of segments) {
|
|
419
|
+
const slice = code.slice(seg.start, seg.end);
|
|
420
|
+
if (slice.length <= maxChars) {
|
|
421
|
+
chunks.push(slice);
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
chunks.push(...chunkText(slice, maxChars));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return chunks.length ? chunks : chunkText(code, maxChars);
|
|
428
|
+
}
|
|
429
|
+
function collectFunctionSegments(ast) {
|
|
430
|
+
const traverse = normalizeTraverse(traverseImport);
|
|
431
|
+
const segments = [];
|
|
432
|
+
let functionDepth = 0;
|
|
433
|
+
traverse(ast, {
|
|
434
|
+
Function: {
|
|
435
|
+
enter(path) {
|
|
436
|
+
if (functionDepth === 0) {
|
|
437
|
+
const inClass = Boolean(path.findParent((parent) => parent.isClassDeclaration() || parent.isClassExpression()));
|
|
438
|
+
if (!inClass) {
|
|
439
|
+
const range = getFunctionRange(path);
|
|
440
|
+
if (range)
|
|
441
|
+
segments.push(range);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
functionDepth += 1;
|
|
445
|
+
},
|
|
446
|
+
exit() {
|
|
447
|
+
functionDepth = Math.max(0, functionDepth - 1);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
return segments;
|
|
452
|
+
}
|
|
453
|
+
function getFunctionRange(path) {
|
|
454
|
+
const node = path.node;
|
|
455
|
+
if (path.isFunctionDeclaration() || path.isObjectMethod() || path.isClassMethod() || path.isClassPrivateMethod()) {
|
|
456
|
+
return getNodeRange(node);
|
|
457
|
+
}
|
|
458
|
+
const stmt = path.getStatementParent();
|
|
459
|
+
if (stmt) {
|
|
460
|
+
const range = getNodeRange(stmt.node);
|
|
461
|
+
if (range)
|
|
462
|
+
return range;
|
|
463
|
+
}
|
|
464
|
+
return getNodeRange(node);
|
|
465
|
+
}
|
|
466
|
+
function getNodeRange(node) {
|
|
467
|
+
const start = node?.start;
|
|
468
|
+
const end = node?.end;
|
|
469
|
+
if (typeof start !== "number" || typeof end !== "number" || end <= start)
|
|
470
|
+
return null;
|
|
471
|
+
return { start, end };
|
|
472
|
+
}
|
|
473
|
+
function isRangeInside(inner, outer) {
|
|
474
|
+
return inner.start >= outer.start && inner.end <= outer.end;
|
|
475
|
+
}
|
|
476
|
+
function buildPrompt(filePath, chunk, index, total) {
|
|
477
|
+
return buildPromptWithContext(filePath, chunk, index, total);
|
|
478
|
+
}
|
|
479
|
+
export function buildPromptWithContext(filePath, chunk, index, total, context) {
|
|
480
|
+
const contextBlock = context
|
|
481
|
+
? [`Context Summary:`, context.trim(), ``]
|
|
482
|
+
: [];
|
|
483
|
+
return [
|
|
484
|
+
"You are a security static analysis engine.",
|
|
485
|
+
"Return JSON only.",
|
|
486
|
+
"Do not add explanations.",
|
|
487
|
+
"Do not wrap in markdown.",
|
|
488
|
+
"Do not add code fences.",
|
|
489
|
+
"",
|
|
490
|
+
...contextBlock,
|
|
491
|
+
"Schema:",
|
|
492
|
+
"{\"findings\":[{\"id\":string,\"severity\":\"low|medium|high|critical\",\"title\":string,\"description\":string,\"file\":string,\"line\":number}]}",
|
|
493
|
+
"",
|
|
494
|
+
`Analyze this code chunk from ${filePath} (chunk ${index}/${total}):`,
|
|
495
|
+
chunk
|
|
496
|
+
].join("\n");
|
|
497
|
+
}
|
|
498
|
+
async function resolveProviderAuthToken(globalConfig, provider) {
|
|
499
|
+
if (provider !== "openai") {
|
|
500
|
+
return resolveNonOpenAiApiKey(globalConfig, provider);
|
|
501
|
+
}
|
|
502
|
+
if (globalConfig.authMode !== "oauth") {
|
|
503
|
+
return globalConfig.apiKey?.trim();
|
|
504
|
+
}
|
|
505
|
+
const profileId = globalConfig.authProfileId ?? "codex";
|
|
506
|
+
const profile = await getOAuthProfile(profileId);
|
|
507
|
+
if (!profile) {
|
|
508
|
+
throw new Error("No OAuth profile found. Run login with --mode oauth.");
|
|
509
|
+
}
|
|
510
|
+
if (!isTokenExpired(profile)) {
|
|
511
|
+
return profile.accessToken;
|
|
512
|
+
}
|
|
513
|
+
if (!profile.refreshToken) {
|
|
514
|
+
throw new Error("OAuth token expired and no refresh token is available. Run login again.");
|
|
515
|
+
}
|
|
516
|
+
const refreshed = await refreshAccessToken(profile);
|
|
517
|
+
await saveOAuthProfile(refreshed);
|
|
518
|
+
return refreshed.accessToken;
|
|
519
|
+
}
|
|
520
|
+
function resolveNonOpenAiApiKey(globalConfig, provider) {
|
|
521
|
+
const envKey = getProviderEnvKey(provider);
|
|
522
|
+
return (globalConfig.providerApiKey?.trim() ||
|
|
523
|
+
process.env[envKey]?.trim() ||
|
|
524
|
+
globalConfig.apiKey?.trim());
|
|
525
|
+
}
|
|
526
|
+
function getProviderEnvKey(provider) {
|
|
527
|
+
switch (provider) {
|
|
528
|
+
case "anthropic":
|
|
529
|
+
return "ANTHROPIC_API_KEY";
|
|
530
|
+
case "google":
|
|
531
|
+
return "GEMINI_API_KEY";
|
|
532
|
+
case "mistral":
|
|
533
|
+
return "MISTRAL_API_KEY";
|
|
534
|
+
case "xai":
|
|
535
|
+
return "XAI_API_KEY";
|
|
536
|
+
case "cohere":
|
|
537
|
+
return "COHERE_API_KEY";
|
|
538
|
+
case "openai":
|
|
539
|
+
default:
|
|
540
|
+
return "OPENAI_API_KEY";
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async function callCodexCli(params) {
|
|
544
|
+
const { prompt, onOutputChunk } = params;
|
|
545
|
+
const { spawn } = await import("node:child_process");
|
|
546
|
+
return new Promise((resolve, reject) => {
|
|
547
|
+
const args = [
|
|
548
|
+
"exec",
|
|
549
|
+
"--skip-git-repo-check",
|
|
550
|
+
"--sandbox",
|
|
551
|
+
"read-only"
|
|
552
|
+
];
|
|
553
|
+
const child = spawn("codex", [...args, prompt], {
|
|
554
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
555
|
+
});
|
|
556
|
+
let stdout = "";
|
|
557
|
+
child.stdout.on("data", (chunk) => {
|
|
558
|
+
const text = chunk.toString();
|
|
559
|
+
stdout += text;
|
|
560
|
+
if (onOutputChunk)
|
|
561
|
+
onOutputChunk(text);
|
|
562
|
+
});
|
|
563
|
+
child.on("error", (err) => reject(new Error(`codex exec failed: ${err.message}`)));
|
|
564
|
+
child.on("close", (code) => {
|
|
565
|
+
if (code && code !== 0) {
|
|
566
|
+
reject(new Error(`codex exec failed with exit code ${code}`));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
resolve(stdout);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
async function callCodexCliWithRetry(prompt, maxRetries, retryDelayMs, onOutputChunk) {
|
|
574
|
+
let attempt = 0;
|
|
575
|
+
let delay = retryDelayMs;
|
|
576
|
+
while (true) {
|
|
577
|
+
try {
|
|
578
|
+
const output = await callCodexCli({ prompt, onOutputChunk });
|
|
579
|
+
const parsed = extractJson(output);
|
|
580
|
+
if (!parsed) {
|
|
581
|
+
throw new Error("Codex returned non-JSON output.");
|
|
582
|
+
}
|
|
583
|
+
return parsed;
|
|
584
|
+
}
|
|
585
|
+
catch (err) {
|
|
586
|
+
if (attempt >= maxRetries)
|
|
587
|
+
throw err;
|
|
588
|
+
await sleep(delay + Math.floor(Math.random() * 100));
|
|
589
|
+
delay = Math.max(delay * 2, retryDelayMs);
|
|
590
|
+
attempt += 1;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
async function refreshAccessToken(profile) {
|
|
595
|
+
const body = new URLSearchParams({
|
|
596
|
+
grant_type: "refresh_token",
|
|
597
|
+
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
598
|
+
refresh_token: profile.refreshToken ?? ""
|
|
599
|
+
});
|
|
600
|
+
const res = await fetch("https://auth.openai.com/oauth/token", {
|
|
601
|
+
method: "POST",
|
|
602
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
603
|
+
body
|
|
604
|
+
});
|
|
605
|
+
if (!res.ok) {
|
|
606
|
+
const text = await res.text();
|
|
607
|
+
throw new Error(`OAuth refresh failed: ${res.status} ${text}`);
|
|
608
|
+
}
|
|
609
|
+
const data = (await res.json());
|
|
610
|
+
if (!data.access_token) {
|
|
611
|
+
throw new Error("OAuth refresh did not return an access_token.");
|
|
612
|
+
}
|
|
613
|
+
const expiresAt = data.expires_in ? Date.now() + data.expires_in * 1000 : undefined;
|
|
614
|
+
return {
|
|
615
|
+
...profile,
|
|
616
|
+
accessToken: data.access_token,
|
|
617
|
+
refreshToken: data.refresh_token ?? profile.refreshToken,
|
|
618
|
+
tokenType: data.token_type ?? profile.tokenType,
|
|
619
|
+
scope: data.scope ?? profile.scope,
|
|
620
|
+
expiresAt,
|
|
621
|
+
obtainedAt: Date.now()
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
async function callModel(params) {
|
|
625
|
+
const { provider } = params;
|
|
626
|
+
switch (provider) {
|
|
627
|
+
case "openai":
|
|
628
|
+
return callOpenAiModel(params);
|
|
629
|
+
case "anthropic":
|
|
630
|
+
return callAnthropicModel(params);
|
|
631
|
+
case "google":
|
|
632
|
+
return callGeminiModel(params);
|
|
633
|
+
case "mistral":
|
|
634
|
+
return callMistralModel(params);
|
|
635
|
+
case "xai":
|
|
636
|
+
return callXaiModel(params);
|
|
637
|
+
case "cohere":
|
|
638
|
+
return callCohereModel(params);
|
|
639
|
+
default:
|
|
640
|
+
throw new Error(`Unsupported provider: ${provider}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async function callOpenAiModel(params) {
|
|
644
|
+
const { apiKey, baseUrl, apiType, model, prompt } = params;
|
|
645
|
+
if (!baseUrl || !apiType) {
|
|
646
|
+
throw new Error("OpenAI baseUrl and apiType are required.");
|
|
647
|
+
}
|
|
648
|
+
const headers = {
|
|
649
|
+
"Content-Type": "application/json",
|
|
650
|
+
Authorization: `Bearer ${apiKey}`
|
|
651
|
+
};
|
|
652
|
+
const scrub = (msg) => msg.replace(apiKey, "sk-***" + apiKey.slice(-4));
|
|
653
|
+
if (apiType === "chat" || baseUrl.includes("/chat/completions")) {
|
|
654
|
+
const body = JSON.stringify({
|
|
655
|
+
model,
|
|
656
|
+
messages: [{ role: "user", content: prompt }],
|
|
657
|
+
temperature: 0
|
|
658
|
+
});
|
|
659
|
+
const res = await fetch(baseUrl, { method: "POST", headers, body });
|
|
660
|
+
if (!res.ok) {
|
|
661
|
+
const text = await res.text();
|
|
662
|
+
throw new Error(scrub(`Model request failed: ${res.status} ${text}`));
|
|
663
|
+
}
|
|
664
|
+
const data = await res.json();
|
|
665
|
+
return data?.choices?.[0]?.message?.content ?? "";
|
|
666
|
+
}
|
|
667
|
+
const body = JSON.stringify({
|
|
668
|
+
model,
|
|
669
|
+
input: prompt,
|
|
670
|
+
temperature: 0
|
|
671
|
+
});
|
|
672
|
+
const res = await fetch(baseUrl, { method: "POST", headers, body });
|
|
673
|
+
if (!res.ok) {
|
|
674
|
+
const text = await res.text();
|
|
675
|
+
throw new Error(scrub(`Model request failed: ${res.status} ${text}`));
|
|
676
|
+
}
|
|
677
|
+
const data = await res.json();
|
|
678
|
+
return data?.output_text ?? extractResponsesText(data);
|
|
679
|
+
}
|
|
680
|
+
async function callAnthropicModel(params) {
|
|
681
|
+
const { apiKey, baseUrl, model, prompt } = params;
|
|
682
|
+
const url = baseUrl ?? "https://api.anthropic.com/v1/messages";
|
|
683
|
+
const body = JSON.stringify({
|
|
684
|
+
model,
|
|
685
|
+
max_tokens: 1024,
|
|
686
|
+
messages: [{ role: "user", content: prompt }]
|
|
687
|
+
});
|
|
688
|
+
const res = await fetch(url, {
|
|
689
|
+
method: "POST",
|
|
690
|
+
headers: {
|
|
691
|
+
"Content-Type": "application/json",
|
|
692
|
+
"x-api-key": apiKey,
|
|
693
|
+
"anthropic-version": "2023-06-01"
|
|
694
|
+
},
|
|
695
|
+
body
|
|
696
|
+
});
|
|
697
|
+
if (!res.ok) {
|
|
698
|
+
const text = await res.text();
|
|
699
|
+
throw new Error(`Anthropic request failed: ${res.status} ${text}`);
|
|
700
|
+
}
|
|
701
|
+
const data = await res.json();
|
|
702
|
+
return data?.content?.[0]?.text ?? "";
|
|
703
|
+
}
|
|
704
|
+
async function callGeminiModel(params) {
|
|
705
|
+
const { apiKey, baseUrl, model, prompt } = params;
|
|
706
|
+
const base = baseUrl ?? "https://generativelanguage.googleapis.com/v1beta/models";
|
|
707
|
+
const modelPath = model.startsWith("models/") ? model.slice("models/".length) : model;
|
|
708
|
+
const url = `${base}/${modelPath}:generateContent`;
|
|
709
|
+
const body = JSON.stringify({
|
|
710
|
+
contents: [{ role: "user", parts: [{ text: prompt }] }]
|
|
711
|
+
});
|
|
712
|
+
const res = await fetch(url, {
|
|
713
|
+
method: "POST",
|
|
714
|
+
headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
|
|
715
|
+
body
|
|
716
|
+
});
|
|
717
|
+
if (!res.ok) {
|
|
718
|
+
const text = await res.text();
|
|
719
|
+
throw new Error(`Gemini request failed: ${res.status} ${text}`);
|
|
720
|
+
}
|
|
721
|
+
const data = await res.json();
|
|
722
|
+
const parts = data?.candidates?.[0]?.content?.parts ?? [];
|
|
723
|
+
return parts.map((p) => p?.text ?? "").join("");
|
|
724
|
+
}
|
|
725
|
+
async function callMistralModel(params) {
|
|
726
|
+
const { apiKey, baseUrl, model, prompt } = params;
|
|
727
|
+
const url = baseUrl ?? "https://api.mistral.ai/v1/chat/completions";
|
|
728
|
+
const body = JSON.stringify({
|
|
729
|
+
model,
|
|
730
|
+
messages: [{ role: "user", content: prompt }],
|
|
731
|
+
temperature: 0
|
|
732
|
+
});
|
|
733
|
+
const res = await fetch(url, {
|
|
734
|
+
method: "POST",
|
|
735
|
+
headers: {
|
|
736
|
+
"Content-Type": "application/json",
|
|
737
|
+
Authorization: `Bearer ${apiKey}`
|
|
738
|
+
},
|
|
739
|
+
body
|
|
740
|
+
});
|
|
741
|
+
if (!res.ok) {
|
|
742
|
+
const text = await res.text();
|
|
743
|
+
throw new Error(`Mistral request failed: ${res.status} ${text}`);
|
|
744
|
+
}
|
|
745
|
+
const data = await res.json();
|
|
746
|
+
return data?.choices?.[0]?.message?.content ?? "";
|
|
747
|
+
}
|
|
748
|
+
async function callXaiModel(params) {
|
|
749
|
+
const { apiKey, baseUrl, model, prompt } = params;
|
|
750
|
+
const url = baseUrl ?? "https://api.x.ai/v1/chat/completions";
|
|
751
|
+
const body = JSON.stringify({
|
|
752
|
+
model,
|
|
753
|
+
messages: [{ role: "user", content: prompt }],
|
|
754
|
+
temperature: 0
|
|
755
|
+
});
|
|
756
|
+
const res = await fetch(url, {
|
|
757
|
+
method: "POST",
|
|
758
|
+
headers: {
|
|
759
|
+
"Content-Type": "application/json",
|
|
760
|
+
Authorization: `Bearer ${apiKey}`
|
|
761
|
+
},
|
|
762
|
+
body
|
|
763
|
+
});
|
|
764
|
+
if (!res.ok) {
|
|
765
|
+
const text = await res.text();
|
|
766
|
+
throw new Error(`xAI request failed: ${res.status} ${text}`);
|
|
767
|
+
}
|
|
768
|
+
const data = await res.json();
|
|
769
|
+
return data?.choices?.[0]?.message?.content ?? "";
|
|
770
|
+
}
|
|
771
|
+
async function callCohereModel(params) {
|
|
772
|
+
const { apiKey, baseUrl, model, prompt } = params;
|
|
773
|
+
const url = baseUrl ?? "https://api.cohere.com/v2/chat";
|
|
774
|
+
const body = JSON.stringify({
|
|
775
|
+
model,
|
|
776
|
+
messages: [{ role: "user", content: prompt }],
|
|
777
|
+
temperature: 0
|
|
778
|
+
});
|
|
779
|
+
const res = await fetch(url, {
|
|
780
|
+
method: "POST",
|
|
781
|
+
headers: {
|
|
782
|
+
"Content-Type": "application/json",
|
|
783
|
+
Authorization: `Bearer ${apiKey}`
|
|
784
|
+
},
|
|
785
|
+
body
|
|
786
|
+
});
|
|
787
|
+
if (!res.ok) {
|
|
788
|
+
const text = await res.text();
|
|
789
|
+
throw new Error(`Cohere request failed: ${res.status} ${text}`);
|
|
790
|
+
}
|
|
791
|
+
const data = await res.json();
|
|
792
|
+
return data?.message?.content?.[0]?.text ?? "";
|
|
793
|
+
}
|
|
794
|
+
async function callModelWithRetry(params, maxRetries, retryDelayMs) {
|
|
795
|
+
let attempt = 0;
|
|
796
|
+
let delay = retryDelayMs;
|
|
797
|
+
// Simple exponential backoff with jitter
|
|
798
|
+
while (true) {
|
|
799
|
+
try {
|
|
800
|
+
return await callModel(params);
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
if (attempt >= maxRetries)
|
|
804
|
+
throw err;
|
|
805
|
+
await sleep(delay + Math.floor(Math.random() * 100));
|
|
806
|
+
delay = delay * 2;
|
|
807
|
+
attempt += 1;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
async function runWithConcurrency(tasks, limit) {
|
|
812
|
+
const queue = tasks.slice();
|
|
813
|
+
const workers = [];
|
|
814
|
+
const worker = async () => {
|
|
815
|
+
while (queue.length) {
|
|
816
|
+
const task = queue.shift();
|
|
817
|
+
if (!task)
|
|
818
|
+
return;
|
|
819
|
+
await task();
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
for (let i = 0; i < limit; i += 1) {
|
|
823
|
+
workers.push(worker());
|
|
824
|
+
}
|
|
825
|
+
await Promise.all(workers);
|
|
826
|
+
}
|
|
827
|
+
function sleep(ms) {
|
|
828
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
829
|
+
}
|
|
830
|
+
function extractResponsesText(data) {
|
|
831
|
+
if (!data?.output?.length)
|
|
832
|
+
return "";
|
|
833
|
+
const chunks = [];
|
|
834
|
+
for (const item of data.output) {
|
|
835
|
+
if (!item?.content?.length)
|
|
836
|
+
continue;
|
|
837
|
+
for (const content of item.content) {
|
|
838
|
+
if (content?.type === "output_text" && content?.text)
|
|
839
|
+
chunks.push(content.text);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return chunks.join("\n");
|
|
843
|
+
}
|
|
844
|
+
function extractJson(text) {
|
|
845
|
+
if (!text)
|
|
846
|
+
return null;
|
|
847
|
+
const firstBrace = text.indexOf("{");
|
|
848
|
+
const lastBrace = text.lastIndexOf("}");
|
|
849
|
+
if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace)
|
|
850
|
+
return null;
|
|
851
|
+
const candidate = text.slice(firstBrace, lastBrace + 1);
|
|
852
|
+
try {
|
|
853
|
+
return JSON.parse(candidate);
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
export function renderTextReport(result) {
|
|
860
|
+
const grouped = {
|
|
861
|
+
critical: [],
|
|
862
|
+
high: [],
|
|
863
|
+
medium: [],
|
|
864
|
+
low: []
|
|
865
|
+
};
|
|
866
|
+
for (const finding of result.findings) {
|
|
867
|
+
grouped[finding.severity]?.push(finding);
|
|
868
|
+
}
|
|
869
|
+
const lines = [];
|
|
870
|
+
const order = ["critical", "high", "medium", "low"];
|
|
871
|
+
for (const severity of order) {
|
|
872
|
+
const items = grouped[severity];
|
|
873
|
+
if (!items.length)
|
|
874
|
+
continue;
|
|
875
|
+
lines.push(`${severity.toUpperCase()} (${items.length})`);
|
|
876
|
+
for (const item of items) {
|
|
877
|
+
const location = item.line
|
|
878
|
+
? item.column
|
|
879
|
+
? `${item.file}:${item.line}:${item.column}`
|
|
880
|
+
: `${item.file}:${item.line}`
|
|
881
|
+
: item.file;
|
|
882
|
+
const owasp = item.owasp ? ` ${item.owasp}` : "";
|
|
883
|
+
lines.push(`- [${item.id}] ${item.title}${owasp} (${location})`);
|
|
884
|
+
lines.push(` ${item.description}`);
|
|
885
|
+
if (item.category === "dependency") {
|
|
886
|
+
const pkg = item.packageVersion
|
|
887
|
+
? `${item.packageName}@${item.packageVersion}`
|
|
888
|
+
: item.packageName ?? "unknown";
|
|
889
|
+
const score = item.riskScore !== undefined ? ` score=${item.riskScore}` : "";
|
|
890
|
+
const cve = item.cveId ? ` ${item.cveId}` : "";
|
|
891
|
+
lines.push(` package: ${pkg}${cve}${score}`);
|
|
892
|
+
}
|
|
893
|
+
if (item.recommendation) {
|
|
894
|
+
lines.push(` recommendation: ${item.recommendation}`);
|
|
895
|
+
}
|
|
896
|
+
if (item.simulation) {
|
|
897
|
+
lines.push(` simulate: ${item.simulation.payload}`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
lines.push("");
|
|
901
|
+
}
|
|
902
|
+
return lines.join("\n").trim();
|
|
903
|
+
}
|
|
904
|
+
export function renderJsonReport(result) {
|
|
905
|
+
return JSON.stringify({ schemaVersion: SCHEMA_VERSION, ...result }, null, 2);
|
|
906
|
+
}
|
|
907
|
+
export function renderSarifReport(result) {
|
|
908
|
+
const sarifResults = result.findings.map((finding) => {
|
|
909
|
+
const level = mapSeverityToSarif(finding.severity);
|
|
910
|
+
const message = [finding.title, finding.description].filter(Boolean).join(" — ");
|
|
911
|
+
const location = {
|
|
912
|
+
physicalLocation: {
|
|
913
|
+
artifactLocation: { uri: finding.file }
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
if (finding.line) {
|
|
917
|
+
location.physicalLocation.region = {
|
|
918
|
+
startLine: finding.line,
|
|
919
|
+
...(finding.column ? { startColumn: finding.column } : {})
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
return {
|
|
923
|
+
ruleId: finding.id,
|
|
924
|
+
level,
|
|
925
|
+
message: { text: message },
|
|
926
|
+
locations: [location]
|
|
927
|
+
};
|
|
928
|
+
});
|
|
929
|
+
const sarif = {
|
|
930
|
+
version: "2.1.0",
|
|
931
|
+
runs: [
|
|
932
|
+
{
|
|
933
|
+
tool: { driver: { name: "OpenSecurity", version: SCHEMA_VERSION } },
|
|
934
|
+
results: sarifResults
|
|
935
|
+
}
|
|
936
|
+
]
|
|
937
|
+
};
|
|
938
|
+
return JSON.stringify(sarif, null, 2);
|
|
939
|
+
}
|
|
940
|
+
function isAnalyzableFile(filePath) {
|
|
941
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
942
|
+
// JS/TS only: Babel AST parser expects JS/TS syntax.
|
|
943
|
+
const supported = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"];
|
|
944
|
+
return supported.includes(ext);
|
|
945
|
+
}
|
|
946
|
+
function isLikelyTextFile(filePath) {
|
|
947
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
948
|
+
if (!ext)
|
|
949
|
+
return true;
|
|
950
|
+
const blocked = new Set([
|
|
951
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg",
|
|
952
|
+
".zip", ".gz", ".tgz", ".rar", ".7z",
|
|
953
|
+
".pdf", ".mp3", ".mp4", ".mov", ".avi", ".mkv",
|
|
954
|
+
".woff", ".woff2", ".ttf", ".otf",
|
|
955
|
+
".bin", ".exe", ".dmg", ".iso"
|
|
956
|
+
]);
|
|
957
|
+
return !blocked.has(ext);
|
|
958
|
+
}
|
|
959
|
+
export function groupFilesByModule(relPaths, depth) {
|
|
960
|
+
const groups = new Map();
|
|
961
|
+
for (const relPath of relPaths) {
|
|
962
|
+
const normalized = relPath.split(path.sep).join("/");
|
|
963
|
+
const parts = normalized.split("/");
|
|
964
|
+
const last = parts[parts.length - 1] ?? "";
|
|
965
|
+
const isFile = last.includes(".");
|
|
966
|
+
const dirParts = isFile ? parts.slice(0, -1) : parts;
|
|
967
|
+
let key = "root";
|
|
968
|
+
if (dirParts[0] === "src" && dirParts.length > 1) {
|
|
969
|
+
key = ["src", ...dirParts.slice(1, 1 + depth)].join("/");
|
|
970
|
+
}
|
|
971
|
+
else if (dirParts[0] === "packages" && dirParts.length > 1) {
|
|
972
|
+
key = ["packages", dirParts[1]].join("/");
|
|
973
|
+
}
|
|
974
|
+
else if (dirParts[0] === "apps" && dirParts.length > 1) {
|
|
975
|
+
key = ["apps", dirParts[1]].join("/");
|
|
976
|
+
}
|
|
977
|
+
else if (dirParts.length > 1) {
|
|
978
|
+
key = dirParts[0];
|
|
979
|
+
}
|
|
980
|
+
const bucket = groups.get(key) ?? [];
|
|
981
|
+
bucket.push(normalized);
|
|
982
|
+
groups.set(key, bucket);
|
|
983
|
+
}
|
|
984
|
+
return groups;
|
|
985
|
+
}
|
|
986
|
+
export function createBatches(groups, batchSize) {
|
|
987
|
+
const batches = [];
|
|
988
|
+
for (const [key, files] of groups.entries()) {
|
|
989
|
+
for (let i = 0; i < files.length; i += batchSize) {
|
|
990
|
+
batches.push({ key, files: files.slice(i, i + batchSize) });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return batches;
|
|
994
|
+
}
|
|
995
|
+
function resolveCachePath(cwd, override) {
|
|
996
|
+
if (override && override.trim()) {
|
|
997
|
+
return path.isAbsolute(override) ? override : path.join(cwd, override);
|
|
998
|
+
}
|
|
999
|
+
return path.join(cwd, ".opensecurity", "ai-cache.json");
|
|
1000
|
+
}
|
|
1001
|
+
async function loadAiCache(cachePath) {
|
|
1002
|
+
try {
|
|
1003
|
+
const raw = await fs.readFile(cachePath, "utf8");
|
|
1004
|
+
const parsed = JSON.parse(raw);
|
|
1005
|
+
const entries = new Map(Object.entries(parsed ?? {}));
|
|
1006
|
+
return { entries };
|
|
1007
|
+
}
|
|
1008
|
+
catch (err) {
|
|
1009
|
+
if (err?.code === "ENOENT")
|
|
1010
|
+
return { entries: new Map() };
|
|
1011
|
+
return { entries: new Map() };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
async function saveAiCache(cachePath, cache) {
|
|
1015
|
+
const obj = {};
|
|
1016
|
+
for (const [key, value] of cache.entries.entries()) {
|
|
1017
|
+
obj[key] = value;
|
|
1018
|
+
}
|
|
1019
|
+
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
1020
|
+
await fs.writeFile(cachePath, JSON.stringify(obj, null, 2), "utf8");
|
|
1021
|
+
}
|
|
1022
|
+
function computeCacheKey(provider, model, content) {
|
|
1023
|
+
return crypto
|
|
1024
|
+
.createHash("sha256")
|
|
1025
|
+
.update(provider)
|
|
1026
|
+
.update("|")
|
|
1027
|
+
.update(model)
|
|
1028
|
+
.update("|")
|
|
1029
|
+
.update(content)
|
|
1030
|
+
.digest("hex");
|
|
1031
|
+
}
|
|
1032
|
+
async function safeStat(target) {
|
|
1033
|
+
try {
|
|
1034
|
+
return await fs.stat(target);
|
|
1035
|
+
}
|
|
1036
|
+
catch {
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
const execFileAsync = promisify(execFile);
|
|
1041
|
+
async function getChangedFiles(cwd, baseRef) {
|
|
1042
|
+
try {
|
|
1043
|
+
const [diff, diffCached, diffBase, untracked] = await Promise.all([
|
|
1044
|
+
execFileAsync("git", ["-C", cwd, "diff", "--name-only", "--diff-filter=ACMR"], { encoding: "utf8" }),
|
|
1045
|
+
execFileAsync("git", ["-C", cwd, "diff", "--name-only", "--diff-filter=ACMR", "--cached"], { encoding: "utf8" }),
|
|
1046
|
+
execFileAsync("git", ["-C", cwd, "diff", "--name-only", "--diff-filter=ACMR", baseRef], { encoding: "utf8" }),
|
|
1047
|
+
execFileAsync("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard"], { encoding: "utf8" })
|
|
1048
|
+
]);
|
|
1049
|
+
const entries = [diff.stdout, diffCached.stdout, diffBase.stdout, untracked.stdout]
|
|
1050
|
+
.join("\n")
|
|
1051
|
+
.split(/\r?\n/)
|
|
1052
|
+
.map((line) => line.trim())
|
|
1053
|
+
.filter(Boolean);
|
|
1054
|
+
return Array.from(new Set(entries));
|
|
1055
|
+
}
|
|
1056
|
+
catch (err) {
|
|
1057
|
+
throw new Error("diff-only requires a git repository with a valid base ref.");
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
async function buildLeaderContext(cwd) {
|
|
1061
|
+
const parts = [];
|
|
1062
|
+
const readIfExists = async (filePath) => {
|
|
1063
|
+
try {
|
|
1064
|
+
return await fs.readFile(filePath, "utf8");
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
return "";
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
const readme = await readIfExists(path.join(cwd, "README.md"));
|
|
1071
|
+
if (readme) {
|
|
1072
|
+
parts.push("README:", truncateLines(readme, 12));
|
|
1073
|
+
}
|
|
1074
|
+
const pkgRaw = await readIfExists(path.join(cwd, "package.json"));
|
|
1075
|
+
if (pkgRaw) {
|
|
1076
|
+
try {
|
|
1077
|
+
const pkg = JSON.parse(pkgRaw);
|
|
1078
|
+
parts.push(`package.json: ${[pkg.name, pkg.version, pkg.description].filter(Boolean).join(" | ")}`);
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
parts.push("package.json: (unreadable)");
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
try {
|
|
1085
|
+
const entries = await fs.readdir(cwd, { withFileTypes: true });
|
|
1086
|
+
const top = entries
|
|
1087
|
+
.filter((e) => ![".git", "node_modules", "dist", "build", "coverage"].includes(e.name))
|
|
1088
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
1089
|
+
.slice(0, 30);
|
|
1090
|
+
if (top.length) {
|
|
1091
|
+
parts.push(`Top-level: ${top.join(", ")}`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
catch {
|
|
1095
|
+
// ignore
|
|
1096
|
+
}
|
|
1097
|
+
return parts.join("\n");
|
|
1098
|
+
}
|
|
1099
|
+
function truncateLines(text, maxLines) {
|
|
1100
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim().length);
|
|
1101
|
+
return lines.slice(0, maxLines).join("\n");
|
|
1102
|
+
}
|
|
1103
|
+
function dedupeFindings(findings) {
|
|
1104
|
+
const seen = new Map();
|
|
1105
|
+
for (const finding of findings) {
|
|
1106
|
+
const line = finding.line ?? 0;
|
|
1107
|
+
const key = `${finding.file}:${line}:${finding.id}`;
|
|
1108
|
+
if (!seen.has(key)) {
|
|
1109
|
+
seen.set(key, finding);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return Array.from(seen.values());
|
|
1113
|
+
}
|
|
1114
|
+
function mapSeverityToSarif(severity) {
|
|
1115
|
+
switch (severity) {
|
|
1116
|
+
case "critical":
|
|
1117
|
+
case "high":
|
|
1118
|
+
return "error";
|
|
1119
|
+
case "medium":
|
|
1120
|
+
return "warning";
|
|
1121
|
+
case "low":
|
|
1122
|
+
return "note";
|
|
1123
|
+
default:
|
|
1124
|
+
return "note";
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
function normalizeTraverse(value) {
|
|
1128
|
+
return value.default ?? value;
|
|
1129
|
+
}
|