@elytrasec/engine 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +37 -0
- package/dist/index.js +245 -204
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -218,6 +218,23 @@ declare const ReviewResultSchema: z.ZodObject<{
|
|
|
218
218
|
deducted: number;
|
|
219
219
|
}>>;
|
|
220
220
|
}>>;
|
|
221
|
+
/** keyed by "${ruleId}:${filePath}:${startLine}" — only present when runPoc=true */
|
|
222
|
+
pocs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
223
|
+
status: z.ZodEnum<["confirmed", "not-exploitable", "generated", "error"]>;
|
|
224
|
+
pocCode: z.ZodString;
|
|
225
|
+
output: z.ZodString;
|
|
226
|
+
rpcUrl: z.ZodOptional<z.ZodString>;
|
|
227
|
+
}, "strip", z.ZodTypeAny, {
|
|
228
|
+
status: "confirmed" | "not-exploitable" | "generated" | "error";
|
|
229
|
+
pocCode: string;
|
|
230
|
+
output: string;
|
|
231
|
+
rpcUrl?: string | undefined;
|
|
232
|
+
}, {
|
|
233
|
+
status: "confirmed" | "not-exploitable" | "generated" | "error";
|
|
234
|
+
pocCode: string;
|
|
235
|
+
output: string;
|
|
236
|
+
rpcUrl?: string | undefined;
|
|
237
|
+
}>>>;
|
|
221
238
|
}, "strip", z.ZodTypeAny, {
|
|
222
239
|
findings: {
|
|
223
240
|
severity: "critical" | "high" | "medium" | "low" | "info";
|
|
@@ -244,6 +261,12 @@ declare const ReviewResultSchema: z.ZodObject<{
|
|
|
244
261
|
deducted: number;
|
|
245
262
|
}>>;
|
|
246
263
|
} | undefined;
|
|
264
|
+
pocs?: Record<string, {
|
|
265
|
+
status: "confirmed" | "not-exploitable" | "generated" | "error";
|
|
266
|
+
pocCode: string;
|
|
267
|
+
output: string;
|
|
268
|
+
rpcUrl?: string | undefined;
|
|
269
|
+
}> | undefined;
|
|
247
270
|
}, {
|
|
248
271
|
findings: {
|
|
249
272
|
severity: "critical" | "high" | "medium" | "low" | "info";
|
|
@@ -270,6 +293,12 @@ declare const ReviewResultSchema: z.ZodObject<{
|
|
|
270
293
|
deducted: number;
|
|
271
294
|
}>>;
|
|
272
295
|
} | undefined;
|
|
296
|
+
pocs?: Record<string, {
|
|
297
|
+
status: "confirmed" | "not-exploitable" | "generated" | "error";
|
|
298
|
+
pocCode: string;
|
|
299
|
+
output: string;
|
|
300
|
+
rpcUrl?: string | undefined;
|
|
301
|
+
}> | undefined;
|
|
273
302
|
}>;
|
|
274
303
|
type ReviewResult = z.infer<typeof ReviewResultSchema>;
|
|
275
304
|
|
|
@@ -1213,6 +1242,14 @@ interface AnalyzeParams {
|
|
|
1213
1242
|
skipStatic?: boolean;
|
|
1214
1243
|
/** AI concurrency limit. Defaults to AI_CONCURRENCY env var or 3. */
|
|
1215
1244
|
aiConcurrency?: number;
|
|
1245
|
+
/**
|
|
1246
|
+
* Run agentic PoC generation on critical/high Solidity findings.
|
|
1247
|
+
* Requires an AI provider. If forge is installed, attempts to confirm
|
|
1248
|
+
* exploitability by running the generated test against Anvil.
|
|
1249
|
+
*/
|
|
1250
|
+
runPoc?: boolean;
|
|
1251
|
+
/** Optional RPC URL for forked Anvil PoC execution (e.g. Base/mainnet). */
|
|
1252
|
+
pocRpcUrl?: string;
|
|
1216
1253
|
}
|
|
1217
1254
|
/**
|
|
1218
1255
|
* Deduplicate findings by (filePath, startLine, ruleId).
|
package/dist/index.js
CHANGED
|
@@ -251,10 +251,18 @@ var SecurityScoreSchema = z.object({
|
|
|
251
251
|
z.object({ count: z.number(), deducted: z.number() })
|
|
252
252
|
)
|
|
253
253
|
});
|
|
254
|
+
var PocResultSchema = z.object({
|
|
255
|
+
status: z.enum(["confirmed", "not-exploitable", "generated", "error"]),
|
|
256
|
+
pocCode: z.string(),
|
|
257
|
+
output: z.string(),
|
|
258
|
+
rpcUrl: z.string().optional()
|
|
259
|
+
});
|
|
254
260
|
var ReviewResultSchema = z.object({
|
|
255
261
|
findings: z.array(FindingSchema),
|
|
256
262
|
summary: z.string(),
|
|
257
|
-
score: SecurityScoreSchema.optional()
|
|
263
|
+
score: SecurityScoreSchema.optional(),
|
|
264
|
+
/** keyed by "${ruleId}:${filePath}:${startLine}" — only present when runPoc=true */
|
|
265
|
+
pocs: z.record(PocResultSchema).optional()
|
|
258
266
|
});
|
|
259
267
|
|
|
260
268
|
// src/logger.ts
|
|
@@ -5124,6 +5132,175 @@ var patternScannerRunner = {
|
|
|
5124
5132
|
}
|
|
5125
5133
|
};
|
|
5126
5134
|
|
|
5135
|
+
// src/ai/poc-generator.ts
|
|
5136
|
+
import { execFile as execFile4 } from "child_process";
|
|
5137
|
+
import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
5138
|
+
import { promisify as promisify4 } from "util";
|
|
5139
|
+
import { tmpdir } from "os";
|
|
5140
|
+
import { join } from "path";
|
|
5141
|
+
import { randomBytes } from "crypto";
|
|
5142
|
+
var exec4 = promisify4(execFile4);
|
|
5143
|
+
var SYSTEM_PROMPT = `You are an expert smart contract security researcher who writes Foundry (forge) exploit tests to demonstrate vulnerabilities.
|
|
5144
|
+
|
|
5145
|
+
Given a security finding and the vulnerable contract source code, write a minimal Foundry test that:
|
|
5146
|
+
1. Deploys the vulnerable contract (or a minimal reproduction of the vulnerable pattern)
|
|
5147
|
+
2. Demonstrates the exploit in a test function named test_exploit()
|
|
5148
|
+
3. Uses vm.expectRevert() or assertions to show the vulnerability is real
|
|
5149
|
+
4. Includes a brief comment explaining the attack vector
|
|
5150
|
+
|
|
5151
|
+
Rules:
|
|
5152
|
+
- Use Solidity ^0.8.20 and Foundry's Test base
|
|
5153
|
+
- Import only from forge-std (available: Test, console, Vm, StdCheats)
|
|
5154
|
+
- If the contract needs an RPC fork (e.g. for on-chain state), emit a // @fork-required comment on line 1
|
|
5155
|
+
- Keep it minimal \u2014 prove the bug, nothing else
|
|
5156
|
+
- Return ONLY valid Solidity, no markdown fences, no explanation outside comments
|
|
5157
|
+
|
|
5158
|
+
Format:
|
|
5159
|
+
\`\`\`solidity
|
|
5160
|
+
// SPDX-License-Identifier: MIT
|
|
5161
|
+
pragma solidity ^0.8.20;
|
|
5162
|
+
|
|
5163
|
+
import "forge-std/Test.sol";
|
|
5164
|
+
|
|
5165
|
+
contract ExploitTest is Test {
|
|
5166
|
+
// ...setup...
|
|
5167
|
+
|
|
5168
|
+
function test_exploit() public {
|
|
5169
|
+
// ...exploit...
|
|
5170
|
+
}
|
|
5171
|
+
}
|
|
5172
|
+
\`\`\``;
|
|
5173
|
+
function buildUserMessage(finding, contractSource) {
|
|
5174
|
+
return `## Vulnerability Finding
|
|
5175
|
+
|
|
5176
|
+
Rule ID: ${finding.ruleId}
|
|
5177
|
+
Title: ${finding.title}
|
|
5178
|
+
Severity: ${finding.severity}
|
|
5179
|
+
File: ${finding.filePath} (lines ${finding.startLine}\u2013${finding.endLine})
|
|
5180
|
+
Description: ${finding.description}
|
|
5181
|
+
|
|
5182
|
+
Vulnerable code snippet:
|
|
5183
|
+
\`\`\`solidity
|
|
5184
|
+
${finding.codeSnippet}
|
|
5185
|
+
\`\`\`
|
|
5186
|
+
|
|
5187
|
+
Fix suggestion: ${finding.suggestion}
|
|
5188
|
+
|
|
5189
|
+
## Full Contract Source
|
|
5190
|
+
|
|
5191
|
+
\`\`\`solidity
|
|
5192
|
+
${contractSource}
|
|
5193
|
+
\`\`\`
|
|
5194
|
+
|
|
5195
|
+
Write the Foundry exploit test now.`;
|
|
5196
|
+
}
|
|
5197
|
+
function extractSolidity(text) {
|
|
5198
|
+
const fenceMatch = text.match(/```(?:solidity)?\s*([\s\S]*?)```/);
|
|
5199
|
+
if (fenceMatch) return fenceMatch[1].trim();
|
|
5200
|
+
return text.trim();
|
|
5201
|
+
}
|
|
5202
|
+
async function forgeAvailable() {
|
|
5203
|
+
try {
|
|
5204
|
+
await exec4("forge", ["--version"], { timeout: 5e3 });
|
|
5205
|
+
return true;
|
|
5206
|
+
} catch {
|
|
5207
|
+
return false;
|
|
5208
|
+
}
|
|
5209
|
+
}
|
|
5210
|
+
async function runForgeTest(pocCode, rpcUrl) {
|
|
5211
|
+
const id = randomBytes(6).toString("hex");
|
|
5212
|
+
const dir = join(tmpdir(), `elytra-poc-${id}`);
|
|
5213
|
+
const srcDir = join(dir, "src");
|
|
5214
|
+
const testDir = join(dir, "test");
|
|
5215
|
+
const libDir = join(dir, "lib");
|
|
5216
|
+
try {
|
|
5217
|
+
await mkdir2(srcDir, { recursive: true });
|
|
5218
|
+
await mkdir2(testDir, { recursive: true });
|
|
5219
|
+
await mkdir2(libDir, { recursive: true });
|
|
5220
|
+
await writeFile2(
|
|
5221
|
+
join(dir, "foundry.toml"),
|
|
5222
|
+
`[profile.default]
|
|
5223
|
+
src = "src"
|
|
5224
|
+
out = "out"
|
|
5225
|
+
libs = ["lib"]
|
|
5226
|
+
`
|
|
5227
|
+
);
|
|
5228
|
+
const testFile = join(testDir, "Exploit.t.sol");
|
|
5229
|
+
await writeFile2(testFile, pocCode);
|
|
5230
|
+
const args = ["test", "--match-contract", "ExploitTest", "--no-match-coverage", "-vv"];
|
|
5231
|
+
if (rpcUrl) {
|
|
5232
|
+
args.push("--fork-url", rpcUrl);
|
|
5233
|
+
}
|
|
5234
|
+
const { stdout, stderr } = await exec4("forge", args, {
|
|
5235
|
+
cwd: dir,
|
|
5236
|
+
timeout: 6e4,
|
|
5237
|
+
env: { ...process.env, FOUNDRY_PROFILE: "default" }
|
|
5238
|
+
});
|
|
5239
|
+
const output = [stdout, stderr].filter(Boolean).join("\n");
|
|
5240
|
+
const passed = output.includes("PASS") || output.includes("ok");
|
|
5241
|
+
return { success: passed, output };
|
|
5242
|
+
} catch (err) {
|
|
5243
|
+
const output = err.stdout ?? err.stderr ?? String(err);
|
|
5244
|
+
return { success: false, output };
|
|
5245
|
+
} finally {
|
|
5246
|
+
exec4("rm", ["-rf", dir]).catch(() => {
|
|
5247
|
+
});
|
|
5248
|
+
}
|
|
5249
|
+
}
|
|
5250
|
+
var PocGenerator = class {
|
|
5251
|
+
provider;
|
|
5252
|
+
constructor(provider) {
|
|
5253
|
+
this.provider = provider;
|
|
5254
|
+
}
|
|
5255
|
+
async generate(finding, contractSource, rpcUrl) {
|
|
5256
|
+
if (finding.category !== "solidity" && !finding.ruleId.startsWith("cp-sol")) {
|
|
5257
|
+
return {
|
|
5258
|
+
status: "error",
|
|
5259
|
+
pocCode: "",
|
|
5260
|
+
output: "PoC generation only supported for Solidity findings"
|
|
5261
|
+
};
|
|
5262
|
+
}
|
|
5263
|
+
let pocCode = "";
|
|
5264
|
+
try {
|
|
5265
|
+
const response = await this.provider.complete({
|
|
5266
|
+
messages: [
|
|
5267
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
5268
|
+
{ role: "user", content: buildUserMessage(finding, contractSource) }
|
|
5269
|
+
],
|
|
5270
|
+
maxTokens: 4096
|
|
5271
|
+
});
|
|
5272
|
+
pocCode = extractSolidity(response.text);
|
|
5273
|
+
if (!pocCode) {
|
|
5274
|
+
return { status: "error", pocCode: "", output: "AI returned empty response" };
|
|
5275
|
+
}
|
|
5276
|
+
} catch (err) {
|
|
5277
|
+
logger.error(`PocGenerator: AI call failed: ${err}`);
|
|
5278
|
+
return { status: "error", pocCode: "", output: String(err) };
|
|
5279
|
+
}
|
|
5280
|
+
const hasForge = await forgeAvailable();
|
|
5281
|
+
if (!hasForge) {
|
|
5282
|
+
logger.info("PocGenerator: forge not available, returning generated code only");
|
|
5283
|
+
return {
|
|
5284
|
+
status: "generated",
|
|
5285
|
+
pocCode,
|
|
5286
|
+
output: "forge not installed \u2014 PoC generated but not executed",
|
|
5287
|
+
rpcUrl
|
|
5288
|
+
};
|
|
5289
|
+
}
|
|
5290
|
+
const forkRequired = pocCode.includes("@fork-required");
|
|
5291
|
+
const { success, output } = await runForgeTest(
|
|
5292
|
+
pocCode,
|
|
5293
|
+
forkRequired ? rpcUrl : void 0
|
|
5294
|
+
);
|
|
5295
|
+
return {
|
|
5296
|
+
status: success ? "confirmed" : "not-exploitable",
|
|
5297
|
+
pocCode,
|
|
5298
|
+
output,
|
|
5299
|
+
rpcUrl: forkRequired ? rpcUrl : void 0
|
|
5300
|
+
};
|
|
5301
|
+
}
|
|
5302
|
+
};
|
|
5303
|
+
|
|
5127
5304
|
// src/analyzer.ts
|
|
5128
5305
|
var SEVERITY_ORDER2 = {
|
|
5129
5306
|
critical: 0,
|
|
@@ -5228,7 +5405,9 @@ async function analyze(params) {
|
|
|
5228
5405
|
repoPath,
|
|
5229
5406
|
staticOnly = false,
|
|
5230
5407
|
skipStatic = false,
|
|
5231
|
-
aiConcurrency
|
|
5408
|
+
aiConcurrency,
|
|
5409
|
+
runPoc = false,
|
|
5410
|
+
pocRpcUrl
|
|
5232
5411
|
} = params;
|
|
5233
5412
|
const parsed = parseDiff(diff);
|
|
5234
5413
|
if (parsed.files.length === 0) {
|
|
@@ -5346,7 +5525,38 @@ async function analyze(params) {
|
|
|
5346
5525
|
const enriched = enrichFindings(sorted);
|
|
5347
5526
|
const score = computeScore(enriched, parsed.files.length);
|
|
5348
5527
|
const summary = buildSummary(enriched, parsed.files.length, staticResult?.toolsRan);
|
|
5349
|
-
|
|
5528
|
+
let pocs;
|
|
5529
|
+
if (runPoc && (provider || apiKey)) {
|
|
5530
|
+
const pocProvider = provider ?? {
|
|
5531
|
+
name: "anthropic-poc",
|
|
5532
|
+
async complete(p) {
|
|
5533
|
+
const { AnthropicProvider: AnthropicProvider2 } = await import("./anthropic-PRRF4E3I.js");
|
|
5534
|
+
return new AnthropicProvider2(apiKey).complete(p);
|
|
5535
|
+
}
|
|
5536
|
+
};
|
|
5537
|
+
const pocGen = new PocGenerator(pocProvider);
|
|
5538
|
+
const solFindings = enriched.filter(
|
|
5539
|
+
(f) => (f.severity === "critical" || f.severity === "high") && (f.category === "solidity" || f.ruleId.startsWith("cp-sol"))
|
|
5540
|
+
);
|
|
5541
|
+
const fileContents = /* @__PURE__ */ new Map();
|
|
5542
|
+
for (const file of parsed.files) {
|
|
5543
|
+
fileContents.set(file.path, reconstructContent(file));
|
|
5544
|
+
}
|
|
5545
|
+
pocs = {};
|
|
5546
|
+
await Promise.all(
|
|
5547
|
+
solFindings.map(async (finding) => {
|
|
5548
|
+
const src = fileContents.get(finding.filePath) ?? finding.codeSnippet;
|
|
5549
|
+
const key = `${finding.ruleId}:${finding.filePath}:${finding.startLine}`;
|
|
5550
|
+
try {
|
|
5551
|
+
pocs[key] = await pocGen.generate(finding, src, pocRpcUrl);
|
|
5552
|
+
} catch (err) {
|
|
5553
|
+
logger.error(`[analyzer] PoC generation failed for ${key}: ${err}`);
|
|
5554
|
+
pocs[key] = { status: "error", pocCode: "", output: String(err) };
|
|
5555
|
+
}
|
|
5556
|
+
})
|
|
5557
|
+
);
|
|
5558
|
+
}
|
|
5559
|
+
return { findings: enriched, summary, score, ...pocs ? { pocs } : {} };
|
|
5350
5560
|
}
|
|
5351
5561
|
function enrichFindings(findings) {
|
|
5352
5562
|
return findings.map((f) => {
|
|
@@ -5482,12 +5692,12 @@ function formatStaticFindingsForAI(rawFindings) {
|
|
|
5482
5692
|
import { rm as rm2 } from "fs/promises";
|
|
5483
5693
|
|
|
5484
5694
|
// src/upgrade/ingest.ts
|
|
5485
|
-
import { execFile as
|
|
5486
|
-
import { promisify as
|
|
5695
|
+
import { execFile as execFile5 } from "child_process";
|
|
5696
|
+
import { promisify as promisify5 } from "util";
|
|
5487
5697
|
import { readdir, stat, readFile as readFile2, mkdtemp } from "fs/promises";
|
|
5488
5698
|
import path4 from "path";
|
|
5489
5699
|
import os2 from "os";
|
|
5490
|
-
var
|
|
5700
|
+
var exec5 = promisify5(execFile5);
|
|
5491
5701
|
var EXT_TO_LANG = {
|
|
5492
5702
|
".ts": "typescript",
|
|
5493
5703
|
".tsx": "typescript",
|
|
@@ -5708,7 +5918,7 @@ async function ingestRepo(repoUrl, branch) {
|
|
|
5708
5918
|
if (branch) {
|
|
5709
5919
|
cloneArgs.splice(2, 0, "--branch", branch);
|
|
5710
5920
|
}
|
|
5711
|
-
await
|
|
5921
|
+
await exec5("git", cloneArgs, {
|
|
5712
5922
|
timeout: 12e4,
|
|
5713
5923
|
maxBuffer: 200 * 1024 * 1024
|
|
5714
5924
|
});
|
|
@@ -5770,11 +5980,11 @@ async function readRepoFiles(repoPath, filePaths, maxTotalSize = 5e5) {
|
|
|
5770
5980
|
}
|
|
5771
5981
|
|
|
5772
5982
|
// src/upgrade/deps.ts
|
|
5773
|
-
import { execFile as
|
|
5774
|
-
import { promisify as
|
|
5983
|
+
import { execFile as execFile6 } from "child_process";
|
|
5984
|
+
import { promisify as promisify6 } from "util";
|
|
5775
5985
|
import { readFile as readFile3, access } from "fs/promises";
|
|
5776
5986
|
import path5 from "path";
|
|
5777
|
-
var
|
|
5987
|
+
var exec6 = promisify6(execFile6);
|
|
5778
5988
|
var DEPRECATED_REPLACEMENTS = {
|
|
5779
5989
|
"moment": "dayjs or date-fns",
|
|
5780
5990
|
"request": "node-fetch or axios or undici",
|
|
@@ -5828,7 +6038,7 @@ async function analyzeNodeDeps(repoPath) {
|
|
|
5828
6038
|
let vulnerableCount = 0;
|
|
5829
6039
|
let auditResults = {};
|
|
5830
6040
|
try {
|
|
5831
|
-
const { stdout } = await
|
|
6041
|
+
const { stdout } = await exec6("npm", ["audit", "--json"], {
|
|
5832
6042
|
cwd: repoPath,
|
|
5833
6043
|
timeout: 6e4,
|
|
5834
6044
|
maxBuffer: 20 * 1024 * 1024
|
|
@@ -5853,7 +6063,7 @@ async function analyzeNodeDeps(repoPath) {
|
|
|
5853
6063
|
}
|
|
5854
6064
|
let outdatedResults = {};
|
|
5855
6065
|
try {
|
|
5856
|
-
const { stdout } = await
|
|
6066
|
+
const { stdout } = await exec6("npm", ["outdated", "--json"], {
|
|
5857
6067
|
cwd: repoPath,
|
|
5858
6068
|
timeout: 3e4,
|
|
5859
6069
|
maxBuffer: 10 * 1024 * 1024
|
|
@@ -5913,7 +6123,7 @@ async function analyzeRustDeps(repoPath) {
|
|
|
5913
6123
|
const deps = [];
|
|
5914
6124
|
let vulnerableCount = 0;
|
|
5915
6125
|
try {
|
|
5916
|
-
const { stdout } = await
|
|
6126
|
+
const { stdout } = await exec6("cargo", ["audit", "--json"], {
|
|
5917
6127
|
cwd: repoPath,
|
|
5918
6128
|
timeout: 6e4,
|
|
5919
6129
|
maxBuffer: 20 * 1024 * 1024
|
|
@@ -5971,7 +6181,7 @@ async function analyzePythonDeps(repoPath) {
|
|
|
5971
6181
|
const deps = [];
|
|
5972
6182
|
let vulnerableCount = 0;
|
|
5973
6183
|
try {
|
|
5974
|
-
const { stdout } = await
|
|
6184
|
+
const { stdout } = await exec6(
|
|
5975
6185
|
"pip-audit",
|
|
5976
6186
|
["-r", path5.join(repoPath, "requirements.txt"), "--format", "json"],
|
|
5977
6187
|
{ cwd: repoPath, timeout: 6e4, maxBuffer: 20 * 1024 * 1024 }
|
|
@@ -6053,10 +6263,10 @@ async function analyzeDependencies(repoPath, ecosystems) {
|
|
|
6053
6263
|
}
|
|
6054
6264
|
|
|
6055
6265
|
// src/upgrade/audit.ts
|
|
6056
|
-
import { execFile as
|
|
6057
|
-
import { promisify as
|
|
6266
|
+
import { execFile as execFile7 } from "child_process";
|
|
6267
|
+
import { promisify as promisify7 } from "util";
|
|
6058
6268
|
import path6 from "path";
|
|
6059
|
-
var
|
|
6269
|
+
var exec7 = promisify7(execFile7);
|
|
6060
6270
|
async function runSlitherFullScan(repoPath, solFiles) {
|
|
6061
6271
|
if (solFiles.length === 0) return [];
|
|
6062
6272
|
const IMPACT_MAP2 = {
|
|
@@ -6069,7 +6279,7 @@ async function runSlitherFullScan(repoPath, solFiles) {
|
|
|
6069
6279
|
try {
|
|
6070
6280
|
let stdout;
|
|
6071
6281
|
try {
|
|
6072
|
-
const result = await
|
|
6282
|
+
const result = await exec7("slither", [repoPath, "--json", "-", "--no-fail"], {
|
|
6073
6283
|
timeout: 18e4,
|
|
6074
6284
|
maxBuffer: 50 * 1024 * 1024,
|
|
6075
6285
|
cwd: repoPath
|
|
@@ -6110,7 +6320,7 @@ async function runSemgrepFullScan(repoPath) {
|
|
|
6110
6320
|
try {
|
|
6111
6321
|
let stdout;
|
|
6112
6322
|
try {
|
|
6113
|
-
const result = await
|
|
6323
|
+
const result = await exec7(
|
|
6114
6324
|
"semgrep",
|
|
6115
6325
|
["--config", "p/security-audit", "--json", "--timeout", "30", repoPath],
|
|
6116
6326
|
{ timeout: 18e4, maxBuffer: 50 * 1024 * 1024, cwd: repoPath }
|
|
@@ -6141,7 +6351,7 @@ async function runGitleaksFullScan(repoPath) {
|
|
|
6141
6351
|
try {
|
|
6142
6352
|
let stdout;
|
|
6143
6353
|
try {
|
|
6144
|
-
const result = await
|
|
6354
|
+
const result = await exec7(
|
|
6145
6355
|
"gitleaks",
|
|
6146
6356
|
["detect", "--source", repoPath, "--report-format", "json", "--report-path", "/dev/stdout", "--no-git", "--no-banner"],
|
|
6147
6357
|
{ timeout: 6e4, maxBuffer: 20 * 1024 * 1024, cwd: repoPath }
|
|
@@ -6967,7 +7177,7 @@ async function rewriteFiles(params) {
|
|
|
6967
7177
|
|
|
6968
7178
|
// src/config.ts
|
|
6969
7179
|
import { readFileSync, existsSync } from "fs";
|
|
6970
|
-
import { resolve, join } from "path";
|
|
7180
|
+
import { resolve, join as join2 } from "path";
|
|
6971
7181
|
import yaml from "js-yaml";
|
|
6972
7182
|
import { z as z3 } from "zod";
|
|
6973
7183
|
var DEFAULT_CONFIG = {
|
|
@@ -7010,7 +7220,7 @@ function levenshtein(a, b) {
|
|
|
7010
7220
|
return dp[m][n];
|
|
7011
7221
|
}
|
|
7012
7222
|
function loadConfig(dir) {
|
|
7013
|
-
const configPath = resolve(
|
|
7223
|
+
const configPath = resolve(join2(dir, ".elytra.yml"));
|
|
7014
7224
|
if (!existsSync(configPath)) return null;
|
|
7015
7225
|
let raw;
|
|
7016
7226
|
try {
|
|
@@ -7105,7 +7315,7 @@ function filterByConfig(findings, config) {
|
|
|
7105
7315
|
|
|
7106
7316
|
// src/harden/harden.ts
|
|
7107
7317
|
import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2, readdirSync, statSync } from "fs";
|
|
7108
|
-
import { join as
|
|
7318
|
+
import { join as join3, relative } from "path";
|
|
7109
7319
|
function readJson(filePath) {
|
|
7110
7320
|
try {
|
|
7111
7321
|
return JSON.parse(readFileSync2(filePath, "utf-8"));
|
|
@@ -7124,7 +7334,7 @@ function walkFiles(dir, exts, maxDepth = 6, depth = 0) {
|
|
|
7124
7334
|
}
|
|
7125
7335
|
for (const entry of entries) {
|
|
7126
7336
|
if (entry === "node_modules" || entry === ".git" || entry === "dist" || entry === ".next") continue;
|
|
7127
|
-
const full =
|
|
7337
|
+
const full = join3(dir, entry);
|
|
7128
7338
|
let stat2;
|
|
7129
7339
|
try {
|
|
7130
7340
|
stat2 = statSync(full);
|
|
@@ -7171,7 +7381,7 @@ function checkHelmet(projectPath, sourceFiles, frameworks) {
|
|
|
7171
7381
|
if (!frameworks.includes("express")) return null;
|
|
7172
7382
|
const helmetFile = anyFileContains(sourceFiles, /\bhelmet\s*\(/);
|
|
7173
7383
|
if (helmetFile) return null;
|
|
7174
|
-
const pkgJson = readJson(
|
|
7384
|
+
const pkgJson = readJson(join3(projectPath, "package.json"));
|
|
7175
7385
|
const deps = {
|
|
7176
7386
|
...pkgJson?.dependencies ?? {},
|
|
7177
7387
|
...pkgJson?.devDependencies ?? {}
|
|
@@ -7312,9 +7522,9 @@ app.use(cors({
|
|
|
7312
7522
|
return null;
|
|
7313
7523
|
}
|
|
7314
7524
|
function checkEnvLeakage(projectPath) {
|
|
7315
|
-
const gitignorePath =
|
|
7525
|
+
const gitignorePath = join3(projectPath, ".gitignore");
|
|
7316
7526
|
if (!existsSync2(gitignorePath)) {
|
|
7317
|
-
const hasEnvFile = existsSync2(
|
|
7527
|
+
const hasEnvFile = existsSync2(join3(projectPath, ".env")) || existsSync2(join3(projectPath, ".env.local"));
|
|
7318
7528
|
if (!hasEnvFile) return null;
|
|
7319
7529
|
return {
|
|
7320
7530
|
id: "harden-env-no-gitignore",
|
|
@@ -7333,7 +7543,7 @@ function checkEnvLeakage(projectPath) {
|
|
|
7333
7543
|
const gitignore = readFileSync2(gitignorePath, "utf-8");
|
|
7334
7544
|
const hasEnvRule = /^\.env$/m.test(gitignore) || /^\.env\.\*$/m.test(gitignore) || /^\.env\*$/m.test(gitignore) || /^\.env\.local$/m.test(gitignore);
|
|
7335
7545
|
if (hasEnvRule) return null;
|
|
7336
|
-
const hasEnvFile = existsSync2(
|
|
7546
|
+
const hasEnvFile = existsSync2(join3(projectPath, ".env")) || existsSync2(join3(projectPath, ".env.local")) || existsSync2(join3(projectPath, ".env.production"));
|
|
7337
7547
|
if (!hasEnvFile) return null;
|
|
7338
7548
|
return {
|
|
7339
7549
|
id: "harden-env-not-gitignored",
|
|
@@ -7441,7 +7651,7 @@ res.cookie("token", value, {
|
|
|
7441
7651
|
};
|
|
7442
7652
|
}
|
|
7443
7653
|
function checkTsStrict(projectPath) {
|
|
7444
|
-
const tsconfigPath =
|
|
7654
|
+
const tsconfigPath = join3(projectPath, "tsconfig.json");
|
|
7445
7655
|
if (!existsSync2(tsconfigPath)) return null;
|
|
7446
7656
|
const tsconfig = readJson(tsconfigPath);
|
|
7447
7657
|
if (!tsconfig) return null;
|
|
@@ -7461,7 +7671,7 @@ function checkTsStrict(projectPath) {
|
|
|
7461
7671
|
function applyHardenFix(projectPath, suggestion) {
|
|
7462
7672
|
if (!suggestion.autoFixable) return false;
|
|
7463
7673
|
if (suggestion.id === "harden-ts-no-strict") {
|
|
7464
|
-
const tsconfigPath =
|
|
7674
|
+
const tsconfigPath = join3(projectPath, "tsconfig.json");
|
|
7465
7675
|
const tsconfig = readJson(tsconfigPath);
|
|
7466
7676
|
if (!tsconfig) return false;
|
|
7467
7677
|
const compilerOptions = tsconfig.compilerOptions ?? {};
|
|
@@ -7477,7 +7687,7 @@ function applyHardenFix(projectPath, suggestion) {
|
|
|
7477
7687
|
return false;
|
|
7478
7688
|
}
|
|
7479
7689
|
function runHarden(projectPath) {
|
|
7480
|
-
const pkgJsonPath =
|
|
7690
|
+
const pkgJsonPath = join3(projectPath, "package.json");
|
|
7481
7691
|
const pkgJson = readJson(pkgJsonPath);
|
|
7482
7692
|
if (!pkgJson) {
|
|
7483
7693
|
return { suggestions: [], projectPath, frameworksDetected: [] };
|
|
@@ -7588,7 +7798,7 @@ Respond with a valid JSON object:
|
|
|
7588
7798
|
// src/full-scan-discovery.ts
|
|
7589
7799
|
import { execSync } from "child_process";
|
|
7590
7800
|
import { readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
7591
|
-
import { join as
|
|
7801
|
+
import { join as join4, relative as relative2, extname } from "path";
|
|
7592
7802
|
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
7593
7803
|
".ts",
|
|
7594
7804
|
".tsx",
|
|
@@ -7717,7 +7927,7 @@ function discoverViaGit(targetPath, maxFileSizeKB) {
|
|
|
7717
7927
|
for (const line of raw.split("\n")) {
|
|
7718
7928
|
const rel = line.trim();
|
|
7719
7929
|
if (!rel || !isSourceFile(rel)) continue;
|
|
7720
|
-
const abs =
|
|
7930
|
+
const abs = join4(targetPath, rel);
|
|
7721
7931
|
try {
|
|
7722
7932
|
const stat2 = statSync2(abs);
|
|
7723
7933
|
if (!stat2.isFile()) continue;
|
|
@@ -7749,9 +7959,9 @@ function discoverViaWalk(targetPath, maxFileSizeKB) {
|
|
|
7749
7959
|
if (entry.isDirectory()) {
|
|
7750
7960
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
7751
7961
|
if (entry.name.startsWith(".") && !ALLOWED_HIDDEN_DIRS.has(entry.name)) continue;
|
|
7752
|
-
walk(
|
|
7962
|
+
walk(join4(dir, entry.name));
|
|
7753
7963
|
} else if (entry.isFile()) {
|
|
7754
|
-
const abs =
|
|
7964
|
+
const abs = join4(dir, entry.name);
|
|
7755
7965
|
const rel = relative2(targetPath, abs);
|
|
7756
7966
|
if (!isSourceFile(rel)) continue;
|
|
7757
7967
|
try {
|
|
@@ -8023,175 +8233,6 @@ function buildSummary2(findings, fileCount, toolsRan) {
|
|
|
8023
8233
|
}
|
|
8024
8234
|
return summary;
|
|
8025
8235
|
}
|
|
8026
|
-
|
|
8027
|
-
// src/ai/poc-generator.ts
|
|
8028
|
-
import { execFile as execFile7 } from "child_process";
|
|
8029
|
-
import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
8030
|
-
import { promisify as promisify7 } from "util";
|
|
8031
|
-
import { tmpdir } from "os";
|
|
8032
|
-
import { join as join4 } from "path";
|
|
8033
|
-
import { randomBytes } from "crypto";
|
|
8034
|
-
var exec7 = promisify7(execFile7);
|
|
8035
|
-
var SYSTEM_PROMPT = `You are an expert smart contract security researcher who writes Foundry (forge) exploit tests to demonstrate vulnerabilities.
|
|
8036
|
-
|
|
8037
|
-
Given a security finding and the vulnerable contract source code, write a minimal Foundry test that:
|
|
8038
|
-
1. Deploys the vulnerable contract (or a minimal reproduction of the vulnerable pattern)
|
|
8039
|
-
2. Demonstrates the exploit in a test function named test_exploit()
|
|
8040
|
-
3. Uses vm.expectRevert() or assertions to show the vulnerability is real
|
|
8041
|
-
4. Includes a brief comment explaining the attack vector
|
|
8042
|
-
|
|
8043
|
-
Rules:
|
|
8044
|
-
- Use Solidity ^0.8.20 and Foundry's Test base
|
|
8045
|
-
- Import only from forge-std (available: Test, console, Vm, StdCheats)
|
|
8046
|
-
- If the contract needs an RPC fork (e.g. for on-chain state), emit a // @fork-required comment on line 1
|
|
8047
|
-
- Keep it minimal \u2014 prove the bug, nothing else
|
|
8048
|
-
- Return ONLY valid Solidity, no markdown fences, no explanation outside comments
|
|
8049
|
-
|
|
8050
|
-
Format:
|
|
8051
|
-
\`\`\`solidity
|
|
8052
|
-
// SPDX-License-Identifier: MIT
|
|
8053
|
-
pragma solidity ^0.8.20;
|
|
8054
|
-
|
|
8055
|
-
import "forge-std/Test.sol";
|
|
8056
|
-
|
|
8057
|
-
contract ExploitTest is Test {
|
|
8058
|
-
// ...setup...
|
|
8059
|
-
|
|
8060
|
-
function test_exploit() public {
|
|
8061
|
-
// ...exploit...
|
|
8062
|
-
}
|
|
8063
|
-
}
|
|
8064
|
-
\`\`\``;
|
|
8065
|
-
function buildUserMessage(finding, contractSource) {
|
|
8066
|
-
return `## Vulnerability Finding
|
|
8067
|
-
|
|
8068
|
-
Rule ID: ${finding.ruleId}
|
|
8069
|
-
Title: ${finding.title}
|
|
8070
|
-
Severity: ${finding.severity}
|
|
8071
|
-
File: ${finding.filePath} (lines ${finding.startLine}\u2013${finding.endLine})
|
|
8072
|
-
Description: ${finding.description}
|
|
8073
|
-
|
|
8074
|
-
Vulnerable code snippet:
|
|
8075
|
-
\`\`\`solidity
|
|
8076
|
-
${finding.codeSnippet}
|
|
8077
|
-
\`\`\`
|
|
8078
|
-
|
|
8079
|
-
Fix suggestion: ${finding.suggestion}
|
|
8080
|
-
|
|
8081
|
-
## Full Contract Source
|
|
8082
|
-
|
|
8083
|
-
\`\`\`solidity
|
|
8084
|
-
${contractSource}
|
|
8085
|
-
\`\`\`
|
|
8086
|
-
|
|
8087
|
-
Write the Foundry exploit test now.`;
|
|
8088
|
-
}
|
|
8089
|
-
function extractSolidity(text) {
|
|
8090
|
-
const fenceMatch = text.match(/```(?:solidity)?\s*([\s\S]*?)```/);
|
|
8091
|
-
if (fenceMatch) return fenceMatch[1].trim();
|
|
8092
|
-
return text.trim();
|
|
8093
|
-
}
|
|
8094
|
-
async function forgeAvailable() {
|
|
8095
|
-
try {
|
|
8096
|
-
await exec7("forge", ["--version"], { timeout: 5e3 });
|
|
8097
|
-
return true;
|
|
8098
|
-
} catch {
|
|
8099
|
-
return false;
|
|
8100
|
-
}
|
|
8101
|
-
}
|
|
8102
|
-
async function runForgeTest(pocCode, rpcUrl) {
|
|
8103
|
-
const id = randomBytes(6).toString("hex");
|
|
8104
|
-
const dir = join4(tmpdir(), `elytra-poc-${id}`);
|
|
8105
|
-
const srcDir = join4(dir, "src");
|
|
8106
|
-
const testDir = join4(dir, "test");
|
|
8107
|
-
const libDir = join4(dir, "lib");
|
|
8108
|
-
try {
|
|
8109
|
-
await mkdir2(srcDir, { recursive: true });
|
|
8110
|
-
await mkdir2(testDir, { recursive: true });
|
|
8111
|
-
await mkdir2(libDir, { recursive: true });
|
|
8112
|
-
await writeFile2(
|
|
8113
|
-
join4(dir, "foundry.toml"),
|
|
8114
|
-
`[profile.default]
|
|
8115
|
-
src = "src"
|
|
8116
|
-
out = "out"
|
|
8117
|
-
libs = ["lib"]
|
|
8118
|
-
`
|
|
8119
|
-
);
|
|
8120
|
-
const testFile = join4(testDir, "Exploit.t.sol");
|
|
8121
|
-
await writeFile2(testFile, pocCode);
|
|
8122
|
-
const args = ["test", "--match-contract", "ExploitTest", "--no-match-coverage", "-vv"];
|
|
8123
|
-
if (rpcUrl) {
|
|
8124
|
-
args.push("--fork-url", rpcUrl);
|
|
8125
|
-
}
|
|
8126
|
-
const { stdout, stderr } = await exec7("forge", args, {
|
|
8127
|
-
cwd: dir,
|
|
8128
|
-
timeout: 6e4,
|
|
8129
|
-
env: { ...process.env, FOUNDRY_PROFILE: "default" }
|
|
8130
|
-
});
|
|
8131
|
-
const output = [stdout, stderr].filter(Boolean).join("\n");
|
|
8132
|
-
const passed = output.includes("PASS") || output.includes("ok");
|
|
8133
|
-
return { success: passed, output };
|
|
8134
|
-
} catch (err) {
|
|
8135
|
-
const output = err.stdout ?? err.stderr ?? String(err);
|
|
8136
|
-
return { success: false, output };
|
|
8137
|
-
} finally {
|
|
8138
|
-
exec7("rm", ["-rf", dir]).catch(() => {
|
|
8139
|
-
});
|
|
8140
|
-
}
|
|
8141
|
-
}
|
|
8142
|
-
var PocGenerator = class {
|
|
8143
|
-
provider;
|
|
8144
|
-
constructor(provider) {
|
|
8145
|
-
this.provider = provider;
|
|
8146
|
-
}
|
|
8147
|
-
async generate(finding, contractSource, rpcUrl) {
|
|
8148
|
-
if (finding.category !== "solidity" && !finding.ruleId.startsWith("cp-sol")) {
|
|
8149
|
-
return {
|
|
8150
|
-
status: "error",
|
|
8151
|
-
pocCode: "",
|
|
8152
|
-
output: "PoC generation only supported for Solidity findings"
|
|
8153
|
-
};
|
|
8154
|
-
}
|
|
8155
|
-
let pocCode = "";
|
|
8156
|
-
try {
|
|
8157
|
-
const response = await this.provider.complete({
|
|
8158
|
-
messages: [
|
|
8159
|
-
{ role: "system", content: SYSTEM_PROMPT },
|
|
8160
|
-
{ role: "user", content: buildUserMessage(finding, contractSource) }
|
|
8161
|
-
],
|
|
8162
|
-
maxTokens: 4096
|
|
8163
|
-
});
|
|
8164
|
-
pocCode = extractSolidity(response.text);
|
|
8165
|
-
if (!pocCode) {
|
|
8166
|
-
return { status: "error", pocCode: "", output: "AI returned empty response" };
|
|
8167
|
-
}
|
|
8168
|
-
} catch (err) {
|
|
8169
|
-
logger.error(`PocGenerator: AI call failed: ${err}`);
|
|
8170
|
-
return { status: "error", pocCode: "", output: String(err) };
|
|
8171
|
-
}
|
|
8172
|
-
const hasForge = await forgeAvailable();
|
|
8173
|
-
if (!hasForge) {
|
|
8174
|
-
logger.info("PocGenerator: forge not available, returning generated code only");
|
|
8175
|
-
return {
|
|
8176
|
-
status: "generated",
|
|
8177
|
-
pocCode,
|
|
8178
|
-
output: "forge not installed \u2014 PoC generated but not executed",
|
|
8179
|
-
rpcUrl
|
|
8180
|
-
};
|
|
8181
|
-
}
|
|
8182
|
-
const forkRequired = pocCode.includes("@fork-required");
|
|
8183
|
-
const { success, output } = await runForgeTest(
|
|
8184
|
-
pocCode,
|
|
8185
|
-
forkRequired ? rpcUrl : void 0
|
|
8186
|
-
);
|
|
8187
|
-
return {
|
|
8188
|
-
status: success ? "confirmed" : "not-exploitable",
|
|
8189
|
-
pocCode,
|
|
8190
|
-
output,
|
|
8191
|
-
rpcUrl: forkRequired ? rpcUrl : void 0
|
|
8192
|
-
};
|
|
8193
|
-
}
|
|
8194
|
-
};
|
|
8195
8236
|
export {
|
|
8196
8237
|
AIClient,
|
|
8197
8238
|
AnthropicProvider,
|
package/package.json
CHANGED