@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 +37 -0
- package/dist/index.js +644 -234
- package/package.json +4 -9
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
|
-
|
|
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
|
|
3305
|
-
description: "An external call
|
|
3306
|
-
suggestion: "Follow
|
|
3307
|
-
pattern: /\.
|
|
3308
|
-
severity: "
|
|
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: "
|
|
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 (
|
|
3395
|
-
suggestion: "
|
|
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: "
|
|
3398
|
-
category: "
|
|
3399
|
-
confidence: "
|
|
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: "
|
|
3417
|
-
suggestion: "
|
|
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: "
|
|
3420
|
-
category: "
|
|
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: "
|
|
3493
|
+
severity: "medium",
|
|
3455
3494
|
category: "security",
|
|
3456
|
-
confidence: "
|
|
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
|
|
3463
|
-
suggestion: "Add an event emission after state changes
|
|
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: "
|
|
3468
|
-
category: "
|
|
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
|
|
4611
|
-
suggestion: "
|
|
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: "
|
|
4652
|
+
severity: "high",
|
|
4614
4653
|
category: "solidity",
|
|
4615
|
-
confidence: "
|
|
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 (
|
|
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
|
-
|
|
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
|
|
5486
|
-
import { promisify as
|
|
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
|
|
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
|
|
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
|
|
5774
|
-
import { promisify as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
6057
|
-
import { promisify as
|
|
6635
|
+
import { execFile as execFile7 } from "child_process";
|
|
6636
|
+
import { promisify as promisify7 } from "util";
|
|
6058
6637
|
import path6 from "path";
|
|
6059
|
-
var
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
7894
|
+
const gitignorePath = join3(projectPath, ".gitignore");
|
|
7316
7895
|
if (!existsSync2(gitignorePath)) {
|
|
7317
|
-
const hasEnvFile = existsSync2(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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(
|
|
8331
|
+
walk(join4(dir, entry.name));
|
|
7753
8332
|
} else if (entry.isFile()) {
|
|
7754
|
-
const abs =
|
|
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.
|
|
4
|
-
"description": "Core analysis engine for Elytra —
|
|
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://
|
|
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
|
},
|