diff --git a/docs/erc-4337-rules.md b/docs/erc-4337-rules.md new file mode 100644 index 0000000..243176b --- /dev/null +++ b/docs/erc-4337-rules.md @@ -0,0 +1,44 @@ +# ERC-4337 Security Rules + +ChainProof includes deterministic, source-only analysis for smart accounts, EntryPoints, factories, aggregators, and paymasters. The analyzer runs as part of the normal `@chainproof/core` `scan()` pipeline and does not call a chain, bundler, RPC provider, or external service. + +## Compatibility + +The versioned adapter API currently recognizes EntryPoint/UserOperation patterns for `0.6`, `0.7`, and `0.8`. Use `version: "auto"` for marker-based selection or select a version explicitly when a project uses custom interfaces. Detection is conservative about unknown architectures: findings include assumptions and confidence and should be reviewed against the implementation. + +## Covered risks + +Rules cover UserOperation hash and replay domains, nonce validation, validation-data handling, aggregate signatures, paymaster gas/deposit/context/postOp behavior, counterfactual initialization and CREATE2 derivation, module/session authorization, upgrade authorization, and fallback dispatch. + +Stable IDs use the `CP-4337-*` prefix, including `CP-4337-HASH_BINDING`, `CP-4337-ENTRYPOINT_DOMAIN`, `CP-4337-NONCE_REPLAY`, `CP-4337-PAYMASTER_POSTOP`, and related component rules. Findings contain source locations, evidence paths, assumptions, and confidence where applicable. + +## Configuration + +TypeScript: + +```ts +import { scan } from "@chainproof/core"; + +const result = await scan({ + targets: ["contracts/"], + useSlither: false, + useLLM: false, + useMetrics: false, + erc4337: { + version: "auto", + limits: { maxDiagnostics: 100, maxEvidenceItems: 8 }, + }, +}); +``` + +CLI options are `--erc4337-version auto|0.6|0.7|0.8` and `--erc4337-max-diagnostics `. The same values can be placed in `.chainproofrc.json` under `erc4337` and passed through the REST API or GitHub Action. + +## Security boundaries and limitations + +The analyzer uses bounded lexical and AST evidence. It cannot prove runtime storage invariants, cryptographic correctness of custom signature schemes, deployed bytecode identity, bundler behavior, or live EntryPoint deposits. Custom encodings and generated Solidity may lower confidence or produce no finding. Do not treat an empty result as proof of safety. + +Source sizes, function traversal, evidence, and diagnostics are bounded. Aborted analyses return a valid, empty result rather than leaking partial provider or local-path data. Output ordering is deterministic for stable CI diffs. + +## Troubleshooting + +If a custom EntryPoint is misclassified, set the adapter version explicitly and inspect the finding assumptions. If a report is too noisy, use `--min-severity` or lower the ERC-4337 diagnostic budget while reviewing the highest-confidence findings first. For architectures with generated interfaces, scan the implementation and interface sources together so recognizable field and authorization evidence is available. diff --git a/examples/contracts/erc4337/SecureAccount4337.sol b/examples/contracts/erc4337/SecureAccount4337.sol new file mode 100644 index 0000000..b08bbb4 --- /dev/null +++ b/examples/contracts/erc4337/SecureAccount4337.sol @@ -0,0 +1,56 @@ +pragma solidity ^0.8.20; + +contract SecureAccount4337 { + address public immutable entryPoint; + mapping(uint192 => uint256) private nonceSequence; + mapping(address => bool) private session; + mapping(address => uint256) private sponsorshipBudget; + + modifier onlyEntryPoint() { + require(msg.sender == entryPoint, "entry point"); + _; + } + + function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256) external onlyEntryPoint returns (uint256) { + require(userOpHash == keccak256(abi.encode(address(this), block.chainid, entryPoint, userOp.sender, userOp.nonce, userOp.callData)), "hash"); + uint192 key = uint192(userOp.nonce >> 64); + require(userOp.nonce == (key << 64) | nonceSequence[key], "nonce"); + nonceSequence[key]++; + return 0; + } + + function validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) external onlyEntryPoint returns (bytes memory) { + require(userOpHash != bytes32(0), "hash"); + require(maxCost <= sponsorshipBudget[userOp.sender], "budget"); + require(userOp.paymasterData.length >= 32, "context"); + return userOp.paymasterData; + } + + function postOp(bytes calldata contextData, uint256 actualGasCost) external onlyEntryPoint { + require(contextData.length >= 32, "context"); + require(actualGasCost <= sponsorshipBudget[address(this)], "cost"); + sponsorshipBudget[address(this)] -= actualGasCost; + } + + function initialize(address expectedSession) external { + require(!session[expectedSession], "initialized"); + session[expectedSession] = true; + } +} + +struct PackedUserOperation { + address sender; + uint256 nonce; + bytes initCode; + bytes callData; + uint256 callGasLimit; + uint256 verificationGasLimit; + uint256 preVerificationGas; + uint256 maxFeePerGas; + uint256 maxPriorityFeePerGas; + address paymaster; + uint256 paymasterVerificationGasLimit; + uint256 paymasterPostOpGasLimit; + bytes paymasterData; + bytes signature; +} diff --git a/examples/contracts/erc4337/VulnerableAccount4337.sol b/examples/contracts/erc4337/VulnerableAccount4337.sol new file mode 100644 index 0000000..468ba3d --- /dev/null +++ b/examples/contracts/erc4337/VulnerableAccount4337.sol @@ -0,0 +1,50 @@ +pragma solidity ^0.8.20; + +contract VulnerableAccount4337 { + mapping(address => bool) public session; + bytes public context; + + function validateUserOp(PackedUserOperation calldata userOp, bytes32, uint256) external returns (uint256) { + if (session[userOp.sender]) { + context = userOp.paymasterData; + } + return 0; + } + + function validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32, uint256) external returns (bytes memory) { + context = userOp.paymasterData; + return context; + } + + function postOp(bytes calldata contextData, uint256 actualGasCost) external { + (bool ok,) = address(this).call(contextData); + require(ok); + } + + function execute(bytes calldata callData) external { + (bool ok,) = address(this).call(callData); + require(ok); + } + + fallback() external payable { + (bool ok,) = address(this).call(msg.data); + require(ok); + } +} + +struct PackedUserOperation { + address sender; + uint256 nonce; + bytes initCode; + bytes callData; + uint256 callGasLimit; + uint256 verificationGasLimit; + uint256 preVerificationGas; + uint256 maxFeePerGas; + uint256 maxPriorityFeePerGas; + address paymaster; + uint256 paymasterVerificationGasLimit; + uint256 paymasterPostOpGasLimit; + bytes paymasterData; + bytes signature; +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1339203..6f509ea 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -20,6 +20,7 @@ import { loadPlugins, loadConfigFile, mergePluginsFromConfig, + mergeERC4337ConfigFromConfig, generateThreatModel, generateMarkdownThreatModel, generateJSONThreatModel, @@ -90,6 +91,8 @@ program ) .option("--format ", "Output format: table|json|markdown", "table") .option("--output ", "Write report to file instead of stdout") + .option("--erc4337-version ", "ERC-4337 adapter version: auto|0.6|0.7|0.8", "auto") + .option("--erc4337-max-diagnostics ", "Maximum ERC-4337 diagnostics per file", "100") .option( "--plugin ", "Load a custom plugin (can be used multiple times)", @@ -111,6 +114,8 @@ program format: string; output?: string; plugin: string[]; + erc4337Version: string; + erc4337MaxDiagnostics: string; }, ) => { @@ -146,6 +151,7 @@ program // Load plugins from CLI or config file let plugins = []; + let configuredERC4337: ScanConfig["erc4337"] | undefined; if (opts.plugin.length > 0) { plugins = loadPlugins(opts.plugin); } else { @@ -162,6 +168,17 @@ program configFile, ); plugins = merged.plugins || []; + configuredERC4337 = mergeERC4337ConfigFromConfig( + { + targets, + useSlither, + useLLM, + useMetrics, + apiKey, + minSeverity: opts.minSeverity as ScanConfig["minSeverity"], + }, + configFile, + ).erc4337; } console.log( @@ -186,6 +203,10 @@ program minSeverity: opts.minSeverity as ScanConfig["minSeverity"], outputFormat: opts.format as ScanConfig["outputFormat"], plugins, + erc4337: configuredERC4337 ?? { + version: opts.erc4337Version as "auto" | "0.6" | "0.7" | "0.8", + limits: { maxDiagnostics: Number(opts.erc4337MaxDiagnostics) }, + }, }; let result; @@ -471,6 +492,7 @@ program outputFormat: "markdown", output: "audit-report.md", plugins: [], + erc4337: { version: "auto", limits: { maxDiagnostics: 100 } }, }; const configPath = path.join(process.cwd(), ".chainproofrc.json"); if (fs.existsSync(configPath)) { diff --git a/packages/core/src/__tests__/scanner.test.ts b/packages/core/src/__tests__/scanner.test.ts index 2b82d75..52e5f44 100644 --- a/packages/core/src/__tests__/scanner.test.ts +++ b/packages/core/src/__tests__/scanner.test.ts @@ -10,6 +10,10 @@ const SECURE_PATH = path.resolve( __dirname, "../../../../examples/contracts/SecureVault.sol" ); +const ERC4337_PATH = path.resolve( + __dirname, + "../../../../examples/contracts/erc4337/VulnerableAccount4337.sol" +); describe("scan() — integration", () => { it("returns a valid ScanResult structure", async () => { @@ -124,4 +128,16 @@ describe("scan() — integration", () => { const result = await scan({ targets: [dir], useSlither: false, useLLM: false, useMetrics: false }); expect(result.files.length).toBeGreaterThan(0); }); + + it("registers ERC-4337 rules in the standard scan pipeline", async () => { + const result = await scan({ + targets: [ERC4337_PATH], + useSlither: false, + useLLM: false, + useMetrics: false, + }); + const ids = result.files.flatMap((file) => file.findings.map((finding) => finding.id)); + expect(ids).toContain("CP-4337-NONCE_REPLAY"); + expect(ids).toContain("CP-4337-PAYMASTER_POSTOP"); + }); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 92634aa..e01d8d5 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -2,10 +2,12 @@ import * as fs from "fs"; import * as path from "path"; import { loadPlugins } from "./plugins"; import type { ScanConfig, SlitherConfig } from "./types"; +import type { ERC4337AnalysisOptions } from "./erc4337/types"; export interface ChainProofConfig { plugins?: string[]; slither?: SlitherConfig; + erc4337?: ERC4337AnalysisOptions; [key: string]: unknown; } @@ -116,3 +118,12 @@ export function mergeSlitherConfigFromConfig( slither: configFile.slither, }; } + +/** Merge versioned ERC-4337 settings while preserving explicit scan options. */ +export function mergeERC4337ConfigFromConfig( + config: ScanConfig, + configFile?: ChainProofConfig | null, +): ScanConfig { + if (!configFile?.erc4337 || config.erc4337) return config; + return { ...config, erc4337: configFile.erc4337 }; +} diff --git a/packages/core/src/erc4337/__tests__/analyzer.test.ts b/packages/core/src/erc4337/__tests__/analyzer.test.ts new file mode 100644 index 0000000..e4e33bc --- /dev/null +++ b/packages/core/src/erc4337/__tests__/analyzer.test.ts @@ -0,0 +1,47 @@ +import * as fs from "fs"; +import * as path from "path"; +import { parseSolidity } from "../../ast/parser"; +import { analyzeERC4337, detectERC4337 } from "../analyzer"; + +const FIXTURES = path.resolve(__dirname, "../../../../../examples/contracts/erc4337"); + +function readFixture(name: string): { source: string; file: string; ast: any } { + const file = path.join(FIXTURES, name); + const source = fs.readFileSync(file, "utf8"); + const parsed = parseSolidity(source, file); + expect(parsed.ast).not.toBeNull(); + return { source, file, ast: parsed.ast }; +} + +describe("ERC-4337 analyzer", () => { + it("models versioned UserOperations and detects vulnerable paymaster paths", () => { + const fixture = readFixture("VulnerableAccount4337.sol"); + const analysis = analyzeERC4337(fixture.ast, fixture.source, fixture.file); + expect(analysis.protocol).toBe("erc-4337"); + expect(analysis.schemaVersion).toBe("erc4337-analysis-1"); + expect(analysis.version).toBe("0.8"); + expect(analysis.userOperation?.fields.length).toBeGreaterThan(10); + expect(analysis.diagnostics.map((item) => item.code)).toEqual( + expect.arrayContaining(["AA003_NONCE_REPLAY", "AA007_PAYMASTER_DEPOSIT", "AA008_PAYMASTER_POSTOP"]), + ); + }); + + it("keeps secure validation free of nonce and paymaster findings", () => { + const fixture = readFixture("SecureAccount4337.sol"); + const findings = detectERC4337(fixture.ast, fixture.source, fixture.file); + expect(findings.map((finding) => finding.id)).not.toEqual( + expect.arrayContaining(["CP-4337-NONCE_REPLAY", "CP-4337-PAYMASTER_LIMIT", "CP-4337-PAYMASTER_POSTOP"]), + ); + }); + + it("is deterministic and honors diagnostic bounds", () => { + const fixture = readFixture("VulnerableAccount4337.sol"); + const options = { limits: { maxDiagnostics: 2, maxEvidenceItems: 1 } }; + const first = analyzeERC4337(fixture.ast, fixture.source, fixture.file, options); + const second = analyzeERC4337(fixture.ast, fixture.source, fixture.file, options); + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + expect(first.diagnostics).toHaveLength(2); + expect(first.truncated).toBe(true); + expect(first.diagnostics.every((item) => item.evidence.length <= 1)).toBe(true); + }); +}); diff --git a/packages/core/src/erc4337/adapters.ts b/packages/core/src/erc4337/adapters.ts new file mode 100644 index 0000000..e08b655 --- /dev/null +++ b/packages/core/src/erc4337/adapters.ts @@ -0,0 +1,70 @@ +import type { ERC4337Version } from "./types"; + +export interface ERC4337Adapter { + version: ERC4337Version; + userOperationTypeNames: readonly string[]; + entryPointFunctionNames: readonly string[]; + paymasterFunctionNames: readonly string[]; + packedUserOperation: boolean; + supportsAggregatedOperations: boolean; + markers: readonly RegExp[]; +} + +const ADAPTERS: readonly ERC4337Adapter[] = [ + { + version: "0.6", + userOperationTypeNames: ["UserOperation"], + entryPointFunctionNames: ["handleOps", "handleAggregatedOps", "getUserOpHash"], + paymasterFunctionNames: ["validatePaymasterUserOp", "postOp"], + packedUserOperation: false, + supportsAggregatedOperations: true, + markers: [/IEntryPoint/, /UserOperation\s+(?:calldata|memory)/], + }, + { + version: "0.7", + userOperationTypeNames: ["PackedUserOperation"], + entryPointFunctionNames: ["handleOps", "handleAggregatedOps", "getUserOpHash"], + paymasterFunctionNames: ["validatePaymasterUserOp", "postOp"], + packedUserOperation: true, + supportsAggregatedOperations: true, + markers: [/PackedUserOperation/, /IEntryPoint\s*\{/], + }, + { + version: "0.8", + userOperationTypeNames: ["PackedUserOperation"], + entryPointFunctionNames: ["handleOps", "handleAggregatedOps", "getUserOpHash"], + paymasterFunctionNames: ["validatePaymasterUserOp", "postOp"], + packedUserOperation: true, + supportsAggregatedOperations: true, + markers: [/PackedUserOperation/, /postOp\s*\(/, /validatePaymasterUserOp/], + }, +]; + +export function getERC4337Adapter(version: ERC4337Version): ERC4337Adapter { + return ADAPTERS.find((adapter) => adapter.version === version) ?? ADAPTERS[1]; +} + +export function listERC4337Adapters(): readonly ERC4337Adapter[] { + return ADAPTERS; +} + +export function detectERC4337Version(source: string): ERC4337Version { + const scored = ADAPTERS.map((adapter) => ({ + adapter, + score: adapter.markers.filter((marker) => marker.test(source)).length, + })); + scored.sort((left, right) => right.score - left.score || left.adapter.version.localeCompare(right.adapter.version)); + return scored[0]?.score ? scored[0].adapter.version : "0.7"; +} + +export function adapterSupportsFunction(version: ERC4337Version, functionName: string): boolean { + const adapter = getERC4337Adapter(version); + return [...adapter.entryPointFunctionNames, ...adapter.paymasterFunctionNames].includes(functionName); +} + +export function canonicalUserOperationFields(version: ERC4337Version): readonly string[] { + if (version === "0.6") { + return ["sender", "nonce", "initCode", "callData", "callGasLimit", "verificationGasLimit", "preVerificationGas", "maxFeePerGas", "maxPriorityFeePerGas", "paymasterAndData", "signature"]; + } + return ["sender", "nonce", "initCode", "callData", "accountGasLimits", "preVerificationGas", "gasFees", "paymasterAndData", "signature"]; +} diff --git a/packages/core/src/erc4337/analyzer.ts b/packages/core/src/erc4337/analyzer.ts new file mode 100644 index 0000000..6fdf08c --- /dev/null +++ b/packages/core/src/erc4337/analyzer.ts @@ -0,0 +1,275 @@ +import type { ASTNode, Finding } from "../types"; +import { getSnippet, visit } from "../ast/parser"; +import { detectERC4337Version } from "./adapters"; +import { + DEFAULT_ERC4337_ANALYSIS_LIMITS, + type ERC4337AnalysisLimits, + type ERC4337AnalysisOptions, + type ERC4337AnalysisResult, + type ERC4337Component, + type ERC4337Diagnostic, + type ERC4337DiagnosticCode, + type ERC4337Evidence, + type ERC4337Version, + type EntryPointModel, + type NonceModel, + type PaymasterModel, + type UserOperationField, + type UserOperationModel, + type ValidationDataModel, +} from "./types"; + +const USER_OPERATION_FIELDS = [ + "sender", "nonce", "initCode", "callData", "callGasLimit", "verificationGasLimit", + "preVerificationGas", "maxFeePerGas", "maxPriorityFeePerGas", "paymasterAndData", + "signature", "factory", "factoryData", "paymaster", "paymasterVerificationGasLimit", + "paymasterPostOpGasLimit", "paymasterData", +]; + +const FUNCTION_PATTERN = /function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)[^{]*\{/g; +const HASH_CALL_PATTERN = /(?:keccak256|hash|userOpHash|_getUserOpHash)\s*\(([^)]*)\)/g; +const MAX_SOURCE_LENGTH = DEFAULT_ERC4337_ANALYSIS_LIMITS.maxSourceLength; + +export function detectERC4337( + ast: ASTNode, + source: string, + filePath: string, + options: ERC4337AnalysisOptions = {}, +): Finding[] { + return toFindings(analyzeERC4337(ast, source, filePath, options), source); +} + +export function analyzeERC4337( + ast: ASTNode, + source: string, + filePath: string, + options: ERC4337AnalysisOptions = {}, +): ERC4337AnalysisResult { + const limits = normalizeLimits(options.limits); + const version = resolveVersion(source, options.version ?? "auto"); + const result: ERC4337AnalysisResult = { + schemaVersion: "erc4337-analysis-1", + protocol: "erc-4337", + version, + components: [], + diagnostics: [], + truncated: false, + }; + + if (source.length > limits.maxSourceLength) { + result.truncated = true; + return result; + } + if (options.signal?.aborted) return result; + + const functions = collectFunctions(source, limits.maxFunctions); + if (functions.truncated) result.truncated = true; + const lowerSource = source.toLowerCase(); + const hasUserOperation = /useroperation|packeduseroperation/.test(lowerSource); + const hasEntryPoint = /entrypoint|handleops|handleaggregatedops/.test(lowerSource); + const hasPaymaster = /paymaster|validatepaymasteruserop|postop/.test(lowerSource); + const hasFactory = /create2|factory|initcode|factorydata/.test(lowerSource); + const hasAggregator = /aggregator|aggregatedsignature|validateuseropsignature/.test(lowerSource); + + if (hasUserOperation) { + result.components.push("account"); + result.userOperation = modelUserOperation(version, source, functions.names); + result.validationData = modelValidationData(source); + result.nonce = modelNonce(source); + addUserOperationDiagnostics(result, filePath, source, limits); + } + if (hasEntryPoint) { + result.components.push("entryPoint"); + result.entryPoint = modelEntryPoint(version, source); + addEntryPointDiagnostics(result, filePath, source, limits); + } + if (hasPaymaster) { + result.components.push("paymaster"); + result.paymaster = modelPaymaster(source); + addPaymasterDiagnostics(result, filePath, source, limits); + } + if (hasFactory) { + result.components.push("factory"); + addFactoryDiagnostics(result, filePath, source, limits); + } + if (hasAggregator) { + result.components.push("aggregator"); + addAggregatorDiagnostics(result, filePath, source, limits); + } + if (/session|module|fallback|upgrade|initialize/.test(lowerSource)) { + addModuleAndLifecycleDiagnostics(result, filePath, source, limits); + } + + result.components = [...new Set(result.components)]; + result.diagnostics.sort((left, right) => left.line - right.line || left.code.localeCompare(right.code)); + return result; +} + +function normalizeLimits(limits?: Partial): ERC4337AnalysisLimits { + const candidate = { ...DEFAULT_ERC4337_ANALYSIS_LIMITS, ...limits }; + return { + maxSourceLength: clamp(candidate.maxSourceLength, 1_000, MAX_SOURCE_LENGTH), + maxFunctions: clamp(candidate.maxFunctions, 1, 10_000), + maxDiagnostics: clamp(candidate.maxDiagnostics, 1, 1_000), + maxEvidenceItems: clamp(candidate.maxEvidenceItems, 1, 32), + }; +} + +function clamp(value: number, min: number, max: number): number { + return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.floor(value))) : min; +} + +function resolveVersion(source: string, requested: ERC4337AnalysisOptions["version"]): ERC4337Version { + if (requested && requested !== "auto") return requested; + return detectERC4337Version(source); +} + +function collectFunctions(source: string, limit: number): { names: string[]; truncated: boolean } { + const names: string[] = []; + let match: RegExpExecArray | null; + while ((match = FUNCTION_PATTERN.exec(source)) !== null) { + names.push(match[1]); + if (names.length >= limit) return { names, truncated: true }; + } + return { names, truncated: false }; +} + +function modelUserOperation(version: ERC4337Version, source: string, functions: string[]): UserOperationModel { + const fields: UserOperationField[] = USER_OPERATION_FIELDS.map((name) => ({ + name, + type: name === "nonce" ? "uint256" : "bytes", + required: version === "0.6" ? !["factory", "factoryData", "paymaster", "paymasterData"].includes(name) : true, + hashBound: hashBindsField(source, name), + })); + return { + version, + fields, + hasPaymaster: /paymaster|paymasteranddata/i.test(source), + hasFactory: /factory|initcode/i.test(source), + hasAggregator: /aggregator|aggregatedsignature/i.test(source), + hashFunctions: functions.filter((name) => /hash|signature|validate/i.test(name.toLowerCase())), + nonceFunctions: functions.filter((name) => /nonce|validate|execute/i.test(name.toLowerCase())), + }; +} + +function modelValidationData(source: string): ValidationDataModel { + return { + hasAuthorizer: /authorizer|validationdata/i.test(source), + hasTimeRange: /validafter|validuntil|validity|timeRange/i.test(source), + usesPackedEncoding: /validationdata|abi\.encodepacked|uint48/i.test(source), + rejectsInvalidAuthorizer: /authorizer\s*==\s*address\(0\)|authorizer\s*!==\s*address\(0\)/i.test(source), + sourceLine: lineOf(source, /validationdata|authorizer/i), + }; +} + +function modelNonce(source: string): NonceModel { + return { + hasNonceStorage: /mapping\s*\([^)]*\)\s*(?:public\s*)?nonces?|uint256\s+(?:public\s+)?nonce/i.test(source), + usesKeyedNonce: /mapping\s*\s*\(\s*uint192\s*=>|nonce\s*>>\s*64|nonce\s*\/\s*2\*\*\s*64/i.test(source), + incrementsBeforeExecution: /nonce[^;]*(\+\+|\+=\s*1)|\+\+[^;]*nonce/i.test(source), + validatesNonce: /nonce[^;]*(==|!=|require|revert)|require\s*\([^)]*nonce/i.test(source), + sourceLine: lineOf(source, /nonce/i), + }; +} + +function modelEntryPoint(version: ERC4337Version, source: string): EntryPointModel { + return { + version, + functions: functionNames(source).filter((name) => /handle|simulate|deposit|stake|nonce/i.test(name)), + bindsChainId: /chainid|block\.chainid/i.test(source), + bindsEntryPoint: /entrypoint|address\(this\)|msg\.sender/i.test(source), + validatesSender: /sender|validateuserop|validateaccount/i.test(source), + hasDepositAccounting: /deposit|withdraw|stake|balance/i.test(source), + sourceLine: lineOf(source, /entrypoint|handleops/i), + }; +} + +function modelPaymaster(source: string): PaymasterModel { + return { + hasValidation: /validatepaymasteruserop|validatepaymaster/i.test(source), + hasPostOp: /postop/i.test(source), + validatesGasLimits: /verificationgas|postopgas|maxgas|gasleft/i.test(source), + validatesContext: /context|paymasterdata/i.test(source), + tracksDeposit: /deposit|withdraw|balance/i.test(source), + hasSponsorshipLimit: /limit|quota|allowance|budget|sponsor/i.test(source), + externalContextCalls: /context[^;]*(call|transfer|send)|(?:call|transfer|send)[^;]*context/i.test(source), + sourceLine: lineOf(source, /paymaster/i), + }; +} + +function addUserOperationDiagnostics(result: ERC4337AnalysisResult, filePath: string, source: string, limits: ERC4337AnalysisLimits): void { + const model = result.userOperation!; + for (const field of model.fields) { + if (!field.hashBound && ["sender", "nonce", "callData", "paymasterAndData", "factory"].includes(field.name)) { + addDiagnostic(result, limits, diagnostic("AA001_HASH_BINDING", "account", result.version, "UserOperation hash omits security-critical field", `The ${field.name} field appears in the UserOperation model but is not visibly bound into the signed hash.`, "Include every operation-defining field in the canonical hash before signature validation.", "high", filePath, lineOf(source, new RegExp(field.name, "i")) ?? 1, [{ path: `UserOperation.${field.name}`, description: "Field is modeled but no matching hash input was found." }], ["The detector relies on recognizable field names and hash construction in source."], "medium")); + } + } + if (!model.nonceFunctions.some((name) => /nonce/i.test(name)) || !result.nonce?.validatesNonce) { + addDiagnostic(result, limits, diagnostic("AA003_NONCE_REPLAY", "account", result.version, "UserOperation nonce is not clearly validated", "The account exposes UserOperation execution or validation but no bounded nonce validation was identified.", "Validate the nonce against the EntryPoint nonce domain and consume it exactly once before execution.", "high", filePath, result.nonce?.sourceLine ?? 1, [{ path: "account.validateUserOp.nonce", description: "No recognizable nonce comparison or rejection was found." }], ["A custom nonce abstraction may be implemented outside recognizable Solidity expressions."], "low")); + } + if (result.validationData && result.validationData.hasTimeRange && !result.validationData.rejectsInvalidAuthorizer) { + addDiagnostic(result, limits, diagnostic("AA004_VALIDATION_EXECUTION", "account", result.version, "Validation data is not rejected consistently", "Validation data or an authorizer is decoded, but invalid authorization is not clearly rejected before execution.", "Reject failed authorizers and enforce validAfter/validUntil semantics in the EntryPoint validation path.", "high", filePath, result.validationData.sourceLine ?? 1, [{ path: "validationData.authorizer", description: "Time-range or authorizer fields are decoded without a visible invalid-value guard." }], [], "medium")); + } +} + +function addEntryPointDiagnostics(result: ERC4337AnalysisResult, filePath: string, source: string, limits: ERC4337AnalysisLimits): void { + const model = result.entryPoint!; + if (!model.bindsChainId) addDiagnostic(result, limits, diagnostic("AA002_ENTRYPOINT_DOMAIN", "entryPoint", result.version, "UserOperation domain omits chain identity", "The EntryPoint/account hash path does not visibly bind the operation to block.chainid.", "Bind the chain ID and canonical EntryPoint address into the UserOperation hash.", "high", filePath, model.sourceLine ?? 1, [{ path: "EntryPoint.getUserOpHash.domain", description: "No chain ID binding was found." }], [], "medium")); + if (!model.bindsEntryPoint) addDiagnostic(result, limits, diagnostic("AA002_ENTRYPOINT_DOMAIN", "entryPoint", result.version, "UserOperation domain omits EntryPoint identity", "The operation hash path does not visibly bind the canonical EntryPoint address.", "Include the trusted EntryPoint address in the signed domain and reject calls from other EntryPoints.", "high", filePath, model.sourceLine ?? 1, [{ path: "EntryPoint.getUserOpHash.entryPoint", description: "No EntryPoint binding was found." }], [], "medium")); + if (model.hasDepositAccounting && !/onlyentrypoint|msg\.sender\s*==\s*entrypoint|trustedentrypoint/i.test(source)) addDiagnostic(result, limits, diagnostic("AA007_PAYMASTER_DEPOSIT", "entryPoint", result.version, "Deposit accounting lacks visible EntryPoint authorization", "Deposit or stake accounting is exposed without a recognizable EntryPoint-only access check.", "Restrict deposit, stake, and withdrawal accounting to the canonical EntryPoint and validate beneficiary ownership.", "high", filePath, model.sourceLine ?? 1, [{ path: "EntryPoint.deposit", description: "Deposit accounting exists without a trusted-caller guard." }], [], "low")); +} + +function addPaymasterDiagnostics(result: ERC4337AnalysisResult, filePath: string, source: string, limits: ERC4337AnalysisLimits): void { + const model = result.paymaster!; + if (model.hasValidation && !model.validatesGasLimits) addDiagnostic(result, limits, diagnostic("AA006_PAYMASTER_LIMIT", "paymaster", result.version, "Paymaster validation omits visible gas-limit checks", "The paymaster validates sponsorship without recognizable verification or post-operation gas-limit validation.", "Validate all sponsored gas limits and bound the maximum liability before returning context.", "high", filePath, model.sourceLine ?? 1, [{ path: "Paymaster.validatePaymasterUserOp.gas", description: "No gas-limit validation was found." }], [], "medium")); + if (model.hasPostOp && !model.tracksDeposit) addDiagnostic(result, limits, diagnostic("AA007_PAYMASTER_DEPOSIT", "paymaster", result.version, "Paymaster postOp lacks visible deposit accounting", "postOp is implemented but no accounting path ties actual cost or failure handling to the paymaster deposit.", "Charge bounded actual cost, handle postOp failure deterministically, and maintain sufficient EntryPoint deposit.", "high", filePath, model.sourceLine ?? 1, [{ path: "Paymaster.postOp.deposit", description: "postOp exists without recognizable deposit accounting." }], [], "medium")); + if (model.hasPostOp && model.externalContextCalls) addDiagnostic(result, limits, diagnostic("AA008_PAYMASTER_POSTOP", "paymaster", result.version, "Paymaster postOp performs context-sensitive external work", "postOp uses externally supplied context around an external call, creating a griefing or state-confusion surface.", "Authenticate and length-bound context, isolate external work, and make postOp failure and replay behavior explicit.", "high", filePath, model.sourceLine ?? 1, [{ path: "Paymaster.postOp.context", description: "Context and external call patterns overlap." }], [], "medium")); + if (model.hasValidation && model.validatesContext && !/calldatasize|length|bytes4|decode/i.test(source)) addDiagnostic(result, limits, diagnostic("AA009_PAYMASTER_CONTEXT", "paymaster", result.version, "Paymaster context is not visibly validated", "The paymaster accepts context from validation without recognizable length, selector, or encoding checks.", "Treat validation context as untrusted data: authenticate its origin, validate its encoding and bounds, and avoid trusting mutable fields in postOp.", "medium", filePath, model.sourceLine ?? 1, [{ path: "Paymaster.context", description: "Context is used without recognizable structural validation." }], [], "low")); + if (model.hasValidation && !model.hasSponsorshipLimit) addDiagnostic(result, limits, diagnostic("AA006_PAYMASTER_LIMIT", "paymaster", result.version, "Paymaster sponsorship has no visible policy limit", "The paymaster sponsors operations without a recognizable per-user, per-token, or budget limit.", "Bound sponsorship by caller, account, token, time window, and total budget; fail closed when limits are exhausted.", "medium", filePath, model.sourceLine ?? 1, [{ path: "Paymaster.sponsorshipPolicy", description: "No sponsorship quota or budget guard was identified." }], [], "low")); +} + +function addFactoryDiagnostics(result: ERC4337AnalysisResult, filePath: string, source: string, limits: ERC4337AnalysisLimits): void { + if (/create2/i.test(source) && !/keccak256|salt/i.test(source)) addDiagnostic(result, limits, diagnostic("AA011_CREATE2_DERIVATION", "factory", result.version, "CREATE2 account derivation is incomplete", "The factory uses CREATE2 without a recognizable salt or init-code hash derivation path.", "Derive the counterfactual address from deployer, salt, and init-code hash, and verify the deployed account before execution.", "high", filePath, lineOf(source, /create2/i) ?? 1, [{ path: "Factory.getAddress.create2", description: "CREATE2 appears without explicit salt and init-code hashing." }], [], "medium")); + if (/initialize|initcode|factorydata/i.test(source) && !/onlyfactory|msg\.sender|initialized|initializer/i.test(source)) addDiagnostic(result, limits, diagnostic("AA010_FACTORY_INIT", "factory", result.version, "Counterfactual account initialization lacks visible authorization", "Initialization data or an initializer is exposed without a recognizable one-time or trusted-factory guard.", "Bind initialization to the expected factory or deploy transaction, consume it once, and reject re-initialization.", "high", filePath, lineOf(source, /initialize|initcode|factorydata/i) ?? 1, [{ path: "Factory.initialize", description: "Initialization surface lacks a visible authorization or one-time guard." }], [], "medium")); +} + +function addAggregatorDiagnostics(result: ERC4337AnalysisResult, filePath: string, source: string, limits: ERC4337AnalysisLimits): void { + if (/aggregatedsignature/i.test(source) && !/userop|useroperation/i.test(source)) addDiagnostic(result, limits, diagnostic("AA005_AGGREGATOR_SIGNATURE", "aggregator", result.version, "Aggregated signature is not visibly bound to operations", "An aggregated signature is processed without a recognizable UserOperation association.", "Validate the aggregate against the exact ordered operation set and reject missing, duplicate, or reordered operations.", "high", filePath, lineOf(source, /aggregatedsignature/i) ?? 1, [{ path: "Aggregator.validateSignatures.userOps", description: "Aggregate validation does not visibly consume UserOperations." }], [], "low")); +} + +function addModuleAndLifecycleDiagnostics(result: ERC4337AnalysisResult, filePath: string, source: string, limits: ERC4337AnalysisLimits): void { + const lowerSource = source.toLowerCase(); + if (/session/.test(lowerSource) && !/expiry|validuntil|nonce|revoke|disable/i.test(source)) addDiagnostic(result, limits, diagnostic("AA013_SESSION_KEY", "module", result.version, "Session key has no visible expiry or revocation", "A session-key surface is present without recognizable temporal, nonce, or revocation constraints.", "Bind session keys to a narrow scope, expiry, nonce domain, and explicit revocation path.", "high", filePath, lineOf(source, /session/i) ?? 1, [{ path: "SessionModule.authorize", description: "No expiry, nonce, or revocation control was found." }], [], "low")); + if (/module/.test(lowerSource) && !/onlyowner|authorized|isolation|allowlist|whitelist|msg\.sender/i.test(source)) addDiagnostic(result, limits, diagnostic("AA012_MODULE_AUTH", "module", result.version, "Module authorization is not visible", "A module or plugin execution surface is present without a recognizable authorization boundary.", "Use an explicit trusted module registry and authenticate module installation, removal, and execution.", "high", filePath, lineOf(source, /module/i) ?? 1, [{ path: "Account.module", description: "Module capability exists without a visible authorization guard." }], [], "low")); + if (/upgrade/.test(lowerSource) && !/onlyowner|authorized|timelock|accesscontrol|msg\.sender/i.test(source)) addDiagnostic(result, limits, diagnostic("AA014_UPGRADE_AUTH", "account", result.version, "Account upgrade path lacks visible authorization", "An upgrade surface is present without a recognizable owner, role, or timelock check.", "Authorize upgrades with an explicit role or timelock and protect the implementation and initialization state.", "critical", filePath, lineOf(source, /upgrade/i) ?? 1, [{ path: "Account.upgrade", description: "Upgrade function lacks a recognizable authorization boundary." }], [], "low")); + if (/fallback/.test(lowerSource) && !/msg\.sender|selector|allowlist|authorized/i.test(source)) addDiagnostic(result, limits, diagnostic("AA015_FALLBACK_AUTH", "fallback", result.version, "Fallback dispatch lacks visible authorization", "A fallback or selector dispatch surface is present without recognizable selector or caller restrictions.", "Allowlist selectors, authenticate module calls, and preserve value and calldata isolation in fallback dispatch.", "high", filePath, lineOf(source, /fallback/i) ?? 1, [{ path: "Account.fallback", description: "Fallback dispatch lacks a visible authorization boundary." }], [], "low")); +} + +function addDiagnostic(result: ERC4337AnalysisResult, limits: ERC4337AnalysisLimits, value: ERC4337Diagnostic): void { + if (result.diagnostics.length < limits.maxDiagnostics) result.diagnostics.push({ ...value, evidence: value.evidence.slice(0, limits.maxEvidenceItems) }); + else result.truncated = true; +} + +function diagnostic(code: ERC4337DiagnosticCode, component: ERC4337Component, version: ERC4337Version, title: string, description: string, recommendation: string, severity: ERC4337Diagnostic["severity"], file: string, line: number, evidence: ERC4337Evidence[], assumptions: string[], confidence: ERC4337Diagnostic["confidence"]): ERC4337Diagnostic { + return { code, component, version, title, description, recommendation, severity, file, line: Math.max(1, line), evidence, assumptions, confidence }; +} + +function toFindings(result: ERC4337AnalysisResult, source: string): Finding[] { + return result.diagnostics.map((diagnostic) => ({ + id: `CP-4337-${diagnostic.code.slice(6)}`, + title: diagnostic.title, + description: diagnostic.description, + recommendation: diagnostic.recommendation, + severity: diagnostic.severity, + file: diagnostic.file, + line: diagnostic.line, + snippet: source.split("\n")[diagnostic.line - 1]?.trim(), + evidence: diagnostic.evidence.map((item) => ({ description: `${item.path}: ${item.description}`, file: diagnostic.file, line: item.line ?? diagnostic.line })), + assumptions: diagnostic.assumptions, + confidence: diagnostic.confidence, + })); +} + +function functionNames(source: string): string[] { return collectFunctions(source, DEFAULT_ERC4337_ANALYSIS_LIMITS.maxFunctions).names; } +function hashBindsField(source: string, field: string): boolean { return [...source.matchAll(HASH_CALL_PATTERN)].some((match) => match[1].toLowerCase().includes(field.toLowerCase())); } +function lineOf(source: string, pattern: RegExp): number | undefined { const index = source.search(pattern); return index < 0 ? undefined : source.slice(0, index).split("\n").length; } diff --git a/packages/core/src/erc4337/types.ts b/packages/core/src/erc4337/types.ts new file mode 100644 index 0000000..cdb8c66 --- /dev/null +++ b/packages/core/src/erc4337/types.ts @@ -0,0 +1,140 @@ +export type ERC4337Version = "0.6" | "0.7" | "0.8"; + +export type ERC4337Component = + | "account" + | "entryPoint" + | "factory" + | "aggregator" + | "paymaster" + | "module" + | "fallback"; + +export type ERC4337DiagnosticCode = + | "AA001_HASH_BINDING" + | "AA002_ENTRYPOINT_DOMAIN" + | "AA003_NONCE_REPLAY" + | "AA004_VALIDATION_EXECUTION" + | "AA005_AGGREGATOR_SIGNATURE" + | "AA006_PAYMASTER_LIMIT" + | "AA007_PAYMASTER_DEPOSIT" + | "AA008_PAYMASTER_POSTOP" + | "AA009_PAYMASTER_CONTEXT" + | "AA010_FACTORY_INIT" + | "AA011_CREATE2_DERIVATION" + | "AA012_MODULE_AUTH" + | "AA013_SESSION_KEY" + | "AA014_UPGRADE_AUTH" + | "AA015_FALLBACK_AUTH"; + +export interface UserOperationField { + name: string; + type: string; + required: boolean; + hashBound: boolean; + sourceLine?: number; +} + +export interface UserOperationModel { + version: ERC4337Version; + fields: UserOperationField[]; + hasPaymaster: boolean; + hasFactory: boolean; + hasAggregator: boolean; + hashFunctions: string[]; + nonceFunctions: string[]; +} + +export interface ValidationDataModel { + hasAuthorizer: boolean; + hasTimeRange: boolean; + usesPackedEncoding: boolean; + rejectsInvalidAuthorizer: boolean; + sourceLine?: number; +} + +export interface NonceModel { + hasNonceStorage: boolean; + usesKeyedNonce: boolean; + incrementsBeforeExecution: boolean; + validatesNonce: boolean; + sourceLine?: number; +} + +export interface EntryPointModel { + version: ERC4337Version; + functions: string[]; + bindsChainId: boolean; + bindsEntryPoint: boolean; + validatesSender: boolean; + hasDepositAccounting: boolean; + sourceLine?: number; +} + +export interface PaymasterModel { + hasValidation: boolean; + hasPostOp: boolean; + validatesGasLimits: boolean; + validatesContext: boolean; + tracksDeposit: boolean; + hasSponsorshipLimit: boolean; + externalContextCalls: boolean; + sourceLine?: number; +} + +export interface ERC4337AnalysisLimits { + maxSourceLength: number; + maxFunctions: number; + maxDiagnostics: number; + maxEvidenceItems: number; +} + +export interface ERC4337AnalysisOptions { + version?: ERC4337Version | "auto"; + limits?: Partial; + signal?: AbortSignal; +} + +export interface ERC4337Evidence { + path: string; + description: string; + line?: number; +} + +export interface ERC4337Diagnostic { + code: ERC4337DiagnosticCode; + component: ERC4337Component; + version: ERC4337Version; + title: string; + description: string; + recommendation: string; + severity: "critical" | "high" | "medium" | "low" | "info"; + file: string; + line: number; + lineEnd?: number; + evidence: ERC4337Evidence[]; + assumptions: string[]; + confidence: "high" | "medium" | "low"; +} + +export interface ERC4337AnalysisResult { + schemaVersion: "erc4337-analysis-1"; + protocol: "erc-4337"; + version: ERC4337Version; + components: ERC4337Component[]; + userOperation?: UserOperationModel; + validationData?: ValidationDataModel; + nonce?: NonceModel; + entryPoint?: EntryPointModel; + paymaster?: PaymasterModel; + diagnostics: ERC4337Diagnostic[]; + truncated: boolean; +} + +export const DEFAULT_ERC4337_ANALYSIS_LIMITS: ERC4337AnalysisLimits = { + maxSourceLength: 2_000_000, + maxFunctions: 2_000, + maxDiagnostics: 100, + maxEvidenceItems: 8, +}; + +export const ERC4337_VERSION_ORDER: readonly ERC4337Version[] = ["0.6", "0.7", "0.8"]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 00c4e67..2d91129 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,11 +56,35 @@ export { } from "./report/generator"; export { diffScans, computeFingerprint } from "./diff"; export { isSlitherAvailable } from "./ast/slither"; +export { analyzeERC4337, detectERC4337 } from "./erc4337/analyzer"; +export { + getERC4337Adapter, + listERC4337Adapters, + detectERC4337Version, + adapterSupportsFunction, + canonicalUserOperationFields, +} from "./erc4337/adapters"; +export type { ERC4337Adapter } from "./erc4337/adapters"; +export type { + ERC4337Version, + ERC4337Component, + ERC4337DiagnosticCode, + ERC4337AnalysisOptions, + ERC4337AnalysisLimits, + ERC4337AnalysisResult, + ERC4337Diagnostic, + UserOperationModel, + ValidationDataModel, + NonceModel, + EntryPointModel, + PaymasterModel, +} from "./erc4337/types"; export { loadPlugin, loadPlugins } from "./plugins"; export { loadConfigFile, mergePluginsFromConfig, mergeSlitherConfigFromConfig, + mergeERC4337ConfigFromConfig, } from "./config"; export type { ChainProofConfig } from "./config"; diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index 18c487c..0ad2aee 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -28,11 +28,7 @@ import { } from "./rules/erc-compliance"; import { detectVaultInflation } from "./rules/cp122-vault-inflation"; import { detectCallbackReentrancy } from "./rules/callback-analysis"; -import { detectCrossContractReentrancy } from "./rules/cp121-cross-contract-reentrancy"; -import { detectStakingAccounting } from "./staking"; -import { detectGovernanceSafety } from "./governance"; -import { detectBridgeSafety } from "./bridge"; -import { detectDosVulnerabilities } from "./dos"; +import { detectERC4337 } from "./erc4337/analyzer"; import { RuleOptions } from "./rules/rule-context"; import { detectGasIssues } from "./rules/gas-optimizer"; import { enhanceFindingsWithLLM } from "./llm/enhancer"; @@ -106,13 +102,15 @@ function runRulesOnView( ...runERCChecks(view.node, view.source, view.file, ruleOptions), ...detectVaultInflation(view.node, view.source, view.file, ruleOptions), ...detectCallbackReentrancy(view.node, view.source, view.file, ruleOptions), + ...detectERC4337(view.node, view.source, view.file, config.erc4337), ]; } function runRulesOnFile( ast: NonNullable["ast"]>, source: string, - filePath: string + filePath: string, + config: ScanConfig, ): Finding[] { return [ ...detectReentrancy(ast, source, filePath), @@ -123,6 +121,7 @@ function runRulesOnFile( ...detectUncheckedReturn(ast, source, filePath), ...runERCChecks(ast, source, filePath), ...detectVaultInflation(ast, source, filePath), + ...detectERC4337(ast, source, filePath, config.erc4337), ]; } @@ -176,7 +175,7 @@ async function scanFile( ...detectIntegerOverflow(ast, source, filePath), ...detectUncheckedReturn(ast, source, filePath), ] - : runRulesOnFile(ast, source, filePath); + : runRulesOnFile(ast, source, filePath, config); // Staking accounting is intentionally evaluated once per physical source // file. Its model already separates contracts, so running it per merged diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0a10bd0..fdb7f01 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -8,6 +8,8 @@ */ export type Severity = "critical" | "high" | "medium" | "low" | "info" | "gas"; +import type { ERC4337AnalysisOptions } from "./erc4337/types"; + // ─── A single detected issue ────────────────────────────────────────────────── /** @@ -302,6 +304,8 @@ export interface ScanConfig { /** Slither detector allowlist/denylist. No effect unless `useSlither` is `true`. */ slither?: SlitherConfig; + /** Version-aware ERC-4337 account-abstraction analysis options. */ + erc4337?: ERC4337AnalysisOptions; } // ─── Scan Diff Result ──────────────────────────────────────────────────────── diff --git a/packages/github-action/action.yml b/packages/github-action/action.yml index ce26264..44e5b6e 100644 --- a/packages/github-action/action.yml +++ b/packages/github-action/action.yml @@ -47,6 +47,16 @@ inputs: required: false default: "" + erc4337-version: + description: "ERC-4337 adapter version: auto|0.6|0.7|0.8" + required: false + default: "auto" + + erc4337-max-diagnostics: + description: "Maximum ERC-4337 diagnostics per file" + required: false + default: "100" + outputs: critical-count: description: "Number of critical severity findings" diff --git a/packages/github-action/src/action.ts b/packages/github-action/src/action.ts index ff3801b..b41ed54 100644 --- a/packages/github-action/src/action.ts +++ b/packages/github-action/src/action.ts @@ -12,7 +12,7 @@ import { clearCache, isSlitherAvailable, } from "@chainproof/core"; -import type { ScanConfig, ScanResult, ScanDiff } from "@chainproof/core"; +import type { ScanConfig, ScanResult, ScanDiff, ERC4337Version } from "@chainproof/core"; // ─── Build PR comment (Standard) ────────────────────────────────────────────── @@ -204,6 +204,8 @@ async function run() { const uploadReport = core.getInput("upload-report") === "true"; const failOnGas = core.getInput("fail-on-gas") === "true"; const diffRefInput = core.getInput("diff-ref"); + const erc4337Version = core.getInput("erc4337-version") || "auto"; + const erc4337MaxDiagnostics = Number(core.getInput("erc4337-max-diagnostics") || "100"); core.info(`[ChainProof] Scanning: ${targets.join(", ")}`); core.info(`[ChainProof] Min severity: ${minSeverity}`); @@ -218,6 +220,10 @@ async function run() { useMetrics, apiKey, minSeverity, + erc4337: { + version: erc4337Version as ERC4337Version | "auto", + limits: { maxDiagnostics: erc4337MaxDiagnostics }, + }, }; const result = await scan(config); diff --git a/packages/server/openapi.yaml b/packages/server/openapi.yaml index 7eaed84..155867c 100644 --- a/packages/server/openapi.yaml +++ b/packages/server/openapi.yaml @@ -72,6 +72,25 @@ components: type: string llmEnhanced: type: boolean + evidence: + type: array + items: + type: object + required: [description] + properties: + description: + type: string + file: + type: string + line: + type: integer + assumptions: + type: array + items: + type: string + confidence: + type: string + enum: [high, medium, low] # ── GasHint ─────────────────────────────────────────────────────────────── GasHint: @@ -168,6 +187,23 @@ components: llmModel: type: string example: claude-3-5-sonnet-20241022 + erc4337: + $ref: "#/components/schemas/ERC4337AnalysisOptions" + + ERC4337AnalysisOptions: + type: object + properties: + version: + type: string + enum: [auto, "0.6", "0.7", "0.8"] + default: auto + limits: + type: object + properties: + maxSourceLength: { type: integer, minimum: 1000, maximum: 2000000 } + maxFunctions: { type: integer, minimum: 1, maximum: 10000 } + maxDiagnostics: { type: integer, minimum: 1, maximum: 1000 } + maxEvidenceItems: { type: integer, minimum: 1, maximum: 32 } # ── RuleMeta ────────────────────────────────────────────────────────────── RuleMeta: diff --git a/packages/server/src/routes/scan.ts b/packages/server/src/routes/scan.ts index 411d23f..293ce69 100644 --- a/packages/server/src/routes/scan.ts +++ b/packages/server/src/routes/scan.ts @@ -3,7 +3,7 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { scan } from "@chainproof/core"; -import type { ScanConfig, Severity } from "@chainproof/core"; +import type { ScanConfig, Severity, ERC4337AnalysisOptions } from "@chainproof/core"; const router = Router(); @@ -24,6 +24,7 @@ interface ScanRequestBody { apiKey?: string; llmProvider?: string; llmModel?: string; + erc4337?: ERC4337AnalysisOptions; }; } @@ -88,6 +89,7 @@ router.post("/", async (req: Request, res: Response): Promise => { apiKey: cfg.apiKey ?? process.env.ANTHROPIC_API_KEY, llmProvider: cfg.llmProvider, llmModel: cfg.llmModel, + erc4337: cfg.erc4337, }; const result = await scan(config); @@ -171,6 +173,7 @@ router.post("/file", async (req: Request, res: Response): Promise => { apiKey: cfg.apiKey ?? process.env.ANTHROPIC_API_KEY, llmProvider: cfg.llmProvider, llmModel: cfg.llmModel, + erc4337: cfg.erc4337, }; diff --git a/packages/server/src/rules-registry.ts b/packages/server/src/rules-registry.ts index b94665c..1b2826b 100644 --- a/packages/server/src/rules-registry.ts +++ b/packages/server/src/rules-registry.ts @@ -15,6 +15,76 @@ export interface RuleMeta { * the same metadata without importing AST-heavy rule modules. */ export const RULES: RuleMeta[] = [ + { + id: "CP-4337-AA001_HASH_BINDING", + title: "Incomplete ERC-4337 UserOperation hash binding", + severity: "high", + category: "security", + description: "Detects security-critical UserOperation fields that are not visibly included in the signed operation hash.", + }, + { + id: "CP-4337-AA002_ENTRYPOINT_DOMAIN", + title: "ERC-4337 replay domain weakness", + severity: "high", + category: "security", + description: "Detects missing chain-ID or canonical EntryPoint binding in account-abstraction operation domains.", + }, + { + id: "CP-4337-AA003_NONCE_REPLAY", + title: "ERC-4337 nonce replay risk", + severity: "high", + category: "security", + description: "Detects UserOperation execution paths without recognizable nonce validation or consumption.", + }, + { + id: "CP-4337-AA005_AGGREGATOR_SIGNATURE", + title: "Unsafe ERC-4337 signature aggregation", + severity: "high", + category: "security", + description: "Detects aggregate signature validation that is not visibly associated with the exact operation set.", + }, + { + id: "CP-4337-AA006_PAYMASTER_LIMIT", + title: "Unbounded ERC-4337 paymaster sponsorship", + severity: "high", + category: "security", + description: "Detects paymaster sponsorship paths without recognizable gas or budget limits.", + }, + { + id: "CP-4337-AA007_PAYMASTER_DEPOSIT", + title: "Unsafe ERC-4337 deposit accounting", + severity: "high", + category: "security", + description: "Detects paymaster or EntryPoint deposit accounting without clear authorization or cost settlement.", + }, + { + id: "CP-4337-AA008_PAYMASTER_POSTOP", + title: "Unsafe ERC-4337 paymaster postOp", + severity: "high", + category: "security", + description: "Detects context-sensitive external work in paymaster post-operation handling.", + }, + { + id: "CP-4337-AA010_FACTORY_INIT", + title: "Unsafe counterfactual account initialization", + severity: "high", + category: "security", + description: "Detects factory initialization surfaces without recognizable one-time or trusted-caller authorization.", + }, + { + id: "CP-4337-AA012_MODULE_AUTH", + title: "Unauthorized smart-account module surface", + severity: "high", + category: "security", + description: "Detects module capabilities without a recognizable authorization boundary.", + }, + { + id: "CP-4337-AA013_SESSION_KEY", + title: "Unbounded smart-account session key", + severity: "high", + category: "security", + description: "Detects session-key surfaces without recognizable expiry, nonce, or revocation controls.", + }, { id: "CP-107", swcId: "SWC-107", diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index b049e52..47dc89c 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -82,6 +82,19 @@ "enum": ["critical", "high", "medium", "low", "info"], "default": "low", "description": "Minimum severity level to display" + }, + "chainproof.erc4337Version": { + "type": "string", + "enum": ["auto", "0.6", "0.7", "0.8"], + "default": "auto", + "description": "ERC-4337 EntryPoint adapter version" + }, + "chainproof.erc4337MaxDiagnostics": { + "type": "number", + "minimum": 1, + "maximum": 1000, + "default": 100, + "description": "Maximum ERC-4337 diagnostics per file" } } }, diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 42138f7..c70444c 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -222,6 +222,10 @@ async function scanDocument(document: vscode.TextDocument) { useMetrics: config.get("useMetrics") ?? true, apiKey, minSeverity: config.get("minSeverity") ?? "low", + erc4337: { + version: config.get("erc4337Version") ?? "auto", + limits: { maxDiagnostics: config.get("erc4337MaxDiagnostics") ?? 100 }, + }, plugins, };