@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 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
- return { findings: enriched, summary, score };
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 execFile4 } from "child_process";
5486
- import { promisify as promisify4 } from "util";
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 exec4 = promisify4(execFile4);
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 exec4("git", cloneArgs, {
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 execFile5 } from "child_process";
5774
- import { promisify as promisify5 } from "util";
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 exec5 = promisify5(execFile5);
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 exec5("npm", ["audit", "--json"], {
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 exec5("npm", ["outdated", "--json"], {
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 exec5("cargo", ["audit", "--json"], {
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 exec5(
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 execFile6 } from "child_process";
6057
- import { promisify as promisify6 } from "util";
6266
+ import { execFile as execFile7 } from "child_process";
6267
+ import { promisify as promisify7 } from "util";
6058
6268
  import path6 from "path";
6059
- var exec6 = promisify6(execFile6);
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 exec6("slither", [repoPath, "--json", "-", "--no-fail"], {
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 exec6(
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 exec6(
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(join(dir, ".elytra.yml"));
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 join2, relative } from "path";
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 = join2(dir, entry);
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(join2(projectPath, "package.json"));
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 = join2(projectPath, ".gitignore");
7525
+ const gitignorePath = join3(projectPath, ".gitignore");
7316
7526
  if (!existsSync2(gitignorePath)) {
7317
- const hasEnvFile = existsSync2(join2(projectPath, ".env")) || existsSync2(join2(projectPath, ".env.local"));
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(join2(projectPath, ".env")) || existsSync2(join2(projectPath, ".env.local")) || existsSync2(join2(projectPath, ".env.production"));
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 = join2(projectPath, "tsconfig.json");
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 = join2(projectPath, "tsconfig.json");
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 = join2(projectPath, "package.json");
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 join3, relative as relative2, extname } from "path";
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 = join3(targetPath, rel);
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(join3(dir, entry.name));
7962
+ walk(join4(dir, entry.name));
7753
7963
  } else if (entry.isFile()) {
7754
- const abs = join3(dir, entry.name);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elytrasec/engine",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Core analysis engine for Elytra — 120+ detection rules, static + AI scanning, scoring",
5
5
  "license": "MIT",
6
6
  "author": "ElytraSec <hello@elytrasec.io>",