@elytrasec/engine 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -2949,12 +2957,43 @@ var securityRules = [
2949
2957
  title: "Hardcoded secret or credential",
2950
2958
  description: "A string that looks like a password, API key, or token is hardcoded in source code.",
2951
2959
  suggestion: "Move the secret to an environment variable or a secrets manager. Rotate the exposed credential immediately.",
2952
- pattern: /(?:password|secret|api_key|apikey|api_secret|token|auth_token|private_key|client_secret)\s*[:=]\s*["'](?!changeme|password|your-|<|TODO|xxx|test|example|placeholder|REPLACE)[^"']{8,}["']/i,
2960
+ // `token` removed from the keyword list — too ambiguous in crypto context (tokenAddress,
2961
+ // token0, token1, tokenContract, tokenFactory all match and are public addresses, not secrets).
2962
+ // `auth_token`, `accessToken`, `bearerToken`, `jwt_token` etc. remain via specific patterns below.
2963
+ pattern: /(?:password|secret|apikey|api_key|api_secret|auth[_-]?token|access[_-]?token|bearer[_-]?token|jwt[_-]?secret|private[_-]?key|client_secret)\s*[:=]\s*["'](?!changeme|password|your-|<|TODO|xxx|test|example|placeholder|REPLACE|0x[0-9a-fA-F]{40}["'])[^"']{8,}["']/i,
2953
2964
  severity: "critical",
2954
2965
  category: "security",
2955
2966
  confidence: "medium",
2956
2967
  languages: ALL
2957
2968
  },
2969
+ {
2970
+ // Crypto-native: a raw 32-byte (64-hex) value assigned to a *private*-key-shaped name.
2971
+ // Wallet/contract addresses (40 hex) are public — never flag those.
2972
+ // Hashes / UIDs / merkle roots (64 hex) on a `*Hash`, `*Uid`, `*Root` LHS are public — don't flag.
2973
+ // Only flag when the LHS specifically suggests a SIGNING KEY.
2974
+ id: "cp-sec-private-key-hex",
2975
+ title: "Hardcoded EVM private key",
2976
+ description: "A 64-character hex value is assigned to a variable whose name implies it's a signing key. If real, this is a wallet private key \u2014 anyone who reads the source can drain the wallet.",
2977
+ suggestion: "Move to an environment variable, KMS, or hardware wallet immediately. Rotate the key (transfer all assets to a new wallet and never use this key again).",
2978
+ pattern: /\b(?:private[_-]?key|signing[_-]?key|wallet[_-]?key|signer[_-]?key|deployer[_-]?key|mnemonic[_-]?seed|raw[_-]?key|eas_private_key|x402_wallet_key)\s*[:=]\s*["']?0x[0-9a-fA-F]{64}["']?/i,
2979
+ severity: "critical",
2980
+ category: "security",
2981
+ confidence: "high",
2982
+ languages: ALL
2983
+ },
2984
+ {
2985
+ // BIP-39 seed phrase — 12 or 24 lowercase English words in a row.
2986
+ // Any leak of these = full wallet compromise. Targets the common assignment pattern.
2987
+ id: "cp-sec-mnemonic-phrase",
2988
+ title: "Hardcoded BIP-39 mnemonic / seed phrase",
2989
+ description: "A string with 12 or 24 lowercase words looks like a BIP-39 mnemonic. A leaked seed phrase compromises the entire HD wallet and every account derived from it.",
2990
+ suggestion: "Never store mnemonics in source. Use a secure secret manager. If exposed, immediately transfer all assets out of every derived account, never reuse the seed.",
2991
+ pattern: /(?:mnemonic|seed[_-]?phrase|secret[_-]?phrase|backup[_-]?phrase)\s*[:=]\s*["'](?:[a-z]{3,8}\s+){11,23}[a-z]{3,8}["']/i,
2992
+ severity: "critical",
2993
+ category: "security",
2994
+ confidence: "high",
2995
+ languages: ALL
2996
+ },
2958
2997
  {
2959
2998
  id: "cp-sec-hardcoded-ip",
2960
2999
  title: "Hardcoded IP address",
@@ -3301,13 +3340,13 @@ var securityRules = [
3301
3340
  var solidityRules2 = [
3302
3341
  {
3303
3342
  id: "cp-sol-reentrancy",
3304
- title: "Potential reentrancy \u2014 external call before state update",
3305
- description: "An external call (call, send, transfer) appears before a state variable assignment. An attacker contract can re-enter before state is updated.",
3306
- suggestion: "Follow the checks-effects-interactions pattern: update state before making external calls. Consider using ReentrancyGuard.",
3307
- pattern: /\.(?:call|send|transfer)\s*[\({]/,
3308
- severity: "critical",
3343
+ title: "Potential reentrancy \u2014 review external call sequencing",
3344
+ description: "An external call via `.call{value:...}` is present. The classic reentrancy pattern requires the call to occur BEFORE a state update \u2014 a same-line regex can't verify the order across multiple lines, so this is flagged for manual or AI review. `.send` and `.transfer` are intentionally not flagged because their 2300-gas stipend prevents reentrancy except in pathological cases (post-EIP-1884 gas changes).",
3345
+ suggestion: "Follow checks-effects-interactions: update state before external calls. Or use OpenZeppelin's ReentrancyGuard. Confirm by reading the surrounding function or running AI deep-review.",
3346
+ pattern: /\.call\s*\{(?:[^}]*value[^}]*)?\}/,
3347
+ severity: "high",
3309
3348
  category: "security",
3310
- confidence: "medium",
3349
+ confidence: "low",
3311
3350
  languages: SOL
3312
3351
  },
3313
3352
  {
@@ -3391,12 +3430,12 @@ var solidityRules2 = [
3391
3430
  {
3392
3431
  id: "cp-sol-assembly",
3393
3432
  title: "Inline assembly usage",
3394
- description: "Inline assembly bypasses Solidity's safety checks (overflow, type-safety). Bugs are easy to introduce and hard to audit.",
3395
- suggestion: "Use Solidity builtins where possible. If assembly is required, document it thoroughly and add extensive tests.",
3433
+ description: "Inline assembly bypasses Solidity's safety checks. Routine in production DeFi for gas optimization (transient storage, packed structs, math) \u2014 flagged informationally so reviewers can verify the assembly block is justified.",
3434
+ suggestion: "Confirm the assembly block is necessary and audited. Document the storage slots / opcodes used.",
3396
3435
  pattern: /\bassembly\s*\{/,
3397
- severity: "medium",
3398
- category: "security",
3399
- confidence: "high",
3436
+ severity: "info",
3437
+ category: "solidity",
3438
+ confidence: "low",
3400
3439
  languages: SOL
3401
3440
  },
3402
3441
  {
@@ -3413,11 +3452,11 @@ var solidityRules2 = [
3413
3452
  {
3414
3453
  id: "cp-sol-unchecked-math",
3415
3454
  title: "Unchecked arithmetic block",
3416
- description: "The `unchecked` block disables overflow/underflow checks. This is dangerous unless you have proven the values cannot overflow.",
3417
- suggestion: "Only use unchecked blocks when overflow is mathematically impossible. Add comments explaining the safety invariant.",
3455
+ description: "`unchecked` disables Solidity 0.8+ overflow checks. Usually intentional for gas savings on loop counters and proven-safe math, but worth confirming each block has a documented invariant.",
3456
+ suggestion: "Add a comment explaining why overflow is impossible in this block. Consider whether the gas savings justify the risk.",
3418
3457
  pattern: /unchecked\s*\{/,
3419
- severity: "medium",
3420
- category: "security",
3458
+ severity: "low",
3459
+ category: "solidity",
3421
3460
  confidence: "low",
3422
3461
  languages: SOL
3423
3462
  },
@@ -3447,25 +3486,25 @@ var solidityRules2 = [
3447
3486
  {
3448
3487
  id: "cp-sol-storage-collision",
3449
3488
  title: "delegatecall with state variables \u2014 storage collision risk",
3450
- description: "Using delegatecall in a contract with state variables can cause storage layout collisions with the implementation contract.",
3489
+ description: "Using delegatecall in a contract with state variables can cause storage layout collisions with the implementation contract. Common in proxy patterns where it's intentional \u2014 verify EIP-1967 storage slots are used. High false-positive rate on standard proxy implementations.",
3451
3490
  suggestion: "Use EIP-1967 proxy pattern with standardized storage slots, or ensure identical storage layouts.",
3452
3491
  pattern: /delegatecall\s*\(/,
3453
3492
  multilinePattern: /(?:uint|int|address|mapping|bytes|string|bool)\s+(?:public|private|internal)?\s*\w+[\s\S]{0,2000}delegatecall\s*\(/g,
3454
- severity: "high",
3493
+ severity: "medium",
3455
3494
  category: "security",
3456
- confidence: "medium",
3495
+ confidence: "low",
3457
3496
  languages: SOL
3458
3497
  },
3459
3498
  {
3460
3499
  id: "cp-sol-missing-event",
3461
3500
  title: "State change without event emission",
3462
- description: "State-changing functions should emit events for off-chain indexing and transparency.",
3463
- suggestion: "Add an event emission after state changes for important variables.",
3501
+ description: "State-changing functions should emit events for off-chain indexing. Best practice for indexers and UI, not a security vulnerability \u2014 flagged informationally.",
3502
+ suggestion: "Add an event emission after state changes if downstream indexers / UI need to track them.",
3464
3503
  pattern: /(?!)/,
3465
3504
  // never matches per-line; use multiline only
3466
3505
  multilinePattern: /function\s+\w+\s*\([^)]*\)\s*(?:external|public)[^{]*\{(?:(?!emit\s)[\s\S]){1,500}\w+\s*=[^=]/g,
3467
- severity: "medium",
3468
- category: "security",
3506
+ severity: "info",
3507
+ category: "quality",
3469
3508
  confidence: "low",
3470
3509
  languages: SOL
3471
3510
  }
@@ -4607,12 +4646,12 @@ var eip7702Rules = [
4607
4646
  {
4608
4647
  id: "cp-sol-7702-unguarded-init",
4609
4648
  title: "EIP-7702 delegation: unguarded initializer",
4610
- 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.",
4611
- 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.",
4649
+ description: "Contracts intended 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. The pattern also matches generic proxy initializers, which usually ARE guarded (just not by an `initializer` keyword on the same line), so confirm whether this contract is actually a 7702 target before treating as critical.",
4650
+ suggestion: "If this is a 7702 delegation target: add `initializer` modifier (OpenZeppelin) or a boolean guard. For regular proxies, confirm the initializer is gated by the proxy factory.",
4612
4651
  pattern: /function\s+initialize\s*\([^)]*\)\s*(?:external|public)(?!\s+\w*[Ii]nitializer)/,
4613
- severity: "critical",
4652
+ severity: "high",
4614
4653
  category: "solidity",
4615
- confidence: "medium",
4654
+ confidence: "low",
4616
4655
  languages: SOL
4617
4656
  },
4618
4657
  {
@@ -4709,9 +4748,337 @@ var uniswapV4Rules = [
4709
4748
  languages: SOL
4710
4749
  }
4711
4750
  ];
4751
+ var hackReplayRules = [
4752
+ /* ── Curve Finance ($73M, July 2023) ────────────────────────── */
4753
+ /* Root cause: Vyper compiler versions 0.2.15, 0.2.16, 0.3.0 */
4754
+ /* generated broken reentrancy locks — the malloc-style storage */
4755
+ /* slot for the lock was not reused correctly across functions. */
4756
+ {
4757
+ id: "cp-hack-curve-vyper-version",
4758
+ title: "Curve hack pattern \u2014 Vyper compiler version with broken reentrancy lock",
4759
+ description: "This file declares Vyper 0.2.15, 0.2.16, or 0.3.0 \u2014 the exact compiler versions that mis-generated reentrancy locks and enabled the July 2023 Curve Finance exploit ($73M across multiple pools). The bug is in the compiler, not your source code: any @nonreentrant guard in these versions is silently bypassable.",
4760
+ suggestion: "Upgrade to Vyper >= 0.3.1 (lock fix landed in 0.3.1) and redeploy any affected pools. If you cannot redeploy, pause all reentrancy-sensitive functions and migrate liquidity.",
4761
+ pattern: /#\s*@version\s+0\.(?:2\.1[56]|3\.0)(?:\s|$)/,
4762
+ severity: "critical",
4763
+ category: "hack-replay",
4764
+ confidence: "high",
4765
+ languages: [".vy"]
4766
+ },
4767
+ /* ── Euler Finance ($197M, March 2023) ──────────────────────── */
4768
+ /* Root cause: donateToReserves() allowed an attacker to push */
4769
+ /* their own account into an artificially unhealthy state, */
4770
+ /* then self-liquidate at a discount via the liquidation flow. */
4771
+ {
4772
+ id: "cp-hack-euler-donate-self-liquidation",
4773
+ title: "Euler hack pattern \u2014 donate function reachable from liquidation path",
4774
+ description: "A donate/donateTo* function in a lending or vault context can be weaponized to artificially worsen the caller's health factor, then trigger self-liquidation at a profit. This is the exact vector that drained $197M from Euler Finance in March 2023.",
4775
+ suggestion: "Either remove the donate function, or (a) require donations to come from accounts with no active borrows, (b) recompute health AFTER the donation as if the donated assets remained the donor's, and (c) prevent same-block liquidation of the donor.",
4776
+ pattern: /function\s+donate[A-Za-z]*\s*\(/,
4777
+ severity: "high",
4778
+ category: "hack-replay",
4779
+ confidence: "medium",
4780
+ languages: SOL
4781
+ },
4782
+ /* ── Radiant Capital ($53M, October 2024) ───────────────────── */
4783
+ /* Root cause: 3-of-11 multisig members signed a malicious */
4784
+ /* transferOwnership tx (their UI was tampered by malware). */
4785
+ /* A two-step transferOwnership + timelock would have given the */
4786
+ /* team a chance to notice and abort. */
4787
+ {
4788
+ id: "cp-hack-radiant-immediate-ownership-transfer",
4789
+ title: "Radiant hack pattern \u2014 single-step ownership transfer (no Ownable2Step)",
4790
+ description: "transferOwnership is implemented as a single-call function \u2014 the new owner takes effect immediately. This is the same pattern the Radiant Capital ($53M, Oct 2024) attackers exploited after compromising a multisig signer's UI: ownership flipped before anyone could react. A two-step or timelocked pattern would have created a defensive window.",
4791
+ suggestion: "Inherit from OpenZeppelin Ownable2Step (requires acceptOwnership from the new owner) AND gate it behind a timelock for production deployments. Never let a single signature transfer ownership atomically.",
4792
+ pattern: /function\s+transferOwnership\s*\(\s*address\s+\w+\s*\)\s*(?:public|external)(?![\s\S]{0,400}?(?:pendingOwner|_pendingOwner|acceptOwnership|Ownable2Step|timelock|Timelock|TimelockController))/,
4793
+ severity: "high",
4794
+ category: "hack-replay",
4795
+ confidence: "medium",
4796
+ languages: SOL
4797
+ },
4798
+ /* ── zkSync Airdrop ($5M, April 2025) ───────────────────────── */
4799
+ /* Root cause: sweepUnclaimed() admin-only function with no */
4800
+ /* timelock or multisig gate — when the admin key was */
4801
+ /* compromised, attacker minted 111M ZK in a single tx. */
4802
+ /* Pattern targets the unambiguous red-flag names. We do NOT */
4803
+ /* flag plain `mint` since regular token contracts use it with */
4804
+ /* proper guards — single-line regex can't verify a timelock */
4805
+ /* check on the next line, so we stay conservative. */
4806
+ {
4807
+ id: "cp-hack-zksync-admin-sweep-no-timelock",
4808
+ title: "zkSync hack pattern \u2014 admin-only sweep/drain/rescue function",
4809
+ description: "An admin-gated function with a name like sweep*, drain*, rescue*, emergencyWithdraw*, forceMint*, adminMint* is the exact category of function the zkSync airdrop attacker exploited (April 2025, $5M loss). Even with a timelock check inside, these functions are high-value targets \u2014 a compromised admin key bypasses everything. The pattern is reviewed manually because a same-line regex cannot verify whether a timelock guard is enforced.",
4810
+ suggestion: "Audit this function carefully: (a) is it gated by a TimelockController contract address check, not just an onlyOwner modifier? (b) is the action bounded (max amount per call, per epoch)? (c) is there an N-of-M multisig requirement at the modifier level? If any answer is no, add it before mainnet.",
4811
+ pattern: /function\s+(?:sweep[A-Za-z]*|drain[A-Za-z]*|rescue[A-Za-z]*|emergencyWithdraw[A-Za-z]*|forceMint[A-Za-z]*|adminMint[A-Za-z]*)\s*\([^)]*\)\s*(?:external|public)/,
4812
+ severity: "high",
4813
+ category: "hack-replay",
4814
+ confidence: "medium",
4815
+ languages: SOL
4816
+ },
4817
+ /* ── Bybit ($1.46B, February 2025) ──────────────────────────── */
4818
+ /* Root cause: Safe{Wallet}'s frontend bundle on S3 was modified */
4819
+ /* to display a legit tx while signing a malicious one to */
4820
+ /* hardware wallets. This is a CI/CD + frontend integrity issue. */
4821
+ {
4822
+ id: "cp-hack-bybit-unpinned-action-checkout",
4823
+ title: "Bybit hack pattern \u2014 unpinned GitHub Action handling deploy/release",
4824
+ description: "A workflow that deploys frontend bundles or release artifacts uses a third-party action pinned to a mutable ref (@main, @master, or a version tag instead of a commit SHA). This is the supply-chain vector behind the $1.46B Bybit hack (Feb 2025): the deploy pipeline trusts a moving target. An attacker that compromises the action's repo can swap your build output.",
4825
+ suggestion: "Pin every third-party action to a full 40-char commit SHA (e.g. `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab`). Use Dependabot or Renovate to keep SHAs up to date with review.",
4826
+ pattern: /uses:\s+(?!actions\/)[^\s]+@(?:main|master|v\d+(?:\.\d+)*)\s*$/m,
4827
+ severity: "high",
4828
+ category: "hack-replay",
4829
+ confidence: "medium",
4830
+ languages: [],
4831
+ pathPattern: /\.github\/workflows\/.*\.ya?ml$/
4832
+ },
4833
+ /* ── Beanstalk ($182M, April 2022) ──────────────────────────── */
4834
+ /* Root cause: governance proposal could call arbitrary code in */
4835
+ /* the same transaction it was submitted/voted-on, with voting */
4836
+ /* power based on token balance at proposal time. Attacker flash- */
4837
+ /* loaned BEAN, voted YES on a self-draining proposal, executed */
4838
+ /* immediately. No timelock between vote and execution. */
4839
+ {
4840
+ id: "cp-hack-beanstalk-instant-governance",
4841
+ title: "Beanstalk hack pattern \u2014 governance execute() with no timelock between vote and call",
4842
+ description: "A governance contract exposes an execute() / executeProposal() / propose() function callable in the same block as voting. This is the exact $182M Beanstalk vector (April 2022): an attacker flash-loaned the governance token, voted yes on a self-draining proposal, and called execute() in the same transaction. A timelock between successful vote and execution would have made the flash-loan economically pointless.",
4843
+ suggestion: "Add a queue/execute split: successful proposals enter a TimelockController with a minimum delay (24-48h is standard). Require execute() to verify block.timestamp >= queuedAt + delay. Never let voting power, proposal acceptance, and code execution happen atomically.",
4844
+ pattern: /function\s+(?:execute(?:Proposal)?|propose(?:AndExecute)?)\s*\([^)]*\)\s*(?:external|public)(?:\s+payable)?(?:\s+returns)?[^{]*\{(?![\s\S]{0,600}?(?:timelock|Timelock|TimelockController|queued|block\.timestamp\s*[><]=?\s*\w+\s*\+))/,
4845
+ severity: "critical",
4846
+ category: "hack-replay",
4847
+ confidence: "low",
4848
+ languages: SOL
4849
+ },
4850
+ /* ── Multichain ($126M, July 2023) ──────────────────────────── */
4851
+ /* Root cause: bridge admin private keys controlled by a single */
4852
+ /* person (CEO). When that person was detained, funds were */
4853
+ /* withdrawn from MPC-controlled addresses. Single key controls */
4854
+ /* bridge withdraw / unlock = single point of failure. */
4855
+ {
4856
+ id: "cp-hack-multichain-single-admin-bridge",
4857
+ title: "Multichain hack pattern \u2014 bridge withdraw/unlock gated by single owner role",
4858
+ description: "A bridge function (withdraw, unlock, releaseTo, claim, mintBridged) is gated only by onlyOwner or a single-address admin check. This was the structural failure behind the $126M Multichain collapse (July 2023): the CEO's MPC key controlled bridge outflows, and when he was detained, attackers (or insiders) drained the bridge. Bridge withdraw functions are the highest-value attack surface in crypto and must be multi-party.",
4859
+ suggestion: "Require a multi-sig (Safe / Gnosis), a multi-party MPC (TSS), or an N-of-M validator set before any bridge outflow. Never let a single private key sign a withdraw of bridged assets. Add per-asset and per-block outflow caps as a defense-in-depth limit.",
4860
+ pattern: /function\s+(?:withdraw|unlock|releaseTo|releaseFor|claim|mintBridged|bridgeOut|relayOut)\s*\([^)]*\)\s*(?:external|public)\s+(?:onlyOwner|onlyAdmin|onlyMPC)(?![\s\S]{0,200}?(?:multisig|Multisig|threshold|N_OF_M|validators|attestors))/,
4861
+ severity: "critical",
4862
+ category: "hack-replay",
4863
+ confidence: "medium",
4864
+ languages: SOL
4865
+ },
4866
+ /* ── Ronin Bridge ($625M, March 2022) ───────────────────────── */
4867
+ /* Root cause: 5-of-9 validator set with 4 validators run by */
4868
+ /* Sky Mavis + 1 by Axie DAO. Attacker compromised the 4 Sky */
4869
+ /* Mavis keys and the Axie DAO had previously granted Sky Mavis */
4870
+ /* signing rights "temporarily" but never revoked → 5/9 reached. */
4871
+ /* Detection: low signature threshold relative to validator set. */
4872
+ {
4873
+ id: "cp-hack-ronin-low-validator-threshold",
4874
+ title: "Ronin hack pattern \u2014 bridge validator threshold \u2264 5/9 or hardcoded low quorum",
4875
+ description: "A bridge contract uses a hardcoded signature threshold of 4, 5, or 6 \u2014 the same range that allowed the $625M Ronin bridge hack (March 2022). Sky Mavis ran 4 of 9 validators; combined with one improperly-revoked delegated key, the attacker hit the 5/9 threshold from a single team's compromise. A higher threshold (7-of-9, 8-of-11) makes single-team compromise insufficient.",
4876
+ suggestion: "Audit your validator threshold against the social/operational concentration of validator operators. Aim for \u2265 70% threshold (e.g. 7-of-9, 11-of-15). Periodically rotate delegated signing rights and verify revocations actually happen. Set up monitoring for validator-set changes.",
4877
+ pattern: /(?:signatureThreshold|requiredSignatures|threshold|quorum|VALIDATOR_THRESHOLD)\s*[=:]\s*(?:uint(?:8|16|32|256)\s*\(\s*)?[4-6]\b/i,
4878
+ severity: "high",
4879
+ category: "hack-replay",
4880
+ confidence: "low",
4881
+ languages: SOL
4882
+ },
4883
+ /* ── Cream Finance ($130M, October 2021) ────────────────────── */
4884
+ /* Root cause: oracle manipulated via flash-loaned yUSD vault */
4885
+ /* shares. Collateral price computed from spot share supply, */
4886
+ /* inflatable via flash loan → borrow against inflated value → */
4887
+ /* drain. Detection: spot-balance arithmetic in pricing without */
4888
+ /* TWAP / Chainlink fallback. */
4889
+ {
4890
+ id: "cp-hack-cream-spot-share-pricing",
4891
+ title: "Cream hack pattern \u2014 collateral price computed from spot vault share supply",
4892
+ description: "A function computes a token/share price by dividing total assets by spot totalSupply (or balanceOf this). The $130M Cream hack (Oct 2021) exploited exactly this on yUSDVault: flash-loan inflated the share supply during a single tx, the price oracle read the manipulated value, and the attacker borrowed massively against fictitious collateral. Spot supply/balance ratios are flash-loan-attackable.",
4893
+ suggestion: "Use Chainlink price feeds with staleness checks, a TWAP from a deep Uniswap V3 pool, or external collateral-pricing oracles. Never compute price from same-block totalSupply or balanceOf.",
4894
+ pattern: /(?:totalSupply|balanceOf)\s*\([^)]*\)\s*[*/]\s*\w+|\w+\s*[*/]\s*(?:totalSupply|balanceOf)\s*\(/,
4895
+ severity: "high",
4896
+ category: "hack-replay",
4897
+ confidence: "low",
4898
+ languages: SOL
4899
+ },
4900
+ /* ── Wormhole ($325M, February 2022) ────────────────────────── */
4901
+ /* Root cause: signature verification path used a stub guardian */
4902
+ /* set lookup that didn't validate the set hash. Attacker forged */
4903
+ /* a guardian signature against an empty set, which the contract */
4904
+ /* accepted because the set comparison was wrong. Detection: */
4905
+ /* ecrecover or signature-set membership without strict hash */
4906
+ /* equality of the validator set. */
4907
+ {
4908
+ id: "cp-hack-wormhole-unchecked-signature-set",
4909
+ title: "Wormhole hack pattern \u2014 guardian/validator signature accepted without strict set verification",
4910
+ description: "A function verifies a signature against a validator/guardian set but does not strictly check that the set hash matches the expected current set (or compares against a default/empty value). The $325M Wormhole hack (Feb 2022) exploited a stub `verify_signatures` path that accepted forged signatures because the validator set comparison was missing/insecure. ecrecover-based signature schemes need every validator set rotation tracked by an explicit hash check.",
4911
+ suggestion: "Verify the signed message includes a strict hash of the current validator set. Reject signatures where the recovered signer is not in the active set. Never use `default!()` or zero-initialized guardian arrays in signature paths.",
4912
+ pattern: /(?:ecrecover|recover)\s*\([\s\S]{0,400}?\)(?![\s\S]{0,300}?(?:guardianSet|validatorSet|currentSetHash|setHash|require\s*\([^)]*==\s*expected))/,
4913
+ severity: "critical",
4914
+ category: "hack-replay",
4915
+ confidence: "low",
4916
+ languages: SOL
4917
+ },
4918
+ /* ── Nomad Bridge ($190M, August 2022) ──────────────────────── */
4919
+ /* Root cause: an upgrade initialized the trusted-root mapping */
4920
+ /* with 0x00. Any message with a 0-bytes proof became 'proven'. */
4921
+ /* Anyone could replay arbitrary messages against the bridge. */
4922
+ /* Detection: trusted-root / merkle-root mapping reads or writes */
4923
+ /* that don't reject zero values. */
4924
+ {
4925
+ id: "cp-hack-nomad-zero-root-acceptance",
4926
+ title: "Nomad hack pattern \u2014 trusted/merkle root lookup accepts zero hash as valid",
4927
+ description: "A function reads a value from a merkle-root or trusted-root mapping and treats any non-revert response as valid, including the zero default. The $190M Nomad bridge hack (Aug 2022) was caused by an upgrade that initialized the root mapping to zero \u2014 every message proof of zero became 'valid', and the entire bridge was drained by 300+ copy-paste attackers within hours.",
4928
+ suggestion: "Always `require(root != bytes32(0))` before treating a stored root/hash as a valid proof anchor. Apply the same check to any zero-value default in upgrade-initialized storage.",
4929
+ pattern: /(?:roots|messages|acceptableRoot|trustedRoot|merkleRoot)\s*\[[^\]]+\]\s*(?:==\s*(?:true|0x01)|!=\s*0x00\b)(?!\s*&&\s*\w+\s*!=\s*(?:bytes32\(0\)|0x0+))/,
4930
+ severity: "critical",
4931
+ category: "hack-replay",
4932
+ confidence: "low",
4933
+ languages: SOL
4934
+ },
4935
+ /* ── Mango Markets ($114M, October 2022) ────────────────────── */
4936
+ /* Root cause: oracle was a thinly-traded spot AMM price. */
4937
+ /* Attacker pumped MNGO token via CEX wash trades, then borrowed */
4938
+ /* $114M against inflated collateral. Detection: pricing pulled */
4939
+ /* from a single low-liquidity source with no sanity bounds. */
4940
+ {
4941
+ id: "cp-hack-mango-single-source-oracle",
4942
+ title: "Mango hack pattern \u2014 oracle uses single price source without bounds",
4943
+ description: "A pricing function reads from a single oracle source (one Uniswap pool, one CEX feed, one Chainlink aggregator) without sanity bounds or a secondary source. The $114M Mango Markets hack (Oct 2022) was a textbook oracle-manipulation attack: the attacker pumped MNGO price ~10\xD7 via CEX trades, the oracle dutifully reported it, and Mango let them borrow $114M against the now-inflated collateral.",
4944
+ suggestion: "Use at least two independent oracle sources for any collateral pricing. Apply a circuit breaker (max % change per block, deviation from TWAP). For long-tail tokens, cap borrowable collateral or require multi-source confirmation.",
4945
+ // Tightened: require the call to appear in a function whose name implies
4946
+ // lending / collateral / liquidation context (the actual hack surface).
4947
+ // Plain DEX pool internals like Uniswap getReserves are not at risk.
4948
+ pattern: /function\s+(?:borrow\w*|liquidat\w*|isHealthy|collateralValue|getAccountHealth|maxBorrow|withdrawCollateral|getBorrowable)[\s\S]{0,600}?\b(?:getPrice|latestAnswer|peek|read)\s*\((?![\s\S]{0,400}?(?:twap|TWAP|deviation|maxDeviation|stalenessThreshold|secondaryPrice|sanity))/,
4949
+ severity: "medium",
4950
+ category: "hack-replay",
4951
+ confidence: "low",
4952
+ languages: SOL
4953
+ }
4954
+ ];
4955
+ var rugSurfaceRules = [
4956
+ {
4957
+ id: "cp-rug-owner-can-mint",
4958
+ title: "Owner can mint tokens",
4959
+ description: "A mint function is gated by onlyOwner (or similar single-address modifier). Owner can dilute holders at will. Verify whether this is intentional (e.g. emissions schedule) or unrestricted.",
4960
+ suggestion: "Hard-cap max supply, route mints through a timelock + multisig, or renounce ownership after initial mint.",
4961
+ pattern: /function\s+(?:_?mint|mintTo|airdrop)\s*\([^)]*\)\s*(?:external|public)\s+(?:onlyOwner|onlyAdmin|onlyMinter)/,
4962
+ severity: "high",
4963
+ category: "rug-surface",
4964
+ confidence: "medium",
4965
+ languages: SOL
4966
+ },
4967
+ {
4968
+ id: "cp-rug-owner-can-pause",
4969
+ title: "Owner can pause transfers",
4970
+ description: "Owner can call _pause() or set a paused flag \u2014 halting all transfers. Useful for emergencies, dangerous if the owner is an EOA without timelock.",
4971
+ suggestion: "Move pause behind multisig + timelock. Document expected pause scenarios. Consider auto-unpause after N blocks.",
4972
+ pattern: /function\s+(?:pause|setPaused|_pause|emergencyPause)\s*\([^)]*\)\s*(?:external|public)\s+(?:onlyOwner|onlyAdmin)/,
4973
+ severity: "medium",
4974
+ category: "rug-surface",
4975
+ confidence: "medium",
4976
+ languages: SOL
4977
+ },
4978
+ {
4979
+ id: "cp-rug-owner-can-blacklist",
4980
+ title: "Owner can blacklist addresses",
4981
+ description: "Owner can prevent specific addresses from transferring. Honeypot pattern when combined with no transfer-tax limit or a hidden blacklist mapping.",
4982
+ suggestion: "Either remove the blacklist entirely (rely on protocol-level sanctions), or gate it behind a multisig + timelock with public allowlist process.",
4983
+ pattern: /(?:blacklist|denylist|isBlocked|blocked)\s*\[\s*\w+\s*\]\s*=\s*(?:true|1)|function\s+(?:blacklist|setBlacklist|addToBlacklist|block(?:Address|User|Account))\s*\(/i,
4984
+ severity: "high",
4985
+ category: "rug-surface",
4986
+ confidence: "medium",
4987
+ languages: SOL
4988
+ },
4989
+ {
4990
+ id: "cp-rug-owner-can-change-fees",
4991
+ title: "Owner can change transfer fees",
4992
+ description: "A setFee / updateFee / setTax function gated by onlyOwner. Owner can spike fees to 99% effectively stopping trading.",
4993
+ suggestion: "Cap maximum fee in the contract (e.g. require new fee <= 10%). Combine with a timelock so changes are observable in advance.",
4994
+ pattern: /function\s+(?:setFee|setTax|setBuyFee|setSellFee|setTransferFee|updateFee|updateTax|setFees)\s*\([^)]*\)\s*(?:external|public)\s+(?:onlyOwner|onlyAdmin)/i,
4995
+ severity: "high",
4996
+ category: "rug-surface",
4997
+ confidence: "medium",
4998
+ languages: SOL
4999
+ },
5000
+ {
5001
+ id: "cp-rug-owner-can-change-router",
5002
+ title: "Owner can swap router or pair",
5003
+ description: "A setRouter / setPair / setSwapRouter function gated by onlyOwner. Owner can redirect liquidity to an attacker-controlled router and drain trades.",
5004
+ suggestion: "Make router / pair immutable. If upgrades are needed, gate behind multisig + 7-day timelock with clear public announcement.",
5005
+ pattern: /function\s+(?:setRouter|setPair|setSwapRouter|updateRouter|setUniswapRouter|setSwapPair)\s*\([^)]*\)\s*(?:external|public)\s+(?:onlyOwner|onlyAdmin)/i,
5006
+ severity: "high",
5007
+ category: "rug-surface",
5008
+ confidence: "medium",
5009
+ languages: SOL
5010
+ },
5011
+ {
5012
+ id: "cp-rug-owner-can-sweep",
5013
+ title: "Owner can sweep ETH or ERC20 from contract",
5014
+ description: 'Owner can call a sweep / rescue / withdraw / recover function that drains arbitrary tokens or ETH from the contract. Often labelled "rescue stuck funds" \u2014 but the same path drains user-owned balances.',
5015
+ suggestion: "Restrict to specific tokens that should never be in the contract. Exclude the protocol's own LP token / treasury. Behind multisig + timelock.",
5016
+ pattern: /function\s+(?:rescueTokens|rescueERC20|sweepTokens|sweep|sweepERC20|recoverTokens|recoverERC20|withdrawStuck|withdrawTokens|emergencyWithdraw|emergencyRescue)\s*\([^)]*\)\s*(?:external|public)\s+(?:onlyOwner|onlyAdmin)/i,
5017
+ severity: "critical",
5018
+ category: "rug-surface",
5019
+ confidence: "medium",
5020
+ languages: SOL
5021
+ },
5022
+ {
5023
+ id: "cp-rug-owner-can-upgrade",
5024
+ title: "Proxy upgrade gated by single owner",
5025
+ description: "An _upgradeTo / upgradeTo function is callable by onlyOwner (UUPS pattern). The implementation contract can be swapped to attacker code unilaterally.",
5026
+ suggestion: "Route upgrades through a TimelockController owned by a multisig. Publish the new implementation address before the timelock expires.",
5027
+ pattern: /function\s+(?:_upgradeTo|upgradeTo|upgradeToAndCall|_authorizeUpgrade)\s*\([^)]*\)\s*(?:external|public|internal|virtual\s+override)?(?:[^{]*?)(?:onlyOwner|onlyAdmin)/,
5028
+ severity: "high",
5029
+ category: "rug-surface",
5030
+ confidence: "medium",
5031
+ languages: SOL
5032
+ },
5033
+ {
5034
+ id: "cp-rug-role-self-grant",
5035
+ title: "Role admin can grant itself any role",
5036
+ description: "DEFAULT_ADMIN_ROLE or a self-managed role can call grantRole() on itself or on other privileged roles. Single admin compromise = full takeover.",
5037
+ suggestion: "Use AccessControlEnumerable + revoke admin's ability to manage its own role. Move sensitive roles to a separate multisig.",
5038
+ pattern: /_setupRole\s*\(\s*DEFAULT_ADMIN_ROLE|_grantRole\s*\(\s*DEFAULT_ADMIN_ROLE|grantRole\s*\([^,]+,\s*msg\.sender\s*\)/,
5039
+ severity: "high",
5040
+ category: "rug-surface",
5041
+ confidence: "medium",
5042
+ languages: SOL
5043
+ },
5044
+ {
5045
+ id: "cp-rug-no-renounce-evidence",
5046
+ title: "Renounceable ownership without evidence of renunciation",
5047
+ description: "Contract inherits Ownable / OwnableUpgradeable but the constructor / initializer doesn't transfer ownership to a known-safe address (zero, multisig, timelock). Owner power may still be active.",
5048
+ suggestion: "Either renounce ownership in the constructor (acceptably for fully-immutable contracts), or transfer to a multisig. Document this explicitly.",
5049
+ pattern: /(?:contract|abstract\s+contract)\s+\w+[\s\S]{0,500}?\bis\s+[\w,\s]*?Ownable(?:Upgradeable)?\b(?![\s\S]{0,2000}?(?:renounceOwnership\s*\(\)|transferOwnership\s*\(\s*address\(0x|TimelockController|Safe\s*\())/,
5050
+ severity: "medium",
5051
+ category: "rug-surface",
5052
+ confidence: "low",
5053
+ languages: SOL
5054
+ },
5055
+ {
5056
+ id: "cp-rug-max-wallet-or-tx",
5057
+ title: "Trading restrictions (max wallet / max tx)",
5058
+ description: "Contract enforces maxWallet, maxTransactionAmount, or similar caps. Common in memecoin templates \u2014 verify the cap can't be set to 0 or 1 wei (effectively halting trading).",
5059
+ suggestion: "Ensure max-cap setters have a minimum floor (e.g. 0.1% of supply). Combine with timelock for changes after launch.",
5060
+ pattern: /(?:maxWallet|maxTransactionAmount|maxTx|maxWalletAmount|_maxWallet|_maxTx)\s*[:=]/i,
5061
+ severity: "low",
5062
+ category: "rug-surface",
5063
+ confidence: "medium",
5064
+ languages: SOL
5065
+ },
5066
+ {
5067
+ id: "cp-rug-hidden-modifier",
5068
+ title: "Privileged modifier without visible role check in body",
5069
+ description: "A modifier name doesn't suggest privilege (e.g. `lock`, `safe`, `nonReentrant`-shaped names) but its body restricts access. Auditors miss these; users assume the function is open.",
5070
+ suggestion: "Rename modifier to make privilege explicit (e.g. `onlyOperator`). Or move check inline so reviewers see it in the function body.",
5071
+ pattern: /modifier\s+(?!onlyOwner|onlyAdmin|onlyRole|nonReentrant|whenNotPaused|whenPaused|initializer|reinitializer)(\w+)\s*\(\s*\)\s*\{[\s\S]{0,200}?require\s*\(\s*msg\.sender\s*==\s*\w+/,
5072
+ severity: "medium",
5073
+ category: "rug-surface",
5074
+ confidence: "low",
5075
+ languages: SOL
5076
+ }
5077
+ ];
4712
5078
  var ALL_RULES2 = [
4713
5079
  ...securityRules,
4714
5080
  ...solidityRules2,
5081
+ ...rugSurfaceRules,
4715
5082
  ...javaRules,
4716
5083
  ...rubyRules,
4717
5084
  ...phpRules,
@@ -4722,7 +5089,8 @@ var ALL_RULES2 = [
4722
5089
  ...iacRules,
4723
5090
  ...eip7702Rules,
4724
5091
  ...tstoreRules,
4725
- ...uniswapV4Rules
5092
+ ...uniswapV4Rules,
5093
+ ...hackReplayRules
4726
5094
  ];
4727
5095
 
4728
5096
  // src/static/pattern-scanner.ts
@@ -4774,9 +5142,13 @@ function isJsxFile(filePath) {
4774
5142
  function isScriptDir(filePath) {
4775
5143
  return /(?:^|\/)(?:scripts|build|tools|infra)\//i.test(filePath);
4776
5144
  }
5145
+ function isVendorFile(filePath) {
5146
+ return /(?:^|\/)(?:node_modules|@openzeppelin|@uniswap|@aave|@chainlink|solmate|solady|lib|vendor|dependencies|forge-std|ds-test)(?:\/|@)/i.test(filePath);
5147
+ }
4777
5148
  function isFalsePositive(ctx) {
4778
5149
  if (isDocsFile(ctx.filePath)) return true;
4779
- if (ctx.rule.id !== "cp-qual-todo-fixme" && isCommentLine(ctx.line)) return true;
5150
+ if (isVendorFile(ctx.filePath)) return true;
5151
+ if (ctx.rule.id !== "cp-qual-todo-fixme" && ctx.rule.category !== "hack-replay" && isCommentLine(ctx.line)) return true;
4780
5152
  if (isImportLine(ctx.line) && /(?:http-no-tls|hardcoded-ip|hardcoded-secret)/.test(ctx.rule.id)) return true;
4781
5153
  if (isConfigFile(ctx.filePath) && ctx.rule.category === "security") return true;
4782
5154
  const trimmed = ctx.line.trim();
@@ -4794,6 +5166,11 @@ function isFalsePositive(ctx) {
4794
5166
  if (/process\.env\b/.test(ctx.line)) return true;
4795
5167
  }
4796
5168
  }
5169
+ if (ctx.rule.id === "cp-sec-hardcoded-secret") {
5170
+ if (/=\s*["']?0x[0-9a-fA-F]{40}["']?/.test(ctx.line)) return true;
5171
+ if (/\b(?:address|token0?|token1|tokenAddress|contractAddress|pool|router|factory|vault|recipient|payTo|treasury|owner|admin|operator|signer|delegate|merkleRoot|root|domain|salt|chainId|deadline|attestation|uid|schema)\b\s*[:=]/i.test(ctx.line)) return true;
5172
+ if (/\b(?:hash|sha|keccak|merkle|root|uid|tx|proof|signature|sig|domain|salt|nonce|attestation|commitment)\w*\s*[:=]\s*["']?0x[0-9a-fA-F]{64}/i.test(ctx.line)) return true;
5173
+ }
4797
5174
  return false;
4798
5175
  }
4799
5176
  function adjustSeverityForContext(severity, filePath) {
@@ -4931,7 +5308,7 @@ ${lines[i]}`,
4931
5308
  }
4932
5309
  }
4933
5310
  }
4934
- if (lines.length > 8) {
5311
+ if (lines.length > 8 && !SOL2.includes(ext)) {
4935
5312
  const WINDOW = 4;
4936
5313
  const blockMap = /* @__PURE__ */ new Map();
4937
5314
  for (let i = 0; i <= lines.length - WINDOW; i++) {
@@ -5124,6 +5501,175 @@ var patternScannerRunner = {
5124
5501
  }
5125
5502
  };
5126
5503
 
5504
+ // src/ai/poc-generator.ts
5505
+ import { execFile as execFile4 } from "child_process";
5506
+ import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
5507
+ import { promisify as promisify4 } from "util";
5508
+ import { tmpdir } from "os";
5509
+ import { join } from "path";
5510
+ import { randomBytes } from "crypto";
5511
+ var exec4 = promisify4(execFile4);
5512
+ var SYSTEM_PROMPT = `You are an expert smart contract security researcher who writes Foundry (forge) exploit tests to demonstrate vulnerabilities.
5513
+
5514
+ Given a security finding and the vulnerable contract source code, write a minimal Foundry test that:
5515
+ 1. Deploys the vulnerable contract (or a minimal reproduction of the vulnerable pattern)
5516
+ 2. Demonstrates the exploit in a test function named test_exploit()
5517
+ 3. Uses vm.expectRevert() or assertions to show the vulnerability is real
5518
+ 4. Includes a brief comment explaining the attack vector
5519
+
5520
+ Rules:
5521
+ - Use Solidity ^0.8.20 and Foundry's Test base
5522
+ - Import only from forge-std (available: Test, console, Vm, StdCheats)
5523
+ - If the contract needs an RPC fork (e.g. for on-chain state), emit a // @fork-required comment on line 1
5524
+ - Keep it minimal \u2014 prove the bug, nothing else
5525
+ - Return ONLY valid Solidity, no markdown fences, no explanation outside comments
5526
+
5527
+ Format:
5528
+ \`\`\`solidity
5529
+ // SPDX-License-Identifier: MIT
5530
+ pragma solidity ^0.8.20;
5531
+
5532
+ import "forge-std/Test.sol";
5533
+
5534
+ contract ExploitTest is Test {
5535
+ // ...setup...
5536
+
5537
+ function test_exploit() public {
5538
+ // ...exploit...
5539
+ }
5540
+ }
5541
+ \`\`\``;
5542
+ function buildUserMessage(finding, contractSource) {
5543
+ return `## Vulnerability Finding
5544
+
5545
+ Rule ID: ${finding.ruleId}
5546
+ Title: ${finding.title}
5547
+ Severity: ${finding.severity}
5548
+ File: ${finding.filePath} (lines ${finding.startLine}\u2013${finding.endLine})
5549
+ Description: ${finding.description}
5550
+
5551
+ Vulnerable code snippet:
5552
+ \`\`\`solidity
5553
+ ${finding.codeSnippet}
5554
+ \`\`\`
5555
+
5556
+ Fix suggestion: ${finding.suggestion}
5557
+
5558
+ ## Full Contract Source
5559
+
5560
+ \`\`\`solidity
5561
+ ${contractSource}
5562
+ \`\`\`
5563
+
5564
+ Write the Foundry exploit test now.`;
5565
+ }
5566
+ function extractSolidity(text) {
5567
+ const fenceMatch = text.match(/```(?:solidity)?\s*([\s\S]*?)```/);
5568
+ if (fenceMatch) return fenceMatch[1].trim();
5569
+ return text.trim();
5570
+ }
5571
+ async function forgeAvailable() {
5572
+ try {
5573
+ await exec4("forge", ["--version"], { timeout: 5e3 });
5574
+ return true;
5575
+ } catch {
5576
+ return false;
5577
+ }
5578
+ }
5579
+ async function runForgeTest(pocCode, rpcUrl) {
5580
+ const id = randomBytes(6).toString("hex");
5581
+ const dir = join(tmpdir(), `elytra-poc-${id}`);
5582
+ const srcDir = join(dir, "src");
5583
+ const testDir = join(dir, "test");
5584
+ const libDir = join(dir, "lib");
5585
+ try {
5586
+ await mkdir2(srcDir, { recursive: true });
5587
+ await mkdir2(testDir, { recursive: true });
5588
+ await mkdir2(libDir, { recursive: true });
5589
+ await writeFile2(
5590
+ join(dir, "foundry.toml"),
5591
+ `[profile.default]
5592
+ src = "src"
5593
+ out = "out"
5594
+ libs = ["lib"]
5595
+ `
5596
+ );
5597
+ const testFile = join(testDir, "Exploit.t.sol");
5598
+ await writeFile2(testFile, pocCode);
5599
+ const args = ["test", "--match-contract", "ExploitTest", "--no-match-coverage", "-vv"];
5600
+ if (rpcUrl) {
5601
+ args.push("--fork-url", rpcUrl);
5602
+ }
5603
+ const { stdout, stderr } = await exec4("forge", args, {
5604
+ cwd: dir,
5605
+ timeout: 6e4,
5606
+ env: { ...process.env, FOUNDRY_PROFILE: "default" }
5607
+ });
5608
+ const output = [stdout, stderr].filter(Boolean).join("\n");
5609
+ const passed = output.includes("PASS") || output.includes("ok");
5610
+ return { success: passed, output };
5611
+ } catch (err) {
5612
+ const output = err.stdout ?? err.stderr ?? String(err);
5613
+ return { success: false, output };
5614
+ } finally {
5615
+ exec4("rm", ["-rf", dir]).catch(() => {
5616
+ });
5617
+ }
5618
+ }
5619
+ var PocGenerator = class {
5620
+ provider;
5621
+ constructor(provider) {
5622
+ this.provider = provider;
5623
+ }
5624
+ async generate(finding, contractSource, rpcUrl) {
5625
+ if (finding.category !== "solidity" && !finding.ruleId.startsWith("cp-sol")) {
5626
+ return {
5627
+ status: "error",
5628
+ pocCode: "",
5629
+ output: "PoC generation only supported for Solidity findings"
5630
+ };
5631
+ }
5632
+ let pocCode = "";
5633
+ try {
5634
+ const response = await this.provider.complete({
5635
+ messages: [
5636
+ { role: "system", content: SYSTEM_PROMPT },
5637
+ { role: "user", content: buildUserMessage(finding, contractSource) }
5638
+ ],
5639
+ maxTokens: 4096
5640
+ });
5641
+ pocCode = extractSolidity(response.text);
5642
+ if (!pocCode) {
5643
+ return { status: "error", pocCode: "", output: "AI returned empty response" };
5644
+ }
5645
+ } catch (err) {
5646
+ logger.error(`PocGenerator: AI call failed: ${err}`);
5647
+ return { status: "error", pocCode: "", output: String(err) };
5648
+ }
5649
+ const hasForge = await forgeAvailable();
5650
+ if (!hasForge) {
5651
+ logger.info("PocGenerator: forge not available, returning generated code only");
5652
+ return {
5653
+ status: "generated",
5654
+ pocCode,
5655
+ output: "forge not installed \u2014 PoC generated but not executed",
5656
+ rpcUrl
5657
+ };
5658
+ }
5659
+ const forkRequired = pocCode.includes("@fork-required");
5660
+ const { success, output } = await runForgeTest(
5661
+ pocCode,
5662
+ forkRequired ? rpcUrl : void 0
5663
+ );
5664
+ return {
5665
+ status: success ? "confirmed" : "not-exploitable",
5666
+ pocCode,
5667
+ output,
5668
+ rpcUrl: forkRequired ? rpcUrl : void 0
5669
+ };
5670
+ }
5671
+ };
5672
+
5127
5673
  // src/analyzer.ts
5128
5674
  var SEVERITY_ORDER2 = {
5129
5675
  critical: 0,
@@ -5228,7 +5774,9 @@ async function analyze(params) {
5228
5774
  repoPath,
5229
5775
  staticOnly = false,
5230
5776
  skipStatic = false,
5231
- aiConcurrency
5777
+ aiConcurrency,
5778
+ runPoc = false,
5779
+ pocRpcUrl
5232
5780
  } = params;
5233
5781
  const parsed = parseDiff(diff);
5234
5782
  if (parsed.files.length === 0) {
@@ -5346,7 +5894,38 @@ async function analyze(params) {
5346
5894
  const enriched = enrichFindings(sorted);
5347
5895
  const score = computeScore(enriched, parsed.files.length);
5348
5896
  const summary = buildSummary(enriched, parsed.files.length, staticResult?.toolsRan);
5349
- return { findings: enriched, summary, score };
5897
+ let pocs;
5898
+ if (runPoc && (provider || apiKey)) {
5899
+ const pocProvider = provider ?? {
5900
+ name: "anthropic-poc",
5901
+ async complete(p) {
5902
+ const { AnthropicProvider: AnthropicProvider2 } = await import("./anthropic-PRRF4E3I.js");
5903
+ return new AnthropicProvider2(apiKey).complete(p);
5904
+ }
5905
+ };
5906
+ const pocGen = new PocGenerator(pocProvider);
5907
+ const solFindings = enriched.filter(
5908
+ (f) => (f.severity === "critical" || f.severity === "high") && (f.category === "solidity" || f.ruleId.startsWith("cp-sol"))
5909
+ );
5910
+ const fileContents = /* @__PURE__ */ new Map();
5911
+ for (const file of parsed.files) {
5912
+ fileContents.set(file.path, reconstructContent(file));
5913
+ }
5914
+ pocs = {};
5915
+ await Promise.all(
5916
+ solFindings.map(async (finding) => {
5917
+ const src = fileContents.get(finding.filePath) ?? finding.codeSnippet;
5918
+ const key = `${finding.ruleId}:${finding.filePath}:${finding.startLine}`;
5919
+ try {
5920
+ pocs[key] = await pocGen.generate(finding, src, pocRpcUrl);
5921
+ } catch (err) {
5922
+ logger.error(`[analyzer] PoC generation failed for ${key}: ${err}`);
5923
+ pocs[key] = { status: "error", pocCode: "", output: String(err) };
5924
+ }
5925
+ })
5926
+ );
5927
+ }
5928
+ return { findings: enriched, summary, score, ...pocs ? { pocs } : {} };
5350
5929
  }
5351
5930
  function enrichFindings(findings) {
5352
5931
  return findings.map((f) => {
@@ -5482,12 +6061,12 @@ function formatStaticFindingsForAI(rawFindings) {
5482
6061
  import { rm as rm2 } from "fs/promises";
5483
6062
 
5484
6063
  // src/upgrade/ingest.ts
5485
- import { execFile as execFile4 } from "child_process";
5486
- import { promisify as promisify4 } from "util";
6064
+ import { execFile as execFile5 } from "child_process";
6065
+ import { promisify as promisify5 } from "util";
5487
6066
  import { readdir, stat, readFile as readFile2, mkdtemp } from "fs/promises";
5488
6067
  import path4 from "path";
5489
6068
  import os2 from "os";
5490
- var exec4 = promisify4(execFile4);
6069
+ var exec5 = promisify5(execFile5);
5491
6070
  var EXT_TO_LANG = {
5492
6071
  ".ts": "typescript",
5493
6072
  ".tsx": "typescript",
@@ -5708,7 +6287,7 @@ async function ingestRepo(repoUrl, branch) {
5708
6287
  if (branch) {
5709
6288
  cloneArgs.splice(2, 0, "--branch", branch);
5710
6289
  }
5711
- await exec4("git", cloneArgs, {
6290
+ await exec5("git", cloneArgs, {
5712
6291
  timeout: 12e4,
5713
6292
  maxBuffer: 200 * 1024 * 1024
5714
6293
  });
@@ -5770,11 +6349,11 @@ async function readRepoFiles(repoPath, filePaths, maxTotalSize = 5e5) {
5770
6349
  }
5771
6350
 
5772
6351
  // src/upgrade/deps.ts
5773
- import { execFile as execFile5 } from "child_process";
5774
- import { promisify as promisify5 } from "util";
6352
+ import { execFile as execFile6 } from "child_process";
6353
+ import { promisify as promisify6 } from "util";
5775
6354
  import { readFile as readFile3, access } from "fs/promises";
5776
6355
  import path5 from "path";
5777
- var exec5 = promisify5(execFile5);
6356
+ var exec6 = promisify6(execFile6);
5778
6357
  var DEPRECATED_REPLACEMENTS = {
5779
6358
  "moment": "dayjs or date-fns",
5780
6359
  "request": "node-fetch or axios or undici",
@@ -5828,7 +6407,7 @@ async function analyzeNodeDeps(repoPath) {
5828
6407
  let vulnerableCount = 0;
5829
6408
  let auditResults = {};
5830
6409
  try {
5831
- const { stdout } = await exec5("npm", ["audit", "--json"], {
6410
+ const { stdout } = await exec6("npm", ["audit", "--json"], {
5832
6411
  cwd: repoPath,
5833
6412
  timeout: 6e4,
5834
6413
  maxBuffer: 20 * 1024 * 1024
@@ -5853,7 +6432,7 @@ async function analyzeNodeDeps(repoPath) {
5853
6432
  }
5854
6433
  let outdatedResults = {};
5855
6434
  try {
5856
- const { stdout } = await exec5("npm", ["outdated", "--json"], {
6435
+ const { stdout } = await exec6("npm", ["outdated", "--json"], {
5857
6436
  cwd: repoPath,
5858
6437
  timeout: 3e4,
5859
6438
  maxBuffer: 10 * 1024 * 1024
@@ -5913,7 +6492,7 @@ async function analyzeRustDeps(repoPath) {
5913
6492
  const deps = [];
5914
6493
  let vulnerableCount = 0;
5915
6494
  try {
5916
- const { stdout } = await exec5("cargo", ["audit", "--json"], {
6495
+ const { stdout } = await exec6("cargo", ["audit", "--json"], {
5917
6496
  cwd: repoPath,
5918
6497
  timeout: 6e4,
5919
6498
  maxBuffer: 20 * 1024 * 1024
@@ -5971,7 +6550,7 @@ async function analyzePythonDeps(repoPath) {
5971
6550
  const deps = [];
5972
6551
  let vulnerableCount = 0;
5973
6552
  try {
5974
- const { stdout } = await exec5(
6553
+ const { stdout } = await exec6(
5975
6554
  "pip-audit",
5976
6555
  ["-r", path5.join(repoPath, "requirements.txt"), "--format", "json"],
5977
6556
  { cwd: repoPath, timeout: 6e4, maxBuffer: 20 * 1024 * 1024 }
@@ -6053,10 +6632,10 @@ async function analyzeDependencies(repoPath, ecosystems) {
6053
6632
  }
6054
6633
 
6055
6634
  // src/upgrade/audit.ts
6056
- import { execFile as execFile6 } from "child_process";
6057
- import { promisify as promisify6 } from "util";
6635
+ import { execFile as execFile7 } from "child_process";
6636
+ import { promisify as promisify7 } from "util";
6058
6637
  import path6 from "path";
6059
- var exec6 = promisify6(execFile6);
6638
+ var exec7 = promisify7(execFile7);
6060
6639
  async function runSlitherFullScan(repoPath, solFiles) {
6061
6640
  if (solFiles.length === 0) return [];
6062
6641
  const IMPACT_MAP2 = {
@@ -6069,7 +6648,7 @@ async function runSlitherFullScan(repoPath, solFiles) {
6069
6648
  try {
6070
6649
  let stdout;
6071
6650
  try {
6072
- const result = await exec6("slither", [repoPath, "--json", "-", "--no-fail"], {
6651
+ const result = await exec7("slither", [repoPath, "--json", "-", "--no-fail"], {
6073
6652
  timeout: 18e4,
6074
6653
  maxBuffer: 50 * 1024 * 1024,
6075
6654
  cwd: repoPath
@@ -6110,7 +6689,7 @@ async function runSemgrepFullScan(repoPath) {
6110
6689
  try {
6111
6690
  let stdout;
6112
6691
  try {
6113
- const result = await exec6(
6692
+ const result = await exec7(
6114
6693
  "semgrep",
6115
6694
  ["--config", "p/security-audit", "--json", "--timeout", "30", repoPath],
6116
6695
  { timeout: 18e4, maxBuffer: 50 * 1024 * 1024, cwd: repoPath }
@@ -6141,7 +6720,7 @@ async function runGitleaksFullScan(repoPath) {
6141
6720
  try {
6142
6721
  let stdout;
6143
6722
  try {
6144
- const result = await exec6(
6723
+ const result = await exec7(
6145
6724
  "gitleaks",
6146
6725
  ["detect", "--source", repoPath, "--report-format", "json", "--report-path", "/dev/stdout", "--no-git", "--no-banner"],
6147
6726
  { timeout: 6e4, maxBuffer: 20 * 1024 * 1024, cwd: repoPath }
@@ -6967,7 +7546,7 @@ async function rewriteFiles(params) {
6967
7546
 
6968
7547
  // src/config.ts
6969
7548
  import { readFileSync, existsSync } from "fs";
6970
- import { resolve, join } from "path";
7549
+ import { resolve, join as join2 } from "path";
6971
7550
  import yaml from "js-yaml";
6972
7551
  import { z as z3 } from "zod";
6973
7552
  var DEFAULT_CONFIG = {
@@ -7010,7 +7589,7 @@ function levenshtein(a, b) {
7010
7589
  return dp[m][n];
7011
7590
  }
7012
7591
  function loadConfig(dir) {
7013
- const configPath = resolve(join(dir, ".elytra.yml"));
7592
+ const configPath = resolve(join2(dir, ".elytra.yml"));
7014
7593
  if (!existsSync(configPath)) return null;
7015
7594
  let raw;
7016
7595
  try {
@@ -7105,7 +7684,7 @@ function filterByConfig(findings, config) {
7105
7684
 
7106
7685
  // src/harden/harden.ts
7107
7686
  import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2, readdirSync, statSync } from "fs";
7108
- import { join as join2, relative } from "path";
7687
+ import { join as join3, relative } from "path";
7109
7688
  function readJson(filePath) {
7110
7689
  try {
7111
7690
  return JSON.parse(readFileSync2(filePath, "utf-8"));
@@ -7124,7 +7703,7 @@ function walkFiles(dir, exts, maxDepth = 6, depth = 0) {
7124
7703
  }
7125
7704
  for (const entry of entries) {
7126
7705
  if (entry === "node_modules" || entry === ".git" || entry === "dist" || entry === ".next") continue;
7127
- const full = join2(dir, entry);
7706
+ const full = join3(dir, entry);
7128
7707
  let stat2;
7129
7708
  try {
7130
7709
  stat2 = statSync(full);
@@ -7171,7 +7750,7 @@ function checkHelmet(projectPath, sourceFiles, frameworks) {
7171
7750
  if (!frameworks.includes("express")) return null;
7172
7751
  const helmetFile = anyFileContains(sourceFiles, /\bhelmet\s*\(/);
7173
7752
  if (helmetFile) return null;
7174
- const pkgJson = readJson(join2(projectPath, "package.json"));
7753
+ const pkgJson = readJson(join3(projectPath, "package.json"));
7175
7754
  const deps = {
7176
7755
  ...pkgJson?.dependencies ?? {},
7177
7756
  ...pkgJson?.devDependencies ?? {}
@@ -7312,9 +7891,9 @@ app.use(cors({
7312
7891
  return null;
7313
7892
  }
7314
7893
  function checkEnvLeakage(projectPath) {
7315
- const gitignorePath = join2(projectPath, ".gitignore");
7894
+ const gitignorePath = join3(projectPath, ".gitignore");
7316
7895
  if (!existsSync2(gitignorePath)) {
7317
- const hasEnvFile = existsSync2(join2(projectPath, ".env")) || existsSync2(join2(projectPath, ".env.local"));
7896
+ const hasEnvFile = existsSync2(join3(projectPath, ".env")) || existsSync2(join3(projectPath, ".env.local"));
7318
7897
  if (!hasEnvFile) return null;
7319
7898
  return {
7320
7899
  id: "harden-env-no-gitignore",
@@ -7333,7 +7912,7 @@ function checkEnvLeakage(projectPath) {
7333
7912
  const gitignore = readFileSync2(gitignorePath, "utf-8");
7334
7913
  const hasEnvRule = /^\.env$/m.test(gitignore) || /^\.env\.\*$/m.test(gitignore) || /^\.env\*$/m.test(gitignore) || /^\.env\.local$/m.test(gitignore);
7335
7914
  if (hasEnvRule) return null;
7336
- const hasEnvFile = existsSync2(join2(projectPath, ".env")) || existsSync2(join2(projectPath, ".env.local")) || existsSync2(join2(projectPath, ".env.production"));
7915
+ const hasEnvFile = existsSync2(join3(projectPath, ".env")) || existsSync2(join3(projectPath, ".env.local")) || existsSync2(join3(projectPath, ".env.production"));
7337
7916
  if (!hasEnvFile) return null;
7338
7917
  return {
7339
7918
  id: "harden-env-not-gitignored",
@@ -7441,7 +8020,7 @@ res.cookie("token", value, {
7441
8020
  };
7442
8021
  }
7443
8022
  function checkTsStrict(projectPath) {
7444
- const tsconfigPath = join2(projectPath, "tsconfig.json");
8023
+ const tsconfigPath = join3(projectPath, "tsconfig.json");
7445
8024
  if (!existsSync2(tsconfigPath)) return null;
7446
8025
  const tsconfig = readJson(tsconfigPath);
7447
8026
  if (!tsconfig) return null;
@@ -7461,7 +8040,7 @@ function checkTsStrict(projectPath) {
7461
8040
  function applyHardenFix(projectPath, suggestion) {
7462
8041
  if (!suggestion.autoFixable) return false;
7463
8042
  if (suggestion.id === "harden-ts-no-strict") {
7464
- const tsconfigPath = join2(projectPath, "tsconfig.json");
8043
+ const tsconfigPath = join3(projectPath, "tsconfig.json");
7465
8044
  const tsconfig = readJson(tsconfigPath);
7466
8045
  if (!tsconfig) return false;
7467
8046
  const compilerOptions = tsconfig.compilerOptions ?? {};
@@ -7477,7 +8056,7 @@ function applyHardenFix(projectPath, suggestion) {
7477
8056
  return false;
7478
8057
  }
7479
8058
  function runHarden(projectPath) {
7480
- const pkgJsonPath = join2(projectPath, "package.json");
8059
+ const pkgJsonPath = join3(projectPath, "package.json");
7481
8060
  const pkgJson = readJson(pkgJsonPath);
7482
8061
  if (!pkgJson) {
7483
8062
  return { suggestions: [], projectPath, frameworksDetected: [] };
@@ -7588,7 +8167,7 @@ Respond with a valid JSON object:
7588
8167
  // src/full-scan-discovery.ts
7589
8168
  import { execSync } from "child_process";
7590
8169
  import { readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7591
- import { join as join3, relative as relative2, extname } from "path";
8170
+ import { join as join4, relative as relative2, extname } from "path";
7592
8171
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
7593
8172
  ".ts",
7594
8173
  ".tsx",
@@ -7717,7 +8296,7 @@ function discoverViaGit(targetPath, maxFileSizeKB) {
7717
8296
  for (const line of raw.split("\n")) {
7718
8297
  const rel = line.trim();
7719
8298
  if (!rel || !isSourceFile(rel)) continue;
7720
- const abs = join3(targetPath, rel);
8299
+ const abs = join4(targetPath, rel);
7721
8300
  try {
7722
8301
  const stat2 = statSync2(abs);
7723
8302
  if (!stat2.isFile()) continue;
@@ -7749,9 +8328,9 @@ function discoverViaWalk(targetPath, maxFileSizeKB) {
7749
8328
  if (entry.isDirectory()) {
7750
8329
  if (SKIP_DIRS.has(entry.name)) continue;
7751
8330
  if (entry.name.startsWith(".") && !ALLOWED_HIDDEN_DIRS.has(entry.name)) continue;
7752
- walk(join3(dir, entry.name));
8331
+ walk(join4(dir, entry.name));
7753
8332
  } else if (entry.isFile()) {
7754
- const abs = join3(dir, entry.name);
8333
+ const abs = join4(dir, entry.name);
7755
8334
  const rel = relative2(targetPath, abs);
7756
8335
  if (!isSourceFile(rel)) continue;
7757
8336
  try {
@@ -8023,175 +8602,6 @@ function buildSummary2(findings, fileCount, toolsRan) {
8023
8602
  }
8024
8603
  return summary;
8025
8604
  }
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
8605
  export {
8196
8606
  AIClient,
8197
8607
  AnthropicProvider,
package/package.json CHANGED
@@ -1,20 +1,15 @@
1
1
  {
2
2
  "name": "@elytrasec/engine",
3
- "version": "0.3.1",
4
- "description": "Core analysis engine for Elytra — 120+ detection rules, static + AI scanning, scoring",
3
+ "version": "0.4.0",
4
+ "description": "Core analysis engine for Elytra — 173 detection rules including 12 famous-hack patterns and 11 rug-surface checks, static + AI scanning, scoring.",
5
5
  "license": "MIT",
6
6
  "author": "ElytraSec <hello@elytrasec.io>",
7
7
  "homepage": "https://elytrasec.io",
8
- "bugs": "https://github.com/ElytraSec/elytrasecsec/issues",
9
- "keywords": ["security", "scanner", "static-analysis", "code-review", "vulnerability-detection"],
8
+ "bugs": "https://elytrasec.io/agents",
9
+ "keywords": ["security", "scanner", "static-analysis", "code-review", "vulnerability-detection", "solidity", "defi", "rug-surface"],
10
10
  "engines": {
11
11
  "node": ">=20"
12
12
  },
13
- "repository": {
14
- "type": "git",
15
- "url": "https://github.com/ElytraSec/elytrasecsec.git",
16
- "directory": "packages/engine"
17
- },
18
13
  "publishConfig": {
19
14
  "access": "public"
20
15
  },