@elytrasec/engine 0.3.0 → 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).
@@ -1291,4 +1328,31 @@ interface DiscoverOptions {
1291
1328
  */
1292
1329
  declare function discoverFiles(targetPath: string, opts?: DiscoverOptions): DiscoveredFile[];
1293
1330
 
1294
- export { AIClient, type AICompleteParams, type AICompleteResult, type AIMessage, type AIProvider, type AnalyzeCodeParams, type AnalyzeFileParams, type AnalyzeParams, AnthropicProvider, type ApplyFixesResult, type AuditIssue, type AuditReport, CWE_OWASP_MAP, type Chain, type ChangeType, type CreateProviderOptions, type CweOwaspEntry, type DepInfo, type DepVulnerability, type DependencyReport, type DiffChange, type DiffChunk, type DiffFile, type DiffHunk, type DiscoverOptions, type DiscoveredFile, type Ecosystem, type ElytraConfig, type FileClassification, type FileFixInput, type FileFixResult, type FileStatus, type FileTransform, type Finding, FindingSchema, type Framework, type FullScanParams, type GitHubReviewComment, type HardenResult, type HardenSuggestion, type IssueCategory, type Language, MockProvider, OllamaProvider, OpenAIProvider, type ParsedDiff, type PatternRule, type ProjectFile, type ProjectSummary, type ReportOptions, type ReviewResult, ReviewResultSchema, type RewriteResult, type Rule, type SecurityScore, type SecurityScoreOutput, SecurityScoreSchema, type Severity, SeveritySchema, type StaticAnalysisOptions, type StaticAnalysisResult, type StaticFinding, type ToolRunner, type ToolRunnerOptions, type TransformResult, type UpgradeItem, type UpgradeOptions, type UpgradePlan, type UpgradeResult, type UpgradeType, ALL_RULES as _ALL_RULES, scanFile as _scanFile, analyze, analyzeDependencies, analyzeFullScan, apiRules, applyFixes, applyHardenFix, auditCodebase, authRules, bestPracticeRules, classifyFile, complexityRules, computeScore, createProvider, deadCodeRules, dedupeFindings, discoverFiles, filterByConfig, formatAsReviewComments, formatStaticFindingsForAI, gasRules, generalRules, generateFallbackPlan, generateMarkdownReport, generateUpgradePlan, getAllRules, getCweOwasp, getRulesForChains, getSystemPrompt, gitleaksRunner, ingestRepo, injectionRules, loadConfig, logger, namingRules, parseDiff, patternScannerRunner, readRepoFile, readRepoFiles, reconRules, rewriteFile, rewriteFiles, runHarden, runStaticAnalysis, runUpgrade, semgrepRunner, slitherRunner, solidityRules, splitIntoChunks, transformFiles, validateFixedSyntax };
1331
+ /**
1332
+ * Agentic PoC generator.
1333
+ *
1334
+ * Given a Finding from our scanner and the vulnerable contract source,
1335
+ * asks Claude to write a Foundry exploit test, then tries to run it
1336
+ * against an Anvil fork to confirm the vulnerability is actually exploitable.
1337
+ *
1338
+ * Returns a PocResult indicating whether the finding was confirmed, along
1339
+ * with the generated test and execution output.
1340
+ *
1341
+ * Degrades gracefully when forge/anvil are not installed — the PoC code
1342
+ * is still generated, status is "generated" rather than "confirmed".
1343
+ */
1344
+
1345
+ type PocStatus = "confirmed" | "not-exploitable" | "generated" | "error";
1346
+ interface PocResult {
1347
+ status: PocStatus;
1348
+ pocCode: string;
1349
+ output: string;
1350
+ rpcUrl?: string;
1351
+ }
1352
+ declare class PocGenerator {
1353
+ private provider;
1354
+ constructor(provider: AIProvider);
1355
+ generate(finding: Finding, contractSource: string, rpcUrl?: string): Promise<PocResult>;
1356
+ }
1357
+
1358
+ export { AIClient, type AICompleteParams, type AICompleteResult, type AIMessage, type AIProvider, type AnalyzeCodeParams, type AnalyzeFileParams, type AnalyzeParams, AnthropicProvider, type ApplyFixesResult, type AuditIssue, type AuditReport, CWE_OWASP_MAP, type Chain, type ChangeType, type CreateProviderOptions, type CweOwaspEntry, type DepInfo, type DepVulnerability, type DependencyReport, type DiffChange, type DiffChunk, type DiffFile, type DiffHunk, type DiscoverOptions, type DiscoveredFile, type Ecosystem, type ElytraConfig, type FileClassification, type FileFixInput, type FileFixResult, type FileStatus, type FileTransform, type Finding, FindingSchema, type Framework, type FullScanParams, type GitHubReviewComment, type HardenResult, type HardenSuggestion, type IssueCategory, type Language, MockProvider, OllamaProvider, OpenAIProvider, type ParsedDiff, type PatternRule, PocGenerator, type PocResult, type PocStatus, type ProjectFile, type ProjectSummary, type ReportOptions, type ReviewResult, ReviewResultSchema, type RewriteResult, type Rule, type SecurityScore, type SecurityScoreOutput, SecurityScoreSchema, type Severity, SeveritySchema, type StaticAnalysisOptions, type StaticAnalysisResult, type StaticFinding, type ToolRunner, type ToolRunnerOptions, type TransformResult, type UpgradeItem, type UpgradeOptions, type UpgradePlan, type UpgradeResult, type UpgradeType, ALL_RULES as _ALL_RULES, scanFile as _scanFile, analyze, analyzeDependencies, analyzeFullScan, apiRules, applyFixes, applyHardenFix, auditCodebase, authRules, bestPracticeRules, classifyFile, complexityRules, computeScore, createProvider, deadCodeRules, dedupeFindings, discoverFiles, filterByConfig, formatAsReviewComments, formatStaticFindingsForAI, gasRules, generalRules, generateFallbackPlan, generateMarkdownReport, generateUpgradePlan, getAllRules, getCweOwasp, getRulesForChains, getSystemPrompt, gitleaksRunner, ingestRepo, injectionRules, loadConfig, logger, namingRules, parseDiff, patternScannerRunner, readRepoFile, readRepoFiles, reconRules, rewriteFile, rewriteFiles, runHarden, runStaticAnalysis, runUpgrade, semgrepRunner, slitherRunner, solidityRules, splitIntoChunks, transformFiles, validateFixedSyntax };
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
@@ -4603,6 +4611,112 @@ var iacRules = [
4603
4611
  pathPattern: /\.github[/\\]workflows[/\\]/
4604
4612
  }
4605
4613
  ];
4614
+ var eip7702Rules = [
4615
+ {
4616
+ id: "cp-sol-7702-unguarded-init",
4617
+ title: "EIP-7702 delegation: unguarded initializer",
4618
+ description: "Contracts used as EIP-7702 delegation targets must guard initializers \u2014 constructors don't run on delegated EOAs. An unguarded `initialize()` can be front-run, letting an attacker take ownership of the delegated account.",
4619
+ suggestion: "Add an `initializer` modifier (OpenZeppelin) or an `initialized` flag checked at the top of every init function. Ensure no init path is callable twice.",
4620
+ pattern: /function\s+initialize\s*\([^)]*\)\s*(?:external|public)(?!\s+\w*[Ii]nitializer)/,
4621
+ severity: "critical",
4622
+ category: "solidity",
4623
+ confidence: "medium",
4624
+ languages: SOL
4625
+ },
4626
+ {
4627
+ id: "cp-sol-7702-nonce-race",
4628
+ title: "EIP-7702 delegation: nonce race condition",
4629
+ description: "EIP-7702 authorization tuples share the EOA nonce space with regular transactions. A pending delegation can be invalidated or front-run by submitting a regular transaction that increments the nonce before the authorization is mined.",
4630
+ suggestion: "Always set `nonce` explicitly in authorization tuples. Do not rely on mempool ordering for security-sensitive delegations \u2014 use a commit-reveal or atomic multicall pattern.",
4631
+ pattern: /authorization\s*=\s*\{[^}]*nonce\s*:\s*0[^x]/i,
4632
+ severity: "high",
4633
+ category: "solidity",
4634
+ confidence: "medium",
4635
+ languages: SOL
4636
+ }
4637
+ ];
4638
+ var tstoreRules = [
4639
+ {
4640
+ id: "cp-sol-tstore-reentrancy",
4641
+ title: "Transient storage (TSTORE) used \u2014 verify reentrancy safety",
4642
+ description: "Transient storage (EIP-1153, live since Cancun) does not impose the 2300 gas minimum of SSTORE-based guards. If this contract also uses `transfer()` or `send()` for ETH, those calls are no longer reentrancy-safe \u2014 the callee has enough gas to re-enter via TLOAD/TSTORE paths.",
4643
+ suggestion: 'Audit every ETH transfer in this contract. Replace `transfer()`/`send()` with `call{value:}("")` + explicit reentrancy check. Ensure TSTORE lock is written before any external call.',
4644
+ pattern: /assembly\s*\{[^}]*tstore\s*\(|TransientSlot|tstore\s*\(/i,
4645
+ severity: "high",
4646
+ category: "solidity",
4647
+ confidence: "medium",
4648
+ languages: SOL
4649
+ },
4650
+ {
4651
+ id: "cp-sol-tstore-lock-pattern",
4652
+ title: "Transient storage lock: verify all entry points are guarded",
4653
+ description: "TSTORE-based reentrancy locks reset at transaction end (not call end). A lock set in `functionA` does NOT protect `functionB` called in a separate transaction, and a lock set mid-call does NOT protect earlier entry points in the same call chain.",
4654
+ suggestion: "Apply the TSTORE lock modifier to every external/public function that modifies state, not just the function that initiates the ETH transfer.",
4655
+ pattern: /assembly\s*\{[^}]*tload\s*\([^}]*\)[^}]*tstore\s*\(/is,
4656
+ severity: "medium",
4657
+ category: "solidity",
4658
+ confidence: "low",
4659
+ languages: SOL
4660
+ }
4661
+ ];
4662
+ var uniswapV4Rules = [
4663
+ {
4664
+ id: "cp-sol-v4hook-missing-sender-check",
4665
+ title: "Uniswap v4 hook: missing PoolManager sender validation",
4666
+ description: "Hook callbacks (`beforeSwap`, `afterSwap`, `beforeAddLiquidity`, etc.) must verify `msg.sender == address(poolManager)`. Without this check, any address can call the hook directly and manipulate its state.",
4667
+ suggestion: 'Add `require(msg.sender == address(poolManager), "not PoolManager");` as the first line of every hook callback, or inherit from `BaseHook` which enforces this automatically.',
4668
+ pattern: /function\s+(?:before|after)(?:Swap|AddLiquidity|RemoveLiquidity|Initialize|Donate)\s*\(/,
4669
+ severity: "critical",
4670
+ category: "solidity",
4671
+ confidence: "medium",
4672
+ languages: SOL
4673
+ },
4674
+ {
4675
+ id: "cp-sol-v4hook-reentrancy-callback",
4676
+ title: "Uniswap v4 hook: external call inside hook callback",
4677
+ description: "Making external calls from within a hook callback (beforeSwap, afterSwap, etc.) while the PoolManager lock is held can trigger reentrancy into the pool. The singleton PoolManager uses a transient-storage lock that is bypassable via hooks that call back into the pool.",
4678
+ suggestion: "Avoid external calls in hook callbacks. If you must call out, use the `unlockCallback` pattern to queue actions and execute them after the current lock cycle completes.",
4679
+ pattern: /function\s+(?:before|after)(?:Swap|AddLiquidity|RemoveLiquidity|Initialize|Donate)[\s\S]{0,500}?\.call\s*\{/,
4680
+ multilinePattern: /function\s+(before|after)(Swap|AddLiquidity|RemoveLiquidity|Initialize|Donate)\s*\([^)]*\)[^{]*\{[\s\S]{0,800}?(?:\.call\s*\{|\.transfer\s*\(|\.send\s*\()/gm,
4681
+ severity: "critical",
4682
+ category: "solidity",
4683
+ confidence: "medium",
4684
+ languages: SOL
4685
+ },
4686
+ {
4687
+ id: "cp-sol-v4hook-delta-not-consumed",
4688
+ title: "Uniswap v4 hook: BalanceDelta returned without settle/take",
4689
+ description: "Hooks that return a modified `BalanceDelta` must ensure the delta is fully consumed (settled or taken) within the same unlock cycle. An unconsumed delta causes `CurrencyNotSettled` revert; partial consumption can silently strand tokens.",
4690
+ suggestion: "Ensure `poolManager.settle()` or `poolManager.take()` is called for both currency0 and currency1 before returning from the unlock callback.",
4691
+ pattern: /returns\s*\([^)]*BalanceDelta[^)]*\)(?![\s\S]{0,800}?(?:settle|\.take)\s*\()/,
4692
+ severity: "high",
4693
+ category: "solidity",
4694
+ confidence: "low",
4695
+ languages: SOL
4696
+ },
4697
+ {
4698
+ id: "cp-sol-v4hook-unrestricted-permissions",
4699
+ title: "Uniswap v4 hook: overly broad hook permissions",
4700
+ description: "Hook permissions are set at deployment via the address bit-flags and cannot be changed. Requesting permissions you don't need (e.g. `BEFORE_SWAP_RETURNS_DELTA` when you never return a delta) increases attack surface and gas costs for every pool interaction.",
4701
+ suggestion: "Return only the minimum required `Hooks.Permissions` from `getHookPermissions()`. Audit each permission flag against your actual callback implementations.",
4702
+ pattern: /getHookPermissions\s*\(\s*\)\s*(?:public|external|override)[^{]*\{[\s\S]{0,300}?true/,
4703
+ severity: "medium",
4704
+ category: "solidity",
4705
+ confidence: "low",
4706
+ languages: SOL
4707
+ },
4708
+ {
4709
+ id: "cp-sol-v4hook-sqrt-price-manipulation",
4710
+ title: "Uniswap v4 hook: spot sqrtPriceX96 used for pricing without limit",
4711
+ description: "Using spot `sqrtPriceX96` read from pool state inside a hook callback for pricing decisions without a `sqrtPriceLimitX96` bound is sandwich-attackable. The price reflects post-swap state which may be manipulated.",
4712
+ suggestion: "Pass `sqrtPriceLimitX96` to all swap calls from hooks. Use a TWAP oracle for pricing rather than spot price.",
4713
+ pattern: /sqrtPriceX96\s*[*/+-]\s*\w|sqrtPriceX96\s*=\s*(?!.*[Ll]imit)/,
4714
+ severity: "high",
4715
+ category: "solidity",
4716
+ confidence: "low",
4717
+ languages: SOL
4718
+ }
4719
+ ];
4606
4720
  var ALL_RULES2 = [
4607
4721
  ...securityRules,
4608
4722
  ...solidityRules2,
@@ -4613,7 +4727,10 @@ var ALL_RULES2 = [
4613
4727
  ...performanceRules,
4614
4728
  ...cleaningRules,
4615
4729
  ...reactRules,
4616
- ...iacRules
4730
+ ...iacRules,
4731
+ ...eip7702Rules,
4732
+ ...tstoreRules,
4733
+ ...uniswapV4Rules
4617
4734
  ];
4618
4735
 
4619
4736
  // src/static/pattern-scanner.ts
@@ -5015,6 +5132,175 @@ var patternScannerRunner = {
5015
5132
  }
5016
5133
  };
5017
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
+
5018
5304
  // src/analyzer.ts
5019
5305
  var SEVERITY_ORDER2 = {
5020
5306
  critical: 0,
@@ -5119,7 +5405,9 @@ async function analyze(params) {
5119
5405
  repoPath,
5120
5406
  staticOnly = false,
5121
5407
  skipStatic = false,
5122
- aiConcurrency
5408
+ aiConcurrency,
5409
+ runPoc = false,
5410
+ pocRpcUrl
5123
5411
  } = params;
5124
5412
  const parsed = parseDiff(diff);
5125
5413
  if (parsed.files.length === 0) {
@@ -5237,7 +5525,38 @@ async function analyze(params) {
5237
5525
  const enriched = enrichFindings(sorted);
5238
5526
  const score = computeScore(enriched, parsed.files.length);
5239
5527
  const summary = buildSummary(enriched, parsed.files.length, staticResult?.toolsRan);
5240
- 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 } : {} };
5241
5560
  }
5242
5561
  function enrichFindings(findings) {
5243
5562
  return findings.map((f) => {
@@ -5373,12 +5692,12 @@ function formatStaticFindingsForAI(rawFindings) {
5373
5692
  import { rm as rm2 } from "fs/promises";
5374
5693
 
5375
5694
  // src/upgrade/ingest.ts
5376
- import { execFile as execFile4 } from "child_process";
5377
- import { promisify as promisify4 } from "util";
5695
+ import { execFile as execFile5 } from "child_process";
5696
+ import { promisify as promisify5 } from "util";
5378
5697
  import { readdir, stat, readFile as readFile2, mkdtemp } from "fs/promises";
5379
5698
  import path4 from "path";
5380
5699
  import os2 from "os";
5381
- var exec4 = promisify4(execFile4);
5700
+ var exec5 = promisify5(execFile5);
5382
5701
  var EXT_TO_LANG = {
5383
5702
  ".ts": "typescript",
5384
5703
  ".tsx": "typescript",
@@ -5599,7 +5918,7 @@ async function ingestRepo(repoUrl, branch) {
5599
5918
  if (branch) {
5600
5919
  cloneArgs.splice(2, 0, "--branch", branch);
5601
5920
  }
5602
- await exec4("git", cloneArgs, {
5921
+ await exec5("git", cloneArgs, {
5603
5922
  timeout: 12e4,
5604
5923
  maxBuffer: 200 * 1024 * 1024
5605
5924
  });
@@ -5661,11 +5980,11 @@ async function readRepoFiles(repoPath, filePaths, maxTotalSize = 5e5) {
5661
5980
  }
5662
5981
 
5663
5982
  // src/upgrade/deps.ts
5664
- import { execFile as execFile5 } from "child_process";
5665
- import { promisify as promisify5 } from "util";
5983
+ import { execFile as execFile6 } from "child_process";
5984
+ import { promisify as promisify6 } from "util";
5666
5985
  import { readFile as readFile3, access } from "fs/promises";
5667
5986
  import path5 from "path";
5668
- var exec5 = promisify5(execFile5);
5987
+ var exec6 = promisify6(execFile6);
5669
5988
  var DEPRECATED_REPLACEMENTS = {
5670
5989
  "moment": "dayjs or date-fns",
5671
5990
  "request": "node-fetch or axios or undici",
@@ -5719,7 +6038,7 @@ async function analyzeNodeDeps(repoPath) {
5719
6038
  let vulnerableCount = 0;
5720
6039
  let auditResults = {};
5721
6040
  try {
5722
- const { stdout } = await exec5("npm", ["audit", "--json"], {
6041
+ const { stdout } = await exec6("npm", ["audit", "--json"], {
5723
6042
  cwd: repoPath,
5724
6043
  timeout: 6e4,
5725
6044
  maxBuffer: 20 * 1024 * 1024
@@ -5744,7 +6063,7 @@ async function analyzeNodeDeps(repoPath) {
5744
6063
  }
5745
6064
  let outdatedResults = {};
5746
6065
  try {
5747
- const { stdout } = await exec5("npm", ["outdated", "--json"], {
6066
+ const { stdout } = await exec6("npm", ["outdated", "--json"], {
5748
6067
  cwd: repoPath,
5749
6068
  timeout: 3e4,
5750
6069
  maxBuffer: 10 * 1024 * 1024
@@ -5804,7 +6123,7 @@ async function analyzeRustDeps(repoPath) {
5804
6123
  const deps = [];
5805
6124
  let vulnerableCount = 0;
5806
6125
  try {
5807
- const { stdout } = await exec5("cargo", ["audit", "--json"], {
6126
+ const { stdout } = await exec6("cargo", ["audit", "--json"], {
5808
6127
  cwd: repoPath,
5809
6128
  timeout: 6e4,
5810
6129
  maxBuffer: 20 * 1024 * 1024
@@ -5862,7 +6181,7 @@ async function analyzePythonDeps(repoPath) {
5862
6181
  const deps = [];
5863
6182
  let vulnerableCount = 0;
5864
6183
  try {
5865
- const { stdout } = await exec5(
6184
+ const { stdout } = await exec6(
5866
6185
  "pip-audit",
5867
6186
  ["-r", path5.join(repoPath, "requirements.txt"), "--format", "json"],
5868
6187
  { cwd: repoPath, timeout: 6e4, maxBuffer: 20 * 1024 * 1024 }
@@ -5944,10 +6263,10 @@ async function analyzeDependencies(repoPath, ecosystems) {
5944
6263
  }
5945
6264
 
5946
6265
  // src/upgrade/audit.ts
5947
- import { execFile as execFile6 } from "child_process";
5948
- import { promisify as promisify6 } from "util";
6266
+ import { execFile as execFile7 } from "child_process";
6267
+ import { promisify as promisify7 } from "util";
5949
6268
  import path6 from "path";
5950
- var exec6 = promisify6(execFile6);
6269
+ var exec7 = promisify7(execFile7);
5951
6270
  async function runSlitherFullScan(repoPath, solFiles) {
5952
6271
  if (solFiles.length === 0) return [];
5953
6272
  const IMPACT_MAP2 = {
@@ -5960,7 +6279,7 @@ async function runSlitherFullScan(repoPath, solFiles) {
5960
6279
  try {
5961
6280
  let stdout;
5962
6281
  try {
5963
- const result = await exec6("slither", [repoPath, "--json", "-", "--no-fail"], {
6282
+ const result = await exec7("slither", [repoPath, "--json", "-", "--no-fail"], {
5964
6283
  timeout: 18e4,
5965
6284
  maxBuffer: 50 * 1024 * 1024,
5966
6285
  cwd: repoPath
@@ -6001,7 +6320,7 @@ async function runSemgrepFullScan(repoPath) {
6001
6320
  try {
6002
6321
  let stdout;
6003
6322
  try {
6004
- const result = await exec6(
6323
+ const result = await exec7(
6005
6324
  "semgrep",
6006
6325
  ["--config", "p/security-audit", "--json", "--timeout", "30", repoPath],
6007
6326
  { timeout: 18e4, maxBuffer: 50 * 1024 * 1024, cwd: repoPath }
@@ -6032,7 +6351,7 @@ async function runGitleaksFullScan(repoPath) {
6032
6351
  try {
6033
6352
  let stdout;
6034
6353
  try {
6035
- const result = await exec6(
6354
+ const result = await exec7(
6036
6355
  "gitleaks",
6037
6356
  ["detect", "--source", repoPath, "--report-format", "json", "--report-path", "/dev/stdout", "--no-git", "--no-banner"],
6038
6357
  { timeout: 6e4, maxBuffer: 20 * 1024 * 1024, cwd: repoPath }
@@ -6858,7 +7177,7 @@ async function rewriteFiles(params) {
6858
7177
 
6859
7178
  // src/config.ts
6860
7179
  import { readFileSync, existsSync } from "fs";
6861
- import { resolve, join } from "path";
7180
+ import { resolve, join as join2 } from "path";
6862
7181
  import yaml from "js-yaml";
6863
7182
  import { z as z3 } from "zod";
6864
7183
  var DEFAULT_CONFIG = {
@@ -6901,7 +7220,7 @@ function levenshtein(a, b) {
6901
7220
  return dp[m][n];
6902
7221
  }
6903
7222
  function loadConfig(dir) {
6904
- const configPath = resolve(join(dir, ".elytra.yml"));
7223
+ const configPath = resolve(join2(dir, ".elytra.yml"));
6905
7224
  if (!existsSync(configPath)) return null;
6906
7225
  let raw;
6907
7226
  try {
@@ -6996,7 +7315,7 @@ function filterByConfig(findings, config) {
6996
7315
 
6997
7316
  // src/harden/harden.ts
6998
7317
  import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2, readdirSync, statSync } from "fs";
6999
- import { join as join2, relative } from "path";
7318
+ import { join as join3, relative } from "path";
7000
7319
  function readJson(filePath) {
7001
7320
  try {
7002
7321
  return JSON.parse(readFileSync2(filePath, "utf-8"));
@@ -7015,7 +7334,7 @@ function walkFiles(dir, exts, maxDepth = 6, depth = 0) {
7015
7334
  }
7016
7335
  for (const entry of entries) {
7017
7336
  if (entry === "node_modules" || entry === ".git" || entry === "dist" || entry === ".next") continue;
7018
- const full = join2(dir, entry);
7337
+ const full = join3(dir, entry);
7019
7338
  let stat2;
7020
7339
  try {
7021
7340
  stat2 = statSync(full);
@@ -7062,7 +7381,7 @@ function checkHelmet(projectPath, sourceFiles, frameworks) {
7062
7381
  if (!frameworks.includes("express")) return null;
7063
7382
  const helmetFile = anyFileContains(sourceFiles, /\bhelmet\s*\(/);
7064
7383
  if (helmetFile) return null;
7065
- const pkgJson = readJson(join2(projectPath, "package.json"));
7384
+ const pkgJson = readJson(join3(projectPath, "package.json"));
7066
7385
  const deps = {
7067
7386
  ...pkgJson?.dependencies ?? {},
7068
7387
  ...pkgJson?.devDependencies ?? {}
@@ -7203,9 +7522,9 @@ app.use(cors({
7203
7522
  return null;
7204
7523
  }
7205
7524
  function checkEnvLeakage(projectPath) {
7206
- const gitignorePath = join2(projectPath, ".gitignore");
7525
+ const gitignorePath = join3(projectPath, ".gitignore");
7207
7526
  if (!existsSync2(gitignorePath)) {
7208
- const hasEnvFile = existsSync2(join2(projectPath, ".env")) || existsSync2(join2(projectPath, ".env.local"));
7527
+ const hasEnvFile = existsSync2(join3(projectPath, ".env")) || existsSync2(join3(projectPath, ".env.local"));
7209
7528
  if (!hasEnvFile) return null;
7210
7529
  return {
7211
7530
  id: "harden-env-no-gitignore",
@@ -7224,7 +7543,7 @@ function checkEnvLeakage(projectPath) {
7224
7543
  const gitignore = readFileSync2(gitignorePath, "utf-8");
7225
7544
  const hasEnvRule = /^\.env$/m.test(gitignore) || /^\.env\.\*$/m.test(gitignore) || /^\.env\*$/m.test(gitignore) || /^\.env\.local$/m.test(gitignore);
7226
7545
  if (hasEnvRule) return null;
7227
- 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"));
7228
7547
  if (!hasEnvFile) return null;
7229
7548
  return {
7230
7549
  id: "harden-env-not-gitignored",
@@ -7332,7 +7651,7 @@ res.cookie("token", value, {
7332
7651
  };
7333
7652
  }
7334
7653
  function checkTsStrict(projectPath) {
7335
- const tsconfigPath = join2(projectPath, "tsconfig.json");
7654
+ const tsconfigPath = join3(projectPath, "tsconfig.json");
7336
7655
  if (!existsSync2(tsconfigPath)) return null;
7337
7656
  const tsconfig = readJson(tsconfigPath);
7338
7657
  if (!tsconfig) return null;
@@ -7352,7 +7671,7 @@ function checkTsStrict(projectPath) {
7352
7671
  function applyHardenFix(projectPath, suggestion) {
7353
7672
  if (!suggestion.autoFixable) return false;
7354
7673
  if (suggestion.id === "harden-ts-no-strict") {
7355
- const tsconfigPath = join2(projectPath, "tsconfig.json");
7674
+ const tsconfigPath = join3(projectPath, "tsconfig.json");
7356
7675
  const tsconfig = readJson(tsconfigPath);
7357
7676
  if (!tsconfig) return false;
7358
7677
  const compilerOptions = tsconfig.compilerOptions ?? {};
@@ -7368,7 +7687,7 @@ function applyHardenFix(projectPath, suggestion) {
7368
7687
  return false;
7369
7688
  }
7370
7689
  function runHarden(projectPath) {
7371
- const pkgJsonPath = join2(projectPath, "package.json");
7690
+ const pkgJsonPath = join3(projectPath, "package.json");
7372
7691
  const pkgJson = readJson(pkgJsonPath);
7373
7692
  if (!pkgJson) {
7374
7693
  return { suggestions: [], projectPath, frameworksDetected: [] };
@@ -7479,7 +7798,7 @@ Respond with a valid JSON object:
7479
7798
  // src/full-scan-discovery.ts
7480
7799
  import { execSync } from "child_process";
7481
7800
  import { readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7482
- import { join as join3, relative as relative2, extname } from "path";
7801
+ import { join as join4, relative as relative2, extname } from "path";
7483
7802
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
7484
7803
  ".ts",
7485
7804
  ".tsx",
@@ -7608,7 +7927,7 @@ function discoverViaGit(targetPath, maxFileSizeKB) {
7608
7927
  for (const line of raw.split("\n")) {
7609
7928
  const rel = line.trim();
7610
7929
  if (!rel || !isSourceFile(rel)) continue;
7611
- const abs = join3(targetPath, rel);
7930
+ const abs = join4(targetPath, rel);
7612
7931
  try {
7613
7932
  const stat2 = statSync2(abs);
7614
7933
  if (!stat2.isFile()) continue;
@@ -7640,9 +7959,9 @@ function discoverViaWalk(targetPath, maxFileSizeKB) {
7640
7959
  if (entry.isDirectory()) {
7641
7960
  if (SKIP_DIRS.has(entry.name)) continue;
7642
7961
  if (entry.name.startsWith(".") && !ALLOWED_HIDDEN_DIRS.has(entry.name)) continue;
7643
- walk(join3(dir, entry.name));
7962
+ walk(join4(dir, entry.name));
7644
7963
  } else if (entry.isFile()) {
7645
- const abs = join3(dir, entry.name);
7964
+ const abs = join4(dir, entry.name);
7646
7965
  const rel = relative2(targetPath, abs);
7647
7966
  if (!isSourceFile(rel)) continue;
7648
7967
  try {
@@ -7922,6 +8241,7 @@ export {
7922
8241
  MockProvider,
7923
8242
  OllamaProvider,
7924
8243
  OpenAIProvider,
8244
+ PocGenerator,
7925
8245
  ReviewResultSchema,
7926
8246
  SecurityScoreSchema,
7927
8247
  SeveritySchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elytrasec/engine",
3
- "version": "0.3.0",
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>",