From bb2be3e940650ec9a5f5884daaa940c5f77dcfe0 Mon Sep 17 00:00:00 2001 From: Tunmisedasa Date: Sat, 29 Aug 2026 14:47:36 +0100 Subject: [PATCH] feat: add external call return-value and returndata safety analysis Implements a production returndata safety engine with 16 rules (CP-RTD-001 through CP-RTD-016), call classification, decode analysis, guard detection, Slither merge support, and scanner/CLI integration. Co-authored-by: Cursor --- docs/returndata-safety.md | 74 +++ .../contracts/returndata/SecureReturndata.sol | 61 +++ .../returndata/VulnerableReturndata.sol | 47 ++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/returndata.ts | 186 ++++++++ packages/core/src/index.ts | 3 + .../src/returndata/__tests__/analyzer.test.ts | 53 +++ packages/core/src/returndata/adapters.ts | 66 +++ packages/core/src/returndata/analyzer.ts | 448 ++++++++++++++++++ packages/core/src/returndata/api.ts | 266 +++++++++++ .../core/src/returndata/call-classifier.ts | 44 ++ packages/core/src/returndata/config.ts | 216 +++++++++ .../core/src/returndata/decode-analysis.ts | 50 ++ .../core/src/returndata/guard-detection.ts | 68 +++ packages/core/src/returndata/index.ts | 62 +++ packages/core/src/returndata/model.ts | 379 +++++++++++++++ packages/core/src/returndata/rule.ts | 23 + packages/core/src/returndata/serialize.ts | 80 ++++ packages/core/src/returndata/slither-merge.ts | 77 +++ packages/core/src/returndata/types.ts | 194 ++++++++ packages/core/src/scanner.ts | 4 + packages/server/src/rules-registry.ts | 9 + 22 files changed, 2412 insertions(+) create mode 100644 docs/returndata-safety.md create mode 100644 examples/contracts/returndata/SecureReturndata.sol create mode 100644 examples/contracts/returndata/VulnerableReturndata.sol create mode 100644 packages/cli/src/commands/returndata.ts create mode 100644 packages/core/src/returndata/__tests__/analyzer.test.ts create mode 100644 packages/core/src/returndata/adapters.ts create mode 100644 packages/core/src/returndata/analyzer.ts create mode 100644 packages/core/src/returndata/api.ts create mode 100644 packages/core/src/returndata/call-classifier.ts create mode 100644 packages/core/src/returndata/config.ts create mode 100644 packages/core/src/returndata/decode-analysis.ts create mode 100644 packages/core/src/returndata/guard-detection.ts create mode 100644 packages/core/src/returndata/index.ts create mode 100644 packages/core/src/returndata/model.ts create mode 100644 packages/core/src/returndata/rule.ts create mode 100644 packages/core/src/returndata/serialize.ts create mode 100644 packages/core/src/returndata/slither-merge.ts create mode 100644 packages/core/src/returndata/types.ts diff --git a/docs/returndata-safety.md b/docs/returndata-safety.md new file mode 100644 index 0000000..4c4d59e --- /dev/null +++ b/docs/returndata-safety.md @@ -0,0 +1,74 @@ +# External Call Return-Value and Returndata Safety + +ChainProof's returndata analysis engine (`CP-RTD-001` through `CP-RTD-016`) detects ignored call success flags, unchecked token returns, unsafe ABI decoding, and stale returndata patterns. + +## Threat model + +The analyzer assumes: + +- Low-level calls (`.call`, `.send`, `.delegatecall`, `.staticcall`) can fail silently +- ERC20 tokens may be non-standard (no return value or false return) +- Returndata buffers are overwritten by subsequent calls +- Assembly returndata copies may read out of bounds + +## Rules + +| Rule | Category | Description | +|------|----------|-------------| +| CP-RTD-001 | ignored-return | Ignored call success flag | +| CP-RTD-002 | overwritten-return | Call result overwritten before check | +| CP-RTD-003 | token-return | Unchecked ERC20 transfer return | +| CP-RTD-004 | low-level-return | Unchecked low-level `.call()` return | +| CP-RTD-005 | decode-safety | Unsafe ABI decode without length check | +| CP-RTD-006 | stale-returndata | Stale returndata reuse across calls | +| CP-RTD-007 | batch-failure | Partial batch failure ignored | +| CP-RTD-008 | ignored-return | Ignored delegatecall return | +| CP-RTD-009 | ignored-return | Ignored staticcall return | +| CP-RTD-010 | ignored-return | Ignored send() return | +| CP-RTD-011 | transfer-safety | transfer() without return check | +| CP-RTD-012 | assembly-safety | Assembly returndata copy without bounds | +| CP-RTD-013 | try-catch | Try/catch swallows critical failure | +| CP-RTD-014 | multicall | Multicall partial failure not propagated | +| CP-RTD-015 | proxy-decode | Proxy delegatecall decode assumption | +| CP-RTD-016 | optional-call | Security-critical call marked optional | + +## Recognized mitigations + +- **SafeERC20**: `safeTransfer`, `safeTransferFrom`, `safeApprove` +- **Address utilities**: `functionCall`, `functionCallWithValue`, `sendValue` +- **Try/catch**: Wrapped external calls with explicit handling +- **Assembly bounds**: `returndatasize()` checks before `returndatacopy` + +## Usage + +### CLI + +```bash +chainproof returndata contracts/ --format json +chainproof returndata Token.sol --exclude-rule CP-RTD-011 +``` + +### API + +```typescript +import { analyzeReturndataSource, detectReturndataSafety } from '@chainproof/core'; + +const report = analyzeReturndataSource(source, 'Vault.sol'); +// Integrated into ordinary scan via detectReturndataSafety +``` + +## Slither merge + +When `mergeSlither: true` is set in configuration, equivalent Slither return-value findings are merged while preserving ChainProof evidence paths and stable rule identities. + +## Limitations + +- Cannot distinguish all intentionally optional calls without `@dev optional` documentation +- Assembly analysis is pattern-based, not symbolic +- Does not execute contracts or simulate returndata at runtime + +## Troubleshooting + +- **False positive on documented optional call**: Add `@dev optional` comment or use `--exclude-rule CP-RTD-016` +- **Missing detection**: Ensure source contains `.call(`, `.transfer(`, or `abi.decode` patterns +- **Secure fixture still flagged**: Verify SafeERC20/Address patterns appear in source text diff --git a/examples/contracts/returndata/SecureReturndata.sol b/examples/contracts/returndata/SecureReturndata.sol new file mode 100644 index 0000000..fbafda8 --- /dev/null +++ b/examples/contracts/returndata/SecureReturndata.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); +} + +library SafeERC20Mock { + function safeTransfer(IERC20 token, address to, uint256 amount) internal { + require(token.transfer(to, amount), "transfer failed"); + } +} + +library AddressMock { + function functionCall(address target, bytes memory data) internal returns (bytes memory) { + (bool success, bytes memory result) = target.call(data); + require(success, "call failed"); + return result; + } + + function sendValue(address payable to, uint256 amount) internal { + (bool success,) = to.call{value: amount}(""); + require(success, "send failed"); + } +} + +/// @notice Secure contract with checked returns and SafeERC20-style wrappers. +contract SecureReturndata { + using SafeERC20Mock for IERC20; + IERC20 public token; + + constructor(address _token) { + token = IERC20(_token); + } + + function pay(address to, uint256 amount) external { + token.safeTransfer(to, amount); + } + + function execute(address target, bytes calldata data) external { + AddressMock.functionCall(target, data); + } + + function sendEth(address payable to, uint256 amount) external { + AddressMock.sendValue(to, amount); + } + + function batchPay(address[] calldata recipients, uint256[] calldata amounts) external { + require(recipients.length == amounts.length, "length"); + for (uint256 i = 0; i < recipients.length; i++) { + token.safeTransfer(recipients[i], amounts[i]); + } + } + + /// @dev optional notification; failure is intentionally ignored + function tryNotifyOptional(address to) external { + try token.transfer(to, 1) returns (bool success) { + success; + } catch {} + } +} diff --git a/examples/contracts/returndata/VulnerableReturndata.sol b/examples/contracts/returndata/VulnerableReturndata.sol new file mode 100644 index 0000000..cca76ce --- /dev/null +++ b/examples/contracts/returndata/VulnerableReturndata.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); +} + +/// @notice Vulnerable contract with ignored call returns and unsafe token transfers. +contract VulnerableReturndata { + IERC20 public token; + address public target; + + constructor(address _token) { + token = IERC20(_token); + } + + function pay(address to, uint256 amount) external { + token.transfer(to, amount); + } + + function execute(bytes calldata data) external { + target.call(data); + } + + function sendEth(address payable to) external { + to.send(1 ether); + } + + function batchPay(address[] calldata recipients, uint256[] calldata amounts) external { + for (uint256 i = 0; i < recipients.length; i++) { + token.transfer(recipients[i], amounts[i]); + } + } + + function decodeResult(bytes memory data) external pure returns (uint256 value) { + value = abi.decode(data, (uint256)); + } + + function proxyCall(address impl, bytes calldata data) external { + impl.delegatecall(data); + } + + function tryNotify(address to) external { + try token.transfer(to, 1) {} catch {} + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f1d07dc..40f06ab 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -30,6 +30,7 @@ import { registerWatchCommand } from "./commands/watch"; import { registerInvariantsCommand } from "./commands/invariants"; import { registerStakingCommand } from "./commands/staking"; import { registerGovernanceCommand } from "./commands/governance"; +import { registerReturndataCommand } from "./commands/returndata"; // ─── ASCII Banner ───────────────────────────────────────────────────────────── @@ -631,5 +632,6 @@ registerWatchCommand(program, printBanner); registerInvariantsCommand(program, printBanner); registerStakingCommand(program); registerGovernanceCommand(program, printBanner); +registerReturndataCommand(program, printBanner); program.parse(); diff --git a/packages/cli/src/commands/returndata.ts b/packages/cli/src/commands/returndata.ts new file mode 100644 index 0000000..83cd7f2 --- /dev/null +++ b/packages/cli/src/commands/returndata.ts @@ -0,0 +1,186 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import * as fs from "fs"; +import { + analyzeReturndataFiles, + generateReturndataMarkdown, + ReturndataAnalysisCancelledError, + ReturndataConfigError, + loadReturndataConfigFile, + serializeReturndataReport, +} from "@chainproof/core"; +import type { + ReturndataAnalysisLimits, + ReturndataAnalysisOptions, + ReturndataAnalysisReport, + ReturndataRuleId, +} from "@chainproof/core"; + +type OutputFormat = "json" | "markdown"; +type FailSeverity = "none" | "info" | "low" | "medium" | "high" | "critical"; + +interface ReturndataCliOptions { + format: OutputFormat; + output?: string; + config?: string; + includeModels?: boolean; + includeRule: string[]; + excludeRule: string[]; + maxSourceBytes?: number; + maxFiles?: number; + maxContracts?: number; + maxFunctions?: number; + maxOperations?: number; + maxFindings?: number; + failOn: FailSeverity; +} + +const RULE_PATTERN = /^CP-RTD-(?:00[1-9]|01[0-6])$/; +const SEVERITY_RANK: Record = { + none: 99, + info: 1, + low: 2, + medium: 3, + high: 4, + critical: 5, +}; + +export function registerReturndataCommand(program: Command, printBanner: () => void): void { + program + .command("returndata ") + .description("Analyze cross-chain returndata and message verification safety") + .option("--format ", "Output format: json|markdown", "markdown") + .option("--output ", "Write the report to a file") + .option("--config ", "Load a versioned returndata analysis configuration") + .option("--include-models", "Include the normalized returndata model in JSON output") + .option("--include-rule ", "Only run a rule (repeatable)", collect, []) + .option("--exclude-rule ", "Skip a rule (repeatable)", collect, []) + .option("--max-source-bytes ", "Maximum bytes per Solidity source", positiveInteger) + .option("--max-files ", "Maximum number of Solidity files", positiveInteger) + .option("--max-contracts ", "Maximum contracts per Solidity file", positiveInteger) + .option("--max-functions ", "Maximum functions per file and contract", positiveInteger) + .option("--max-operations ", "Maximum modeled operations per function", positiveInteger) + .option("--max-findings ", "Maximum findings in the report", positiveInteger) + .option( + "--fail-on ", + "Exit 1 when this severity or higher is present: none|info|low|medium|high|critical", + "high", + ) + .action((targets: string[], raw: ReturndataCliOptions) => { + const json = raw.format === "json"; + if (!json && raw.format === "markdown") printBanner(); + try { + validateFormat(raw.format); + validateFailSeverity(raw.failOn); + const configured = raw.config ? loadReturndataConfigFile(raw.config) : undefined; + const includeRules = raw.includeRule.length + ? validateRules(raw.includeRule, "--include-rule") + : configured?.config.includeRules; + const excludeRules = raw.excludeRule.length + ? validateRules(raw.excludeRule, "--exclude-rule") + : configured?.config.excludeRules; + rejectOverlap(includeRules, excludeRules); + const limits: Partial = { + ...configured?.config.limits, + ...(raw.maxSourceBytes ? { maxSourceBytes: raw.maxSourceBytes } : {}), + ...(raw.maxFiles ? { maxFiles: raw.maxFiles } : {}), + ...(raw.maxContracts ? { maxContracts: raw.maxContracts } : {}), + ...(raw.maxFunctions ? { + maxFunctionsPerFile: raw.maxFunctions, + maxFunctionsPerContract: raw.maxFunctions, + } : {}), + ...(raw.maxOperations ? { maxOperationsPerFunction: raw.maxOperations } : {}), + ...(raw.maxFindings ? { maxFindings: raw.maxFindings } : {}), + }; + const options: ReturndataAnalysisOptions = { + limits, + includeModels: raw.includeModels ?? configured?.config.includeModels ?? false, + ...(includeRules ? { includeRules } : {}), + ...(excludeRules ? { excludeRules } : {}), + }; + const report = analyzeReturndataFiles(targets, options); + const output = raw.format === "json" + ? serializeReturndataReport(report) + : generateReturndataMarkdown(report); + if (raw.output) { + writeReport(raw.output, output); + if (!json) console.log(chalk.green(`\n Returndata report written to ${raw.output}`)); + } else { + process.stdout.write(output); + } + process.exit(exitCode(report, raw.failOn)); + } catch (error) { + const message = error instanceof ReturndataConfigError || + error instanceof ReturndataAnalysisCancelledError || error instanceof Error + ? error.message + : "Returndata analysis failed"; + console.error(chalk.red(`Returndata analysis error: ${sanitize(message)}`)); + process.exit(2); + } + }); +} + +function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +function positiveInteger(value: string): number { + if (!/^\d+$/.test(value)) throw new ReturndataConfigError("analysis limits must be positive integers"); + const result = Number(value); + if (!Number.isSafeInteger(result) || result <= 0) { + throw new ReturndataConfigError("analysis limits must be positive safe integers"); + } + return result; +} + +function validateRules(values: string[], option: string): ReturndataRuleId[] { + const result = new Set(); + for (const value of values) { + if (!RULE_PATTERN.test(value)) throw new ReturndataConfigError(`${option} contains unknown rule ${value}`); + result.add(value as ReturndataRuleId); + } + return [...result].sort(); +} + +function rejectOverlap(include: ReturndataRuleId[] | undefined, exclude: ReturndataRuleId[] | undefined): void { + if (!include || !exclude) return; + const overlap = include.filter((rule) => exclude.includes(rule)); + if (overlap.length) throw new ReturndataConfigError(`included and excluded rules overlap: ${overlap.join(", ")}`); +} + +function validateFormat(value: string): asserts value is OutputFormat { + if (value !== "json" && value !== "markdown") { + throw new ReturndataConfigError("--format must be json or markdown"); + } +} + +function validateFailSeverity(value: string): asserts value is FailSeverity { + if (!(value in SEVERITY_RANK)) { + throw new ReturndataConfigError("--fail-on must be none, info, low, medium, high, or critical"); + } +} + +function exitCode(report: ReturndataAnalysisReport, threshold: FailSeverity): number { + const rank = SEVERITY_RANK[threshold]; + return report.files.some((file) => file.findings.some((finding) => + severityRank(finding.severity) >= rank, + )) ? 1 : 0; +} + +function severityRank(severity: string): number { + return severity in SEVERITY_RANK ? SEVERITY_RANK[severity as FailSeverity] : 0; +} + +function sanitize(message: string): string { + return message.replace(/[\r\n]+/g, " ").slice(0, 500); +} + +function writeReport(file: string, output: string): void { + try { + fs.writeFileSync(file, output, "utf8"); + } catch (error) { + const code = (error as { code?: unknown } | null)?.code; + const safeCode = typeof code === "string" && /^[A-Z0-9_]+$/.test(code) ? code : "IO_ERROR"; + throw new ReturndataConfigError(`report file could not be written (${safeCode})`); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d6bb39..3934308 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -212,6 +212,9 @@ export type { // ─── Governance / timelock safety analysis ────────────────────────────────── export * from "./governance"; + +// ─── External call return-value and returndata safety analysis ─────────────── +export * from "./returndata"; export type { ParseSpecResult, MigrationResult, diff --git a/packages/core/src/returndata/__tests__/analyzer.test.ts b/packages/core/src/returndata/__tests__/analyzer.test.ts new file mode 100644 index 0000000..29f1166 --- /dev/null +++ b/packages/core/src/returndata/__tests__/analyzer.test.ts @@ -0,0 +1,53 @@ +import * as fs from "fs"; +import * as path from "path"; +import { analyzeReturndataFiles, analyzeReturndataSource } from "../api"; +import type { ReturndataRuleId } from "../types"; + +const FIXTURES = path.resolve(__dirname, "../../../../../examples/contracts/returndata"); + +function fixture(name: string) { + return analyzeReturndataFiles([path.join(FIXTURES, `${name}.sol`)], { includeModels: true }); +} + +function rules(name: string): ReturndataRuleId[] { + return fixture(name).files.flatMap((file) => file.findings.map((finding) => finding.ruleId)); +} + +describe("returndata safety analyzer", () => { + it("detects ignored returns, unchecked token transfers, and unsafe decode", () => { + const ids = new Set(rules("VulnerableReturndata")); + expect(ids.size).toBeGreaterThan(0); + for (const expected of [ + "CP-RTD-003", "CP-RTD-004", "CP-RTD-005", "CP-RTD-007", + ] satisfies ReturndataRuleId[]) { + expect(ids).toContain(expected); + } + }); + + it("recognizes SafeERC20 and Address utility patterns", () => { + const report = fixture("SecureReturndata"); + expect(report.files[0].findings).toEqual([]); + expect(report.files[0].models?.[0].adapter).toBe("safe-erc20-wrapper"); + }); + + it("attaches evidence, assumptions, and precise locations", () => { + const finding = fixture("VulnerableReturndata").files[0].findings[0]; + expect(finding.evidence.length).toBeGreaterThan(0); + expect(finding.location.line).toBeGreaterThan(0); + }); + + it("supports deterministic include/exclude selection", () => { + const file = path.join(FIXTURES, "VulnerableReturndata.sol"); + const source = fs.readFileSync(file, "utf8"); + const filtered = analyzeReturndataSource(source, file, { includeRules: ["CP-RTD-003"] }).files[0].findings; + expect(filtered.every((f) => f.ruleId === "CP-RTD-003")).toBe(true); + }); + + it("returns empty findings for unrelated contracts", () => { + const report = analyzeReturndataSource( + "pragma solidity ^0.8.20; contract X { uint256 public y; }", + "X.sol", + ); + expect(report.files[0].findings).toEqual([]); + }); +}); diff --git a/packages/core/src/returndata/adapters.ts b/packages/core/src/returndata/adapters.ts new file mode 100644 index 0000000..6694a95 --- /dev/null +++ b/packages/core/src/returndata/adapters.ts @@ -0,0 +1,66 @@ +import type { ReturndataContractModel, ReturndataFrameworkAdapterDefinition, ReturndataFrameworkMatch } from "./types"; + +export const RETURNDATA_FRAMEWORK_ADAPTERS: readonly ReturndataFrameworkAdapterDefinition[] = Object.freeze([ + { + id: "safe-erc20-wrapper", + displayName: "SafeERC20 return-value wrapper", + requiredPatterns: ["SafeERC20", "safeTransfer"], + mitigations: ["Token transfers use SafeERC20 which checks return values"], + limitations: ["Does not cover non-ERC20 token interfaces"], + }, + { + id: "address-utilities", + displayName: "OpenZeppelin Address functionCall utilities", + requiredPatterns: ["Address.functionCall", "functionCallWithValue"], + mitigations: ["Low-level calls bubble failures through Address utilities"], + limitations: ["Assembly paths bypassing Address utilities are not covered"], + }, + { + id: "assembly-wrapper", + displayName: "Assembly returndata copy with bounds", + requiredPatterns: ["returndatasize", "returndatacopy"], + mitigations: ["Assembly checks returndatasize before copy"], + limitations: ["Manual assembly correctness is not formally verified"], + }, + { + id: "multicall-batch", + displayName: "Multicall batch with failure propagation", + requiredPatterns: ["multicall", "require(success"], + mitigations: ["Batch calls check individual success flags"], + limitations: ["Partial failure policies vary by implementation"], + }, + { + id: "try-catch-guarded", + displayName: "Try/catch guarded external calls", + requiredPatterns: ["try ", "catch"], + mitigations: ["External call failures are caught and handled"], + limitations: ["Empty catch blocks may still swallow critical failures"], + }, +]); + +export function matchReturndataFramework( + model: Pick, +): ReturndataFrameworkMatch { + const source = model.transitions.map((t) => t.source).join("\n"); + for (const adapter of RETURNDATA_FRAMEWORK_ADAPTERS) { + const matched = adapter.requiredPatterns.filter((pattern) => source.includes(pattern)); + if (matched.length === adapter.requiredPatterns.length) { + return { adapter: adapter.id, matchedPatterns: matched.sort() }; + } + } + if (/SafeERC20|safeTransfer/i.test(source)) { + return { adapter: "safe-erc20-wrapper", matchedPatterns: ["SafeERC20"] }; + } + if (model.transitions.some((t) => t.role === "external-call")) { + return { adapter: "generic-external-call", matchedPatterns: [] }; + } + return { adapter: "none", matchedPatterns: [] }; +} + +export function getReturndataFrameworkAdapter( + id: ReturndataFrameworkAdapterDefinition["id"], +): ReturndataFrameworkAdapterDefinition { + const adapter = RETURNDATA_FRAMEWORK_ADAPTERS.find((c) => c.id === id); + if (!adapter) throw new Error(`Unknown returndata framework adapter: ${id}`); + return adapter; +} diff --git a/packages/core/src/returndata/analyzer.ts b/packages/core/src/returndata/analyzer.ts new file mode 100644 index 0000000..d2a95fa --- /dev/null +++ b/packages/core/src/returndata/analyzer.ts @@ -0,0 +1,448 @@ +import { REQUIRES_SUCCESS_CHECK, TOKEN_RETURN_CALLS } from "./call-classifier"; +import { analyzeDecodeSites, hasStaleReturndataPattern } from "./decode-analysis"; +import { detectGuards, hasSafeWrapper, isOptionalCall } from "./guard-detection"; +import type { + ReturndataContractModel, + ReturndataEvidence, + ReturndataFinding, + ReturndataOperation, + ReturndataRuleId, + ReturndataTransition, +} from "./types"; + +type Rule = (model: ReturndataContractModel) => ReturndataFinding[]; + +const RULE_ORDER: readonly ReturndataRuleId[] = Array.from({ length: 16 }, (_, i) => + `CP-RTD-${String(i + 1).padStart(3, "0")}` as ReturndataRuleId, +); + +const RULES: Record = { + "CP-RTD-001": detectIgnoredSuccessFlag, + "CP-RTD-002": detectOverwrittenResult, + "CP-RTD-003": detectUncheckedTokenReturn, + "CP-RTD-004": detectUncheckedLowLevelReturn, + "CP-RTD-005": detectUnsafeAbiDecode, + "CP-RTD-006": detectStaleReturndata, + "CP-RTD-007": detectPartialBatchFailure, + "CP-RTD-008": detectIgnoredDelegatecallReturn, + "CP-RTD-009": detectIgnoredStaticcallReturn, + "CP-RTD-010": detectIgnoredSendReturn, + "CP-RTD-011": detectUnsafeTransfer, + "CP-RTD-012": detectUnsafeAssemblyCopy, + "CP-RTD-013": detectSwallowedTryCatch, + "CP-RTD-014": detectMulticallPartialFailure, + "CP-RTD-015": detectProxyDecodeAssumption, + "CP-RTD-016": detectMisclassifiedOptionalCall, +}; + +export function analyzeReturndataModel( + model: ReturndataContractModel, + options: { includeRules?: ReturndataRuleId[]; excludeRules?: ReturndataRuleId[] } = {}, +): ReturndataFinding[] { + const include = options.includeRules ? new Set(options.includeRules) : null; + const exclude = new Set(options.excludeRules ?? []); + const findings: ReturndataFinding[] = []; + for (const id of RULE_ORDER) { + if (include && !include.has(id)) continue; + if (exclude.has(id)) continue; + findings.push(...RULES[id](model)); + } + return findings.sort(compareFindings); +} + +function detectIgnoredSuccessFlag(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + for (const op of transition.operations) { + if (op.kind !== "call" || !REQUIRES_SUCCESS_CHECK.has(op.callKind)) continue; + if (hasSafeWrapper(transition, op) || successCheckedAfter(transition, op)) continue; + if (isOptionalCall(transition, op)) continue; + if (isBareStatement(transition, op)) { + findings.push(makeFinding({ + ruleId: "CP-RTD-001", + title: `Ignored ${op.callKind} success flag`, + description: `A ${op.callKind} call's success boolean is not captured or checked. Failures are silently ignored.`, + recommendation: "Capture the return value and require(success) or use Address.functionCall.", + severity: "high", + confidence: "high", + category: "ignored-return", + model, transition, operation: op, + })); + } + } + } + return findings; +} + +function detectOverwrittenResult(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + const source = codeText(transition.source); + if (!/(bool\s+success|\(bool\s+success).*=.*\.call/s.test(source)) continue; + if (/require\s*\(\s*success|if\s*\(\s*!success/i.test(source)) continue; + const call = transition.operations.find((op) => op.callKind === "call"); + if (!call) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-002", + title: `Call result overwritten before check in ${transition.name}`, + description: "The success flag from a low-level call is assigned but overwritten or never checked before subsequent state changes.", + recommendation: "Check the success flag immediately after assignment before any other operations.", + severity: "high", + confidence: "medium", + category: "overwritten-return", + model, transition, operation: call, + })); + } + return findings; +} + +function detectUncheckedTokenReturn(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + for (const op of transition.operations) { + if (!TOKEN_RETURN_CALLS.has(op.callKind)) continue; + if (/transfer\s*\(/i.test(op.expression) && !/\.transfer\s*\(/i.test(op.expression)) continue; + if (hasSafeWrapper(transition, op)) continue; + if (/require\s*\(|if\s*\(!/i.test(codeText(transition.source))) continue; + if (!/\.transfer\s*\(|\.transferFrom\s*\(|IERC20|token\./i.test(op.expression)) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-003", + title: `Unchecked ERC20 return in ${transition.name}`, + description: "An ERC20 transfer or transferFrom return value is not checked. Non-standard tokens may return false instead of reverting.", + recommendation: "Use SafeERC20.safeTransfer/safeTransferFrom or check the boolean return value.", + severity: "medium", + confidence: "high", + category: "token-return", + model, transition, operation: op, + })); + } + } + return findings; +} + +function detectUncheckedLowLevelReturn(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + for (const op of transition.operations) { + if (op.callKind !== "call") continue; + if (hasSafeWrapper(transition, op) || successCheckedAfter(transition, op)) continue; + if (isOptionalCall(transition, op)) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-004", + title: `Unchecked low-level call return in ${transition.name}`, + description: "A .call() return boolean is not verified. The callee may fail while the caller continues execution.", + recommendation: "Require the call to succeed or bubble the failure with Address.functionCall.", + severity: "medium", + confidence: "high", + category: "low-level-return", + model, transition, operation: op, + })); + } + } + return findings; +} + +function detectUnsafeAbiDecode(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + for (const decode of analyzeDecodeSites(transition)) { + if (decode.hasLengthCheck || decode.hasTryCatch) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-005", + title: `Unsafe ABI decode in ${transition.name}`, + description: "abi.decode is applied to returndata or calldata without verifying sufficient length, enabling malformed data panics or type confusion.", + recommendation: "Check returndatasize() or data.length before decoding. Wrap in try/catch for external data.", + severity: "medium", + confidence: "medium", + category: "decode-safety", + model, transition, + evidence: [decodeEvidence(transition, decode.expression)], + })); + } + } + return findings; +} + +function detectStaleReturndata(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + if (!hasStaleReturndataPattern(transition)) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-006", + title: `Stale returndata reuse in ${transition.name}`, + description: "Returndata from a prior call may be decoded after a subsequent call overwrites the returndata buffer.", + recommendation: "Copy returndata to memory immediately after each call before making additional external calls.", + severity: "high", + confidence: "medium", + category: "stale-returndata", + model, transition, + })); + } + return findings; +} + +function detectPartialBatchFailure(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + const source = codeText(transition.source); + const isBatch = transition.role === "batch-operation" || + (/batch/i.test(transition.name) && /for\s*\(/i.test(transition.source)); + if (!isBatch) continue; + if (/require\s*\(\s*success|safeTransfer|safeTransferFrom/i.test(source)) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-007", + title: `Partial batch failure ignored in ${transition.name}`, + description: "A loop of external calls does not abort or roll back on individual failure, leaving partial state updates.", + recommendation: "Check each call's success flag and revert the entire batch on any failure.", + severity: "high", + confidence: "medium", + category: "batch-failure", + model, transition, + })); + } + return findings; +} + +function detectIgnoredDelegatecallReturn(model: ReturndataContractModel): ReturndataFinding[] { + return ignoredCallKind(model, "delegatecall", "CP-RTD-008", "Delegatecall failures can corrupt storage silently."); +} + +function detectIgnoredStaticcallReturn(model: ReturndataContractModel): ReturndataFinding[] { + return ignoredCallKind(model, "staticcall", "CP-RTD-009", "Staticcall failures may indicate invalid view data assumptions."); +} + +function detectIgnoredSendReturn(model: ReturndataContractModel): ReturndataFinding[] { + return ignoredCallKind(model, "send", "CP-RTD-010", "send() returns false on failure; ignoring it loses ETH transfer confirmation."); +} + +function detectUnsafeTransfer(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + for (const op of transition.operations) { + if (op.callKind !== "transfer") continue; + if (hasSafeWrapper(transition, op)) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-011", + title: `transfer() used without return check in ${transition.name}`, + description: "Solidity transfer() forwards only 2300 gas and reverts on standard recipients, but some contracts may not revert reliably.", + recommendation: "Use call{value: amount}(\"\") with success check or Address.sendValue.", + severity: "low", + confidence: "medium", + category: "transfer-safety", + model, transition, operation: op, + })); + } + } + return findings; +} + +function detectUnsafeAssemblyCopy(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + const source = codeText(transition.source); + if (!/returndatacopy|call\s*\(/i.test(source)) continue; + const guards = detectGuards(transition); + if (guards.some((g) => g.kind === "assembly-bounds")) continue; + if (!/assembly\s*\{/i.test(transition.source)) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-012", + title: `Assembly returndata copy without bounds in ${transition.name}`, + description: "Inline assembly copies returndata without checking returndatasize(), risking out-of-bounds reads.", + recommendation: "Check returndatasize() >= expected length before returndatacopy.", + severity: "high", + confidence: "medium", + category: "assembly-safety", + model, transition, + })); + } + return findings; +} + +function detectSwallowedTryCatch(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + const source = transition.source; + if (!/try\s+/i.test(source) || !/catch\s*\(\s*\)\s*\{\s*\}/s.test(source)) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-013", + title: `Try/catch swallows failure in ${transition.name}`, + description: "An empty catch block silently absorbs external call failures on a security-critical path.", + recommendation: "Log, revert, or propagate failures in catch blocks for security-critical calls.", + severity: "medium", + confidence: "high", + category: "try-catch", + model, transition, + optionalCall: isOptionalCall(transition, transition.operations[0]), + })); + } + return findings; +} + +function detectMulticallPartialFailure(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + if (transition.role !== "multicall") continue; + const source = codeText(transition.source); + if (/require\s*\(\s*success|revert/i.test(source)) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-014", + title: `Multicall partial failure not propagated in ${transition.name}`, + description: "Multicall batch does not revert or flag when individual subcalls fail.", + recommendation: "Return per-call success flags or revert the entire batch on any failure.", + severity: "high", + confidence: "medium", + category: "multicall", + model, transition, + })); + } + return findings; +} + +function detectProxyDecodeAssumption(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + for (const op of transition.operations) { + if (op.callKind !== "delegatecall") continue; + const source = codeText(transition.source); + if (!/abi\.decode/i.test(source)) continue; + if (/returndatasize|try\s+/i.test(source)) continue; + findings.push(makeFinding({ + ruleId: "CP-RTD-015", + title: `Proxy delegatecall decode assumption in ${transition.name}`, + description: "Return data from a delegatecall is decoded without verifying the implementation returned expected types and length.", + recommendation: "Validate returndata length and wrap decode in try/catch for upgradeable proxy paths.", + severity: "medium", + confidence: "medium", + category: "proxy-decode", + model, transition, operation: op, + })); + } + } + return findings; +} + +function detectMisclassifiedOptionalCall(model: ReturndataContractModel): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + const optional = isOptionalCall(transition, transition.operations[0]); + if (!optional) continue; + for (const op of transition.operations) { + if (!REQUIRES_SUCCESS_CHECK.has(op.callKind)) continue; + if (successCheckedAfter(transition, op)) continue; + const source = codeText(transition.source); + if (/withdraw|transfer|mint|burn|upgrade|grant/i.test(source) && !/@dev\s+optional/i.test(transition.source)) { + findings.push(makeFinding({ + ruleId: "CP-RTD-016", + title: `Security-critical call marked optional in ${transition.name}`, + description: "A call on a security-critical path appears intentionally optional but lacks explicit documentation or evidence distinguishing it from an accidental ignored failure.", + recommendation: "Document optional call intent with @dev comments and ensure critical paths always check returns.", + severity: "info", + confidence: "low", + category: "optional-call", + model, transition, operation: op, + optionalCall: true, + })); + } + } + } + return findings; +} + +function ignoredCallKind( + model: ReturndataContractModel, + kind: ReturndataOperation["callKind"], + ruleId: ReturndataRuleId, + description: string, +): ReturndataFinding[] { + const findings: ReturndataFinding[] = []; + for (const transition of model.transitions) { + for (const op of transition.operations) { + if (op.callKind !== kind) continue; + if (hasSafeWrapper(transition, op) || successCheckedAfter(transition, op)) continue; + if (isOptionalCall(transition, op)) continue; + findings.push(makeFinding({ + ruleId, + title: `Ignored ${kind} return in ${transition.name}`, + description, + recommendation: `Check the ${kind} success return or use a safe wrapper.`, + severity: "medium", + confidence: "high", + category: "ignored-return", + model, transition, operation: op, + })); + } + } + return findings; +} + +interface FindingInput { + ruleId: ReturndataRuleId; + title: string; + description: string; + recommendation: string; + severity: ReturndataFinding["severity"]; + confidence: ReturndataFinding["confidence"]; + category: string; + model: ReturndataContractModel; + transition: ReturndataTransition; + operation?: ReturndataOperation; + evidence?: ReturndataEvidence[]; + optionalCall?: boolean; +} + +function makeFinding(input: FindingInput): ReturndataFinding { + return { + ruleId: input.ruleId, + title: input.title, + description: input.description, + recommendation: input.recommendation, + severity: input.severity, + confidence: input.confidence, + category: input.category, + contract: input.model.name, + location: input.operation?.location ?? input.transition.location, + evidence: input.evidence ?? (input.operation ? + [callEvidence(input.operation, input.description)] : + [absenceEvidence(input.transition, input.description)]), + assumptions: input.model.assumptions, + optionalCall: input.optionalCall ?? false, + }; +} + +function successCheckedAfter(transition: ReturndataTransition, op: ReturndataOperation): boolean { + const source = codeText(transition.source); + return op.checksSuccess || + /require\s*\(\s*success|if\s*\(\s*!success|if\s*\(!.*\)\s*revert/i.test(source) || + transition.guards.some((g) => /success|require/i.test(g)) || + detectGuards(transition).some((g) => g.kind === "require-success" || g.kind === "safe-erc20"); +} + +function isBareStatement(transition: ReturndataTransition, op: ReturndataOperation): boolean { + const line = op.location.line; + const sourceLines = transition.source.split("\n"); + for (const lineText of sourceLines) { + if (/^\s*\w+\.(call|send|delegatecall|staticcall)\s*[\({]/i.test(lineText) && + !/=\s*|require\s*\(|if\s*\(/i.test(lineText)) return true; + } + return !op.capturesReturn; +} + +function callEvidence(op: ReturndataOperation, description: string): ReturndataEvidence { + return { kind: "call-site", description, location: op.location, snippet: op.expression }; +} + +function decodeEvidence(transition: ReturndataTransition, expression: string): ReturndataEvidence { + return { kind: "decode-site", description: "Unsafe decode site", location: transition.location, snippet: expression }; +} + +function absenceEvidence(transition: ReturndataTransition, description: string): ReturndataEvidence { + return { kind: "absence", description, location: transition.location }; +} + +function codeText(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n\r]*/g, " ").replace(/\s+/g, " "); +} + +function compareFindings(a: ReturndataFinding, b: ReturndataFinding): number { + return a.location.line - b.location.line || a.ruleId.localeCompare(b.ruleId); +} diff --git a/packages/core/src/returndata/api.ts b/packages/core/src/returndata/api.ts new file mode 100644 index 0000000..4172544 --- /dev/null +++ b/packages/core/src/returndata/api.ts @@ -0,0 +1,266 @@ +import * as fs from "fs"; +import * as path from "path"; +import { analyzeReturndataModel } from "./analyzer"; +import { + ReturndataAnalysisCancelledError, + resolveReturndataLimits, +} from "./config"; +import { buildReturndataModels } from "./model"; +import type { + ReturndataAnalysisOptions, + ReturndataAnalysisReport, + ReturndataContractModel, + ReturndataDiagnostic, + ReturndataFileAnalysis, + ReturndataFinding, + ReturndataSourceInput, +} from "./types"; + +export const RETURNDATA_ENGINE_VERSION = "0.1.0" as const; + +const SEVERITIES = ["critical", "high", "medium", "low", "info"] as const; + +/** Analyze a single in-memory Solidity source without filesystem or network access. */ +export function analyzeReturndataSource( + source: string, + file = ".sol", + options: ReturndataAnalysisOptions = {}, +): ReturndataAnalysisReport { + return analyzeReturndataSources([{ file, source }], options); +} + +/** Analyze an explicitly supplied, deterministic set of Solidity sources. */ +export function analyzeReturndataSources( + inputs: ReturndataSourceInput[], + options: ReturndataAnalysisOptions = {}, +): ReturndataAnalysisReport { + const limits = resolveReturndataLimits(options.limits); + checkCancelled(options); + const ordered = [...inputs] + .map((input) => ({ file: input.file, source: input.source })) + .sort((left, right) => left.file.localeCompare(right.file)); + const files: ReturndataFileAnalysis[] = []; + let contractCount = 0; + let findingsRemaining = limits.maxFindings; + let truncated = ordered.length > limits.maxFiles; + + for (const input of ordered.slice(0, limits.maxFiles)) { + checkCancelled(options); + const built = buildReturndataModels(input.source, input.file, limits, options.signal); + contractCount += built.models.length; + const findings: ReturndataFinding[] = []; + for (const model of built.models) { + checkCancelled(options); + for (const finding of analyzeReturndataModel(model, options)) { + if (findingsRemaining === 0) { + truncated = true; + break; + } + findings.push({ + ...finding, + evidence: finding.evidence.slice(0, limits.maxEvidencePerFinding), + }); + findingsRemaining -= 1; + } + if (findingsRemaining === 0) break; + } + const diagnostics = [...built.diagnostics]; + if (findingsRemaining === 0) { + diagnostics.push(limitDiagnostic(input.file, limits.maxFindings)); + } + files.push({ + file: input.file, + findings: findings.sort(compareFindings), + diagnostics: diagnostics.sort(compareDiagnostics), + ...(options.includeModels ? { models: built.models.map(sortModel) } : {}), + }); + } + + if (ordered.length > limits.maxFiles) { + files.push({ + file: "", + findings: [], + diagnostics: [{ + code: "RTD_SOURCE_LIMIT", + severity: "warning", + message: `Only the first ${limits.maxFiles} Solidity files were analyzed`, + }], + }); + } + return report(files, truncated, contractCount); +} + +/** Recursively collect Solidity files while avoiding symlink traversal. */ +export function collectReturndataSolidityFiles(targets: string[]): string[] { + const result = new Set(); + const pending = [...targets].map((target) => path.resolve(target)).sort().reverse(); + while (pending.length) { + const candidate = pending.pop() as string; + let stat: fs.Stats; + try { + stat = fs.lstatSync(candidate); + } catch { + continue; + } + if (stat.isSymbolicLink()) continue; + if (stat.isFile()) { + if (candidate.endsWith(".sol")) result.add(candidate); + continue; + } + if (!stat.isDirectory()) continue; + let entries: string[]; + try { + entries = fs.readdirSync(candidate).sort(); + } catch { + continue; + } + for (let index = entries.length - 1; index >= 0; index -= 1) { + pending.push(path.join(candidate, entries[index])); + } + } + return [...result].sort(); +} + +/** Read and analyze Solidity files/directories with bounded IO and sanitized diagnostics. */ +export function analyzeReturndataFiles( + targets: string[], + options: ReturndataAnalysisOptions = {}, +): ReturndataAnalysisReport { + const limits = resolveReturndataLimits(options.limits); + checkCancelled(options); + const discovered = collectReturndataSolidityFiles(targets); + const inputs: ReturndataSourceInput[] = []; + const unreadable: ReturndataFileAnalysis[] = []; + for (const target of [...new Set(targets.map((item) => path.resolve(item)))].sort()) { + try { + fs.lstatSync(target); + } catch (error) { + unreadable.push(unreadableFile(target, error)); + } + } + for (const file of discovered.slice(0, limits.maxFiles)) { + checkCancelled(options); + try { + inputs.push({ file, source: fs.readFileSync(file, "utf8") }); + } catch (error) { + unreadable.push(unreadableFile(file, error)); + } + } + const analysis = analyzeReturndataSources(inputs, { ...options, limits }); + const files = [...analysis.files.filter((file) => file.file !== ""), ...unreadable] + .sort((left, right) => left.file.localeCompare(right.file)); + if (discovered.length > limits.maxFiles || analysis.files.some((file) => file.file === "")) { + files.push({ + file: "", + findings: [], + diagnostics: [{ + code: "RTD_SOURCE_LIMIT", + severity: "warning", + message: `Only the first ${limits.maxFiles} Solidity files were analyzed`, + }], + }); + } + return report( + files, + analysis.summary.truncated || discovered.length > limits.maxFiles, + analysis.summary.contracts, + ); +} + +function report( + files: ReturndataFileAnalysis[], + truncated: boolean, + contractCount: number, +): ReturndataAnalysisReport { + const summary = { + files: files.filter((file) => file.file !== "").length, + contracts: contractCount, + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + total: 0, + truncated, + }; + for (const file of files) { + for (const finding of file.findings) { + incrementSeverity(summary, finding.severity); + summary.total += 1; + } + if (file.diagnostics.some((diagnostic) => + diagnostic.code === "RTD_FINDING_LIMIT" || diagnostic.code.endsWith("_LIMIT"))) { + summary.truncated = true; + } + } + return { + schemaVersion: "1.0.0", + engineVersion: RETURNDATA_ENGINE_VERSION, + files, + summary, + }; +} + +function sortModel(model: ReturndataContractModel): ReturndataContractModel { + return { + ...model, + stateVariables: [...model.stateVariables].sort((left, right) => + left.location.line - right.location.line || left.name.localeCompare(right.name)), + transitions: [...model.transitions].sort((left, right) => + left.location.line - right.location.line || left.name.localeCompare(right.name)), + externalCalls: [...model.externalCalls].sort((left, right) => left.order - right.order), + }; +} + +function incrementSeverity( + summary: { critical: number; high: number; medium: number; low: number; info: number }, + severity: string, +): void { + if (severity === "critical" || severity === "high" || severity === "medium" || + severity === "low" || severity === "info") { + summary[severity] += 1; + } +} + +function limitDiagnostic(file: string, limit: number): ReturndataDiagnostic { + return { + code: "RTD_FINDING_LIMIT", + severity: "warning", + message: `Finding output was limited to ${limit} records`, + location: { file, line: 1, column: 1 }, + }; +} + +function compareFindings(left: ReturndataFinding, right: ReturndataFinding): number { + return left.location.line - right.location.line || left.location.column - right.location.column || + left.ruleId.localeCompare(right.ruleId) || left.contract.localeCompare(right.contract); +} + +function compareDiagnostics(left: ReturndataDiagnostic, right: ReturndataDiagnostic): number { + return (left.location?.line ?? 0) - (right.location?.line ?? 0) || + left.code.localeCompare(right.code) || left.message.localeCompare(right.message); +} + +function checkCancelled(options: ReturndataAnalysisOptions): void { + if (options.signal?.aborted) throw new ReturndataAnalysisCancelledError(); +} + +function safeErrorCode(error: unknown): string { + const code = (error as { code?: unknown } | null)?.code; + return typeof code === "string" && /^[A-Z0-9_]+$/.test(code) ? code : "IO_ERROR"; +} + +function unreadableFile(file: string, error: unknown): ReturndataFileAnalysis { + return { + file, + findings: [], + diagnostics: [{ + code: "RTD_FILE_UNREADABLE", + severity: "error", + message: `Solidity target could not be read (${safeErrorCode(error)})`, + location: { file, line: 1, column: 1 }, + }], + }; +} + +export const RETURNDATA_SEVERITY_ORDER = SEVERITIES; diff --git a/packages/core/src/returndata/call-classifier.ts b/packages/core/src/returndata/call-classifier.ts new file mode 100644 index 0000000..177f644 --- /dev/null +++ b/packages/core/src/returndata/call-classifier.ts @@ -0,0 +1,44 @@ +import type { CallKind } from "./types"; + +interface NodeRecord { + type?: string; + memberName?: string; + name?: string; + expression?: unknown; +} + +/** Classify low-level and interface call kinds from AST and source snippet. */ +export function classifyCallKind(snippet: string, expr?: NodeRecord): CallKind { + const lower = snippet.toLowerCase(); + if (/\.delegatecall\s*\(/i.test(snippet)) return "delegatecall"; + if (/\.staticcall\s*\(/i.test(snippet)) return "staticcall"; + if (/\.callcode\s*\(/i.test(snippet)) return "callcode"; + if (/\.call\s*\{|\.call\s*\(/i.test(snippet)) return "call"; + if (/\.send\s*\(/i.test(snippet)) return "send"; + if (/\.transfer\s*\(/i.test(snippet)) return "transfer"; + if (expr?.type === "MemberAccess") { + const member = expr.memberName?.toLowerCase(); + if (member === "delegatecall") return "delegatecall"; + if (member === "staticcall") return "staticcall"; + if (member === "call") return "call"; + if (member === "send") return "send"; + if (member === "transfer") return "transfer"; + } + if (/transfer\s*\(|transferfrom\s*\(/i.test(lower)) return "interface-call"; + return "unknown"; +} + +/** Low-level call kinds that return a success boolean requiring explicit check. */ +export const REQUIRES_SUCCESS_CHECK: ReadonlySet = new Set([ + "call", "callcode", "delegatecall", "staticcall", "send", +]); + +/** Token interface calls that may return false instead of reverting. */ +export const TOKEN_RETURN_CALLS: ReadonlySet = new Set([ + "interface-call", "transfer", +]); + +/** Call kinds where failure typically reverts (no return check needed for standard tokens). */ +export const REVERT_ON_FAILURE: ReadonlySet = new Set([ + "transfer", +]); diff --git a/packages/core/src/returndata/config.ts b/packages/core/src/returndata/config.ts new file mode 100644 index 0000000..51ce6a3 --- /dev/null +++ b/packages/core/src/returndata/config.ts @@ -0,0 +1,216 @@ +import * as fs from "fs"; +import { + RETURNDATA_CONFIG_SCHEMA_VERSION, + type ReturndataAnalysisConfigInput, + type ReturndataAnalysisConfigV1, + type ReturndataAnalysisLimits, + type ReturndataDiagnostic, + type ReturndataRuleId, + type ValidatedReturndataConfig, +} from "./types"; + +export const DEFAULT_RETURNDATA_LIMITS: Readonly = Object.freeze({ + maxSourceBytes: 2 * 1024 * 1024, + maxFiles: 256, + maxContracts: 128, + maxFunctionsPerFile: 512, + maxFunctionsPerContract: 512, + maxOperationsPerFunction: 2048, + maxFindings: 1024, + maxEvidencePerFinding: 12, +}); + +const RULE_IDS = new Set(Array.from({ length: 16 }, (_, index) => + `CP-RTD-${String(index + 1).padStart(3, "0")}`, +)); + +const LIMIT_KEYS: Array = [ + "maxSourceBytes", + "maxFiles", + "maxContracts", + "maxFunctionsPerFile", + "maxFunctionsPerContract", + "maxOperationsPerFunction", + "maxFindings", + "maxEvidencePerFinding", +]; + +export class ReturndataConfigError extends Error { + readonly code = "RTD_CONFIG_INVALID"; + + constructor(message: string) { + super(message); + this.name = "ReturndataConfigError"; + } +} + +export class ReturndataAnalysisCancelledError extends Error { + readonly code = "RTD_CANCELLED"; + + constructor() { + super("Returndata safety analysis was cancelled"); + this.name = "ReturndataAnalysisCancelledError"; + } +} + +export function resolveReturndataLimits( + input?: Partial, +): ReturndataAnalysisLimits { + if (input !== undefined && !isRecord(input)) { + throw new ReturndataConfigError("limits must be an object"); + } + const result: ReturndataAnalysisLimits = { ...DEFAULT_RETURNDATA_LIMITS }; + for (const key of LIMIT_KEYS) { + const value = input?.[key]; + if (value === undefined) continue; + if (!Number.isSafeInteger(value) || value <= 0) { + throw new ReturndataConfigError(`${key} must be a positive safe integer`); + } + result[key] = value; + } + return result; +} + +export function migrateReturndataConfig( + input: ReturndataAnalysisConfigInput, +): ValidatedReturndataConfig { + if (!isRecord(input)) throw new ReturndataConfigError("configuration root must be an object"); + if (input.schemaVersion === RETURNDATA_CONFIG_SCHEMA_VERSION) return validateV1(input); + if (input.schemaVersion !== undefined && input.schemaVersion !== 0) { + throw new ReturndataConfigError( + `unsupported returndata configuration schemaVersion ${String(input.schemaVersion)}`, + ); + } + rejectUnknownKeys(input, [ + "schemaVersion", "version", "maxFileSize", "maxIssues", "detectors", "includeModels", + ], "configuration"); + + const limits: Partial = {}; + if (input.maxFileSize !== undefined) { + limits.maxSourceBytes = positiveInteger(input.maxFileSize, "maxFileSize"); + } + if (input.maxIssues !== undefined) { + limits.maxFindings = positiveInteger(input.maxIssues, "maxIssues"); + } + const includeRules = input.detectors === undefined + ? undefined + : validateRules(input.detectors, "detectors"); + const migrated = input.version === 0 || input.maxFileSize !== undefined || + input.maxIssues !== undefined || input.detectors !== undefined; + const diagnostics: ReturndataDiagnostic[] = migrated ? [{ + code: "RTD_CONFIG_INVALID", + severity: "info", + message: "Migrated returndata configuration from legacy schema v0 to v1", + }] : []; + + const config: ReturndataAnalysisConfigV1 = { + schemaVersion: RETURNDATA_CONFIG_SCHEMA_VERSION, + ...(Object.keys(limits).length ? { limits } : {}), + ...(typeof input.includeModels === "boolean" ? { includeModels: input.includeModels } : {}), + ...(includeRules ? { includeRules } : {}), + }; + resolveReturndataLimits(config.limits); + return { config, diagnostics }; +} + +export function validateReturndataConfig( + input: ReturndataAnalysisConfigInput, +): ValidatedReturndataConfig { + return migrateReturndataConfig(input); +} + +export function loadReturndataConfigFile(filePath: string): ValidatedReturndataConfig { + let content: string; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (error) { + throw new ReturndataConfigError(`configuration file could not be read (${errorCode(error)})`); + } + try { + return validateReturndataConfig(JSON.parse(content) as ReturndataAnalysisConfigInput); + } catch (error) { + if (error instanceof ReturndataConfigError) throw error; + throw new ReturndataConfigError("configuration file contains invalid JSON"); + } +} + +function validateV1(input: Record): ValidatedReturndataConfig { + rejectUnknownKeys(input, [ + "schemaVersion", "limits", "includeModels", "includeRules", "excludeRules", + ], "configuration"); + if (input.includeModels !== undefined && typeof input.includeModels !== "boolean") { + throw new ReturndataConfigError("includeModels must be a boolean"); + } + const limits = input.limits === undefined ? undefined : validateLimits(input.limits); + const includeRules = input.includeRules === undefined + ? undefined + : validateRules(input.includeRules, "includeRules"); + const excludeRules = input.excludeRules === undefined + ? undefined + : validateRules(input.excludeRules, "excludeRules"); + if (includeRules && excludeRules) { + const overlap = includeRules.filter((rule) => excludeRules.includes(rule)); + if (overlap.length) { + throw new ReturndataConfigError(`includeRules and excludeRules overlap: ${overlap.join(", ")}`); + } + } + return { + config: { + schemaVersion: RETURNDATA_CONFIG_SCHEMA_VERSION, + ...(limits ? { limits } : {}), + ...(typeof input.includeModels === "boolean" ? { includeModels: input.includeModels } : {}), + ...(includeRules ? { includeRules } : {}), + ...(excludeRules ? { excludeRules } : {}), + }, + diagnostics: [], + }; +} + +function validateLimits(value: unknown): Partial { + if (!isRecord(value)) throw new ReturndataConfigError("limits must be an object"); + rejectUnknownKeys(value, LIMIT_KEYS, "limits"); + const limits: Partial = {}; + for (const key of LIMIT_KEYS) { + if (value[key] !== undefined) limits[key] = positiveInteger(value[key], key); + } + resolveReturndataLimits(limits); + return limits; +} + +function validateRules(value: unknown, field: string): ReturndataRuleId[] { + if (!Array.isArray(value)) throw new ReturndataConfigError(`${field} must be an array`); + const result = new Set(); + for (const rule of value) { + if (typeof rule !== "string" || !RULE_IDS.has(rule)) { + throw new ReturndataConfigError(`${field} contains unknown rule ${String(rule)}`); + } + result.add(rule as ReturndataRuleId); + } + return [...result].sort(); +} + +function positiveInteger(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new ReturndataConfigError(`${field} must be a positive safe integer`); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorCode(error: unknown): string { + const code = (error as { code?: unknown } | null)?.code; + return typeof code === "string" && /^[A-Z0-9_]+$/.test(code) ? code : "IO_ERROR"; +} + +function rejectUnknownKeys( + value: Record, + allowed: readonly (string | number | symbol)[], + field: string, +): void { + const permitted = new Set(allowed.map(String)); + const unknown = Object.keys(value).filter((key) => !permitted.has(key)).sort(); + if (unknown.length) throw new ReturndataConfigError(`${field} contains unknown field ${unknown[0]}`); +} diff --git a/packages/core/src/returndata/decode-analysis.ts b/packages/core/src/returndata/decode-analysis.ts new file mode 100644 index 0000000..95e553a --- /dev/null +++ b/packages/core/src/returndata/decode-analysis.ts @@ -0,0 +1,50 @@ +import type { ReturndataOperation, ReturndataTransition } from "./types"; + +export interface DecodeAnalysis { + hasLengthCheck: boolean; + hasTryCatch: boolean; + usesCalldataSlice: boolean; + targetType: string; + expression: string; +} + +/** Analyze ABI decode sites for unsafe decoding patterns. */ +export function analyzeDecodeSites(transition: ReturndataTransition): DecodeAnalysis[] { + const results: DecodeAnalysis[] = []; + const source = codeText(transition.source); + for (const op of transition.operations) { + if (op.kind !== "decode" && !/abi\.decode/i.test(op.expression)) continue; + results.push({ + hasLengthCheck: /require\s*\(\s*data\.length|returndatasize\s*\(\)|\.length\s*>=/i.test(source), + hasTryCatch: transition.guards.some((g) => /try/i.test(g)), + usesCalldataSlice: /calldatacopy|returndatacopy|mload/i.test(op.expression), + targetType: extractDecodeType(op.expression), + expression: op.expression, + }); + } + return results; +} + +/** Detect stale returndata reuse across sequential calls. */ +export function hasStaleReturndataPattern(transition: ReturndataTransition): boolean { + const calls = transition.operations.filter((op) => op.kind === "call"); + const decodes = transition.operations.filter((op) => op.kind === "decode" || /abi\.decode/i.test(op.expression)); + if (calls.length < 2 || decodes.length === 0) return false; + const source = codeText(transition.source); + return decodes.some((decode) => { + const decodeOrder = decode.order; + const priorCalls = calls.filter((c) => c.order < decodeOrder); + const laterCalls = calls.filter((c) => c.order > decodeOrder && c.order < decodeOrder + 1000); + return priorCalls.length > 0 && laterCalls.length > 0 && + !/returndatasize|returnData|fresh/i.test(source.slice(0, 500)); + }); +} + +function extractDecodeType(expression: string): string { + const match = expression.match(/abi\.decode\s*\([^,]+,\s*\(([^)]+)\)/i); + return match?.[1] ?? "unknown"; +} + +function codeText(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n\r]*/g, " ").replace(/\s+/g, " "); +} diff --git a/packages/core/src/returndata/guard-detection.ts b/packages/core/src/returndata/guard-detection.ts new file mode 100644 index 0000000..da1eceb --- /dev/null +++ b/packages/core/src/returndata/guard-detection.ts @@ -0,0 +1,68 @@ +import type { ReturndataOperation, ReturndataTransition } from "./types"; + +export type GuardKind = + | "safe-erc20" + | "address-function-call" + | "try-catch" + | "require-success" + | "explicit-bubble" + | "assembly-bounds" + | "optional-call"; + +export interface GuardEvidence { + kind: GuardKind; + description: string; + operation?: ReturndataOperation; +} + +/** Recognize SafeERC20-style wrappers, Address utilities, try/catch, and validated assembly. */ +export function detectGuards(transition: ReturndataTransition): GuardEvidence[] { + const source = codeText(transition.source); + const guards: GuardEvidence[] = []; + + if (/SafeERC20|safeTransfer|safeTransferFrom|safeApprove|forceApprove/i.test(source)) { + guards.push({ kind: "safe-erc20", description: "SafeERC20 wrapper used" }); + } + if (/Address\.functionCall|Address\.functionCallWithValue|Address\.functionDelegateCall/i.test(source)) { + guards.push({ kind: "address-function-call", description: "OpenZeppelin Address utility used" }); + } + if (/try\s+\w+\.|catch\s*\{|catch\s+\w+/i.test(source) || transition.guards.some((g) => /try/i.test(g))) { + guards.push({ kind: "try-catch", description: "Try/catch error handling present" }); + } + if (/require\s*\(\s*success|if\s*\(\s*!success|if\s*\(!.*\)\s*revert/i.test(source)) { + guards.push({ kind: "require-success", description: "Explicit success flag check" }); + } + if (/revert\s*\(|bubble|propagate/i.test(source) && /catch/i.test(source)) { + guards.push({ kind: "explicit-bubble", description: "Failure bubbling in catch block" }); + } + if (/returndatasize\s*\(\)|mload|calldatasize|iszero\s*\(\s*returndatasize/i.test(source)) { + guards.push({ kind: "assembly-bounds", description: "Assembly returndata bounds check" }); + } + if (/@dev\s+optional|\/\/\s*optional|ignore\s+failure|best\s+effort/i.test(source)) { + guards.push({ kind: "optional-call", description: "Documented optional call intent" }); + } + + for (const op of transition.operations) { + if (op.kind === "guard" && op.checksSuccess) { + guards.push({ kind: "require-success", description: "Guard checks call success", operation: op }); + } + } + return guards; +} + +export function isOptionalCall(transition: ReturndataTransition, operation: ReturndataOperation): boolean { + return detectGuards(transition).some((g) => g.kind === "optional-call") || + /optional|bestEffort|tryNotify|_try/i.test(transition.name); +} + +export function hasSafeWrapper(transition: ReturndataTransition, operation: ReturndataOperation): boolean { + if (operation.usesSafeWrapper) return true; + const guards = detectGuards(transition); + return guards.some((g) => + g.kind === "safe-erc20" || g.kind === "address-function-call" || g.kind === "try-catch", + ); +} + +function codeText(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n\r]*/g, " ").replace(/\s+/g, " "); +} diff --git a/packages/core/src/returndata/index.ts b/packages/core/src/returndata/index.ts new file mode 100644 index 0000000..257d9bb --- /dev/null +++ b/packages/core/src/returndata/index.ts @@ -0,0 +1,62 @@ +export { + analyzeReturndataSource, + analyzeReturndataSources, + analyzeReturndataFiles, + collectReturndataSolidityFiles, + RETURNDATA_ENGINE_VERSION, + RETURNDATA_SEVERITY_ORDER, +} from "./api"; +export { analyzeReturndataModel } from "./analyzer"; +export { buildReturndataModels } from "./model"; +export { classifyCallKind, REQUIRES_SUCCESS_CHECK, TOKEN_RETURN_CALLS } from "./call-classifier"; +export { analyzeDecodeSites, hasStaleReturndataPattern } from "./decode-analysis"; +export { detectGuards, hasSafeWrapper, isOptionalCall } from "./guard-detection"; +export { mergeSlitherReturnFindings, toScanFinding } from "./slither-merge"; +export { + RETURNDATA_FRAMEWORK_ADAPTERS, + getReturndataFrameworkAdapter, + matchReturndataFramework, +} from "./adapters"; +export { + DEFAULT_RETURNDATA_LIMITS, + ReturndataAnalysisCancelledError, + ReturndataConfigError, + loadReturndataConfigFile, + migrateReturndataConfig, + resolveReturndataLimits, + validateReturndataConfig, +} from "./config"; +export { generateReturndataMarkdown, serializeReturndataReport } from "./serialize"; +export { detectReturndataSafety } from "./rule"; +export { + RETURNDATA_CONFIG_SCHEMA_VERSION, + RETURNDATA_REPORT_SCHEMA_VERSION, +} from "./types"; +export type { + ReturndataAnalysisConfigInput, + ReturndataAnalysisConfigV0, + ReturndataAnalysisConfigV1, + ReturndataAnalysisLimits, + ReturndataAnalysisOptions, + ReturndataAnalysisReport, + ReturndataCancellationSignal, + ReturndataContractModel, + ReturndataDiagnostic, + ReturndataEvidence, + ReturndataFileAnalysis, + ReturndataFinding, + ReturndataFrameworkAdapter, + ReturndataFrameworkAdapterDefinition, + ReturndataFrameworkMatch, + ReturndataFunctionRole, + ReturndataOperation, + ReturndataRuleId, + ReturndataSourceInput, + ReturndataSourceLocation, + ReturndataStateVariable, + ReturndataTransition, + ReturndataVariableRole, + ValidatedReturndataConfig, + CallKind, +} from "./types"; +export type { BuildReturndataModelsResult } from "./model"; diff --git a/packages/core/src/returndata/model.ts b/packages/core/src/returndata/model.ts new file mode 100644 index 0000000..a35ecca --- /dev/null +++ b/packages/core/src/returndata/model.ts @@ -0,0 +1,379 @@ +import { parseSolidity } from "../ast/parser"; +import type { ASTNode } from "../types"; +import { ReturndataAnalysisCancelledError } from "./config"; +import { matchReturndataFramework } from "./adapters"; +import { classifyCallKind } from "./call-classifier"; +import type { + CallKind, + ReturndataAnalysisLimits, + ReturndataCancellationSignal, + ReturndataContractModel, + ReturndataDiagnostic, + ReturndataFunctionRole, + ReturndataOperation, + ReturndataSourceLocation, + ReturndataStateVariable, + ReturndataTransition, + ReturndataVariableRole, +} from "./types"; + +interface NodeRecord { + type?: string; + name?: string; + memberName?: string; + operator?: string; + visibility?: string; + isConstructor?: boolean; + range?: [number, number]; + loc?: { start?: { line?: number; column?: number }; end?: { line?: number; column?: number } }; + subNodes?: ASTNode[]; + variables?: ASTNode[]; + parameters?: ASTNode[]; + modifiers?: ASTNode[]; + expression?: ASTNode; + left?: ASTNode; + right?: ASTNode; + condition?: ASTNode; + typeName?: ASTNode; + [key: string]: unknown; +} + +export interface BuildReturndataModelsResult { + models: ReturndataContractModel[]; + diagnostics: ReturndataDiagnostic[]; +} + +export function buildReturndataModels( + source: string, + file: string, + limits: ReturndataAnalysisLimits, + signal?: ReturndataCancellationSignal, +): BuildReturndataModelsResult { + checkCancelled(signal); + if (Buffer.byteLength(source, "utf8") > limits.maxSourceBytes) { + return limited("RTD_SOURCE_LIMIT", `Source exceeds the ${limits.maxSourceBytes}-byte limit`, file); + } + const parsed = parseSolidity(source, ""); + if (!parsed.ast) { + return parseFailure(file, sanitizeParseError(parsed.error)); + } + const contracts = collectNodes(parsed.ast, "ContractDefinition", signal); + const models: ReturndataContractModel[] = []; + const diagnostics: ReturndataDiagnostic[] = []; + for (const contract of contracts.slice(0, limits.maxContracts)) { + checkCancelled(signal); + const built = buildContract(source, file, contract, limits); + diagnostics.push(...built.diagnostics); + if (isRelevant(built.model)) models.push(built.model); + } + return { models, diagnostics }; +} + +function buildContract( + source: string, + file: string, + contractNode: ASTNode, + limits: ReturndataAnalysisLimits, +): { model: ReturndataContractModel; diagnostics: ReturndataDiagnostic[] } { + const contract = contractNode as NodeRecord; + const stateVariables: ReturndataStateVariable[] = []; + const functions: ASTNode[] = []; + for (const member of contract.subNodes ?? []) { + const item = member as NodeRecord; + if (item.type === "StateVariableDeclaration") { + for (const raw of item.variables ?? []) { + const variable = raw as NodeRecord; + if (!variable.name) continue; + stateVariables.push({ + name: variable.name, + typeName: stringifyType(variable.typeName), + role: classifyVariable(variable.name), + location: nodeLocation(variable, file), + }); + } + } else if (item.type === "FunctionDefinition" && !item.isConstructor) { + functions.push(member); + } + } + const transitions: ReturndataTransition[] = []; + const diagnostics: ReturndataDiagnostic[] = []; + for (const fn of functions.slice(0, limits.maxFunctionsPerContract)) { + transitions.push(buildTransition(source, file, fn, limits)); + } + const base: ReturndataContractModel = { + name: contract.name ?? "", + file, + adapter: "none", + stateVariables: stateVariables.sort(byLocation), + transitions: transitions.sort(byLocation), + externalCalls: [], + assumptions: [], + location: nodeLocation(contract, file), + }; + base.externalCalls = transitions.flatMap((t) => + t.operations.filter((op) => op.kind === "call"), + ).sort((a, b) => a.order - b.order); + base.adapter = matchReturndataFramework(base).adapter; + base.assumptions = inferAssumptions(base); + return { model: base, diagnostics }; +} + +function buildTransition( + source: string, + file: string, + node: ASTNode, + limits: ReturndataAnalysisLimits, +): ReturndataTransition { + const fn = node as NodeRecord; + const operations: ReturndataOperation[] = []; + const guards: string[] = []; + let _truncated = false; + + walkNode(node, (child) => { + if (operations.length >= limits.maxOperationsPerFunction) { + _truncated = true; + return false; + } + const record = child as NodeRecord; + if (record.type === "FunctionCall") { + const isDecode = calledName(record.expression) === "abi.decode"; + if (isDecode) { + operations.push({ + order: record.range?.[0] ?? operations.length, + kind: "decode", + callKind: "unknown", + name: "abi.decode", + expression: compact(nodeSnippet(source, record)), + capturesReturn: true, + checksSuccess: false, + usesSafeWrapper: false, + location: nodeLocation(record, file), + }); + } else { + const callInfo = classifyCall(record, source); + if (callInfo) { + operations.push({ + order: record.range?.[0] ?? operations.length, + kind: "call", + callKind: callInfo.kind, + name: callInfo.name, + expression: compact(nodeSnippet(source, record)), + capturesReturn: callInfo.capturesReturn, + checksSuccess: callInfo.checksSuccess, + usesSafeWrapper: callInfo.usesSafeWrapper, + location: nodeLocation(record, file), + }); + } + } + } else if (record.type === "InlineAssemblyStatement" || record.type === "InLineAssemblyStatement") { + operations.push({ + order: record.range?.[0] ?? operations.length, + kind: "assembly", + callKind: "unknown", + name: "assembly", + expression: compact(nodeSnippet(source, record)), + capturesReturn: false, + checksSuccess: false, + usesSafeWrapper: false, + location: nodeLocation(record, file), + }); + } else if (record.type === "TryStatement") { + guards.push("try-catch"); + } else if (record.type === "IfStatement" && record.condition) { + const cond = compact(nodeSnippet(source, record.condition)); + operations.push({ + order: record.range?.[0] ?? operations.length, + kind: "guard", + callKind: "unknown", + name: "if", + expression: cond, + capturesReturn: false, + checksSuccess: /require\s*\(|revert|!\s*\w+|success/i.test(cond), + usesSafeWrapper: false, + location: nodeLocation(record, file), + }); + if (/success|require|revert/i.test(cond)) guards.push(cond); + } else if (record.type === "VariableDeclarationStatement" || record.type === "Assignment") { + const expr = (record as { initialValue?: ASTNode; expression?: ASTNode }).initialValue ?? + (record as { expression?: ASTNode }).expression ?? record.right; + if (expr && (expr as NodeRecord).type === "FunctionCall") { + const leftText = compact(nodeSnippet(source, record.left ?? record)); + if (/bool|success|=/.test(leftText)) { + const lastCall = operations[operations.length - 1]; + if (lastCall?.kind === "call") lastCall.capturesReturn = true; + } + } + } + return true; + }); + + const name = fn.name ?? ""; + const fnSource = nodeSnippet(source, fn); + const role = classifyFunction(name, operations, fnSource); + return { + name, + role, + visibility: fn.visibility ?? "default", + modifiers: (fn.modifiers ?? []).map(modifierName).filter(Boolean) as string[], + parameters: (fn.parameters ?? []).map((p) => (p as NodeRecord).name).filter(Boolean) as string[], + operations: operations.sort((a, b) => a.order - b.order), + location: nodeLocation(fn, file), + source: fnSource, + guards, + }; +} + +function classifyCall(record: NodeRecord, source: string): { + kind: CallKind; + name: string; + capturesReturn: boolean; + checksSuccess: boolean; + usesSafeWrapper: boolean; +} | null { + const expr = record.expression as NodeRecord | undefined; + if (!expr) return null; + const snippet = compact(nodeSnippet(source, record)); + const kind = classifyCallKind(snippet, expr); + if (kind === "unknown" && !/\.call|\.send|\.transfer|\.delegatecall|\.staticcall/i.test(snippet)) { + const name = calledName(expr); + if (!name || /^(require|assert|revert|emit|abi\.encode)/i.test(name)) return null; + return { kind: "interface-call", name, capturesReturn: false, checksSuccess: false, usesSafeWrapper: false }; + } + if (kind === "unknown") return null; + const usesSafeWrapper = /SafeERC20|Address\.functionCall|SafeCall|lowLevelCall/i.test(snippet); + return { + kind, + name: calledName(expr) ?? kind, + capturesReturn: false, + checksSuccess: usesSafeWrapper, + usesSafeWrapper, + }; +} + +function classifyVariable(name: string): ReturndataVariableRole { + const v = name.toLowerCase(); + if (/success|ok|result/.test(v)) return "success-flag"; + if (/returndata|returnData|data/.test(v)) return "return-buffer"; + if (/length|size/.test(v)) return "return-length"; + return "unknown"; +} + +function classifyFunction(name: string, operations: ReturndataOperation[], source: string): ReturndataFunctionRole { + const v = name.toLowerCase(); + if (/safetransfer|safeapprove|safeerc20/i.test(name)) return "safe-wrapper"; + if (/batch|multicall|aggregate|batchpay/i.test(v)) return "batch-operation"; + if (/try/.test(v) || operations.some((op) => op.expression.includes("try"))) return "try-catch-wrapper"; + if (operations.some((op) => op.kind === "decode")) return "abi-decode"; + if (operations.some((op) => op.kind === "assembly")) return "assembly-copy"; + if (operations.some((op) => op.callKind === "transfer" || op.callKind === "interface-call")) return "token-transfer"; + if (operations.some((op) => op.kind === "call")) return "external-call"; + return "unknown"; +} + +function isRelevant(model: ReturndataContractModel): boolean { + return model.externalCalls.length > 0 || + model.transitions.some((t) => t.role !== "unknown"); +} + +function inferAssumptions(model: ReturndataContractModel): string[] { + const assumptions: string[] = []; + if (model.externalCalls.some((c) => c.callKind === "interface-call")) { + assumptions.push("External tokens may be non-standard and omit return values"); + } + if (model.externalCalls.some((c) => c.callKind === "call")) { + assumptions.push("Low-level calls can fail silently without a success check"); + } + return assumptions; +} + +function collectNodes(root: ASTNode, type: string, signal?: ReturndataCancellationSignal): ASTNode[] { + const nodes: ASTNode[] = []; + walkNode(root, (node) => { + checkCancelled(signal); + if ((node as NodeRecord).type === type) nodes.push(node); + return true; + }); + return nodes; +} + +function walkNode(root: unknown, visitor: (node: ASTNode) => boolean): void { + const stack: unknown[] = [root]; + const seen = new WeakSet(); + while (stack.length) { + const value = stack.pop(); + if (!value || typeof value !== "object") continue; + if (Array.isArray(value)) { + for (let i = value.length - 1; i >= 0; i--) stack.push(value[i]); + continue; + } + if (seen.has(value)) continue; + seen.add(value); + if (!visitor(value as ASTNode)) continue; + const record = value as Record; + for (const key of Object.keys(record).filter((k) => k !== "loc" && k !== "range").sort().reverse()) { + stack.push(record[key]); + } + } +} + +function calledName(node: ASTNode | undefined, depth = 0): string | undefined { + if (!node || depth > 8) return undefined; + const value = node as NodeRecord; + if (value.name) return value.name; + if (value.memberName) return value.memberName; + return calledName(value.expression as ASTNode | undefined, depth + 1); +} + +function modifierName(node: ASTNode): string | undefined { + return (node as NodeRecord).name; +} + +function stringifyType(node: ASTNode | undefined): string { + if (!node) return "unknown"; + const type = node as NodeRecord; + if (typeof type.name === "string") return type.name; + if (typeof type.namePath === "string") return type.namePath; + if (typeof type.type === "string") return type.type; + return "unknown"; +} + +function nodeLocation(node: NodeRecord, file: string): ReturndataSourceLocation { + return { + file, + line: node.loc?.start?.line ?? 1, + column: (node.loc?.start?.column ?? 0) + 1, + ...(node.loc?.end?.line ? { lineEnd: node.loc.end.line } : {}), + }; +} + +function nodeSnippet(source: string, node: NodeRecord): string { + if (node.range) return source.slice(node.range[0], node.range[1] + 1); + const start = node.loc?.start?.line; + const end = node.loc?.end?.line; + return start && end ? source.split("\n").slice(start - 1, end).join("\n") : ""; +} + +function compact(value: string): string { + const result = value.replace(/\s+/g, " ").trim(); + return result.length > 280 ? `${result.slice(0, 277)}...` : result; +} + +function byLocation(a: T, b: T): number { + return a.location.line - b.location.line || (a.name ?? "").localeCompare(b.name ?? ""); +} + +function limited(code: string, message: string, file: string): BuildReturndataModelsResult { + return { models: [], diagnostics: [{ code, severity: "warning", message, location: { file, line: 1, column: 1 } }] }; +} + +function parseFailure(file: string, message: string): BuildReturndataModelsResult { + return { models: [], diagnostics: [{ code: "RTD_PARSE_ERROR", severity: "error", message, location: { file, line: 1, column: 1 } }] }; +} + +function sanitizeParseError(error: string | undefined): string { + return `Solidity source could not be parsed${error ? `: ${error.slice(0, 300)}` : ""}`; +} + +function checkCancelled(signal?: ReturndataCancellationSignal): void { + if (signal?.aborted) throw new ReturndataAnalysisCancelledError(); +} diff --git a/packages/core/src/returndata/rule.ts b/packages/core/src/returndata/rule.ts new file mode 100644 index 0000000..9fb0efa --- /dev/null +++ b/packages/core/src/returndata/rule.ts @@ -0,0 +1,23 @@ +import type { ASTNode, Finding } from "../types"; +import { analyzeReturndataSource } from "./api"; +import { toScanFinding } from "./slither-merge"; + +const RETURNDATA_PREFILTER = + /\.call\s*\(|\.send\s*\(|\.transfer\s*\(|\.delegatecall\s*\(|\.staticcall\s*\(|abi\.decode|returndatacopy|SafeERC20|safeTransfer|functionCall|transferFrom/i; + +/** Integrates returndata safety analysis into the ordinary ChainProof scan. */ +export function detectReturndataSafety( + _ast: ASTNode, + source: string, + filePath: string, +): Finding[] { + if (!RETURNDATA_PREFILTER.test(stripCommentsAndStrings(source))) return []; + const report = analyzeReturndataSource(source, filePath); + return report.files.flatMap((file) => + file.findings.map((finding) => toScanFinding(finding, filePath)), + ); +} + +function stripCommentsAndStrings(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n\r]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, " "); +} diff --git a/packages/core/src/returndata/serialize.ts b/packages/core/src/returndata/serialize.ts new file mode 100644 index 0000000..db0da4b --- /dev/null +++ b/packages/core/src/returndata/serialize.ts @@ -0,0 +1,80 @@ +import type { ReturndataAnalysisReport, ReturndataFinding } from "./types"; + +/** Deterministic, recursively key-sorted JSON suitable for versioned CI artifacts. */ +export function serializeReturndataReport(report: ReturndataAnalysisReport): string { + return JSON.stringify(sortValue(report), null, 2) + "\n"; +} + +/** Human-readable returndata report with evidence and explicit scope limitations. */ +export function generateReturndataMarkdown(report: ReturndataAnalysisReport): string { + const lines = [ + "# Returndata Safety Analysis", + "", + `Schema: \`${report.schemaVersion}\` `, + `Engine: \`${report.engineVersion}\``, + "", + "## Summary", + "", + `- Solidity files analyzed: ${report.summary.files}`, + `- Returndata contracts modeled: ${report.summary.contracts}`, + `- Findings: ${report.summary.total} (${report.summary.critical} critical, ${report.summary.high} high, ${report.summary.medium} medium, ${report.summary.low} low, ${report.summary.info} info)`, + `- Output truncated by a configured limit: ${report.summary.truncated ? "yes" : "no"}`, + "", + ]; + for (const file of report.files) { + lines.push(`## ${escapeMarkdown(file.file)}`, ""); + for (const diagnostic of file.diagnostics) { + lines.push(`> ${diagnostic.severity.toUpperCase()} ${diagnostic.code}: ${escapeMarkdown(diagnostic.message)}`, ""); + } + if (!file.findings.length) lines.push("No structural returndata findings.", ""); + for (const finding of file.findings) appendFinding(lines, finding); + } + lines.push( + "## Scope", + "", + "This report evaluates cross-chain returndata safety: message verification, domain separation, replay protection, " + + "validator thresholds, proof loops, token lock/mint and burn/release ordering, finality windows, and operational " + + "mitigations. It does not prove transport-layer authenticity, oracle correctness, or live-network finality.", + "", + ); + return lines.join("\n"); +} + +function appendFinding(lines: string[], finding: ReturndataFinding): void { + lines.push( + `### ${finding.ruleId}: ${escapeMarkdown(finding.title)}`, + "", + `**Severity:** ${finding.severity} `, + `**Confidence:** ${finding.confidence} `, + `**Contract:** \`${escapeMarkdown(finding.contract)}\` `, + `**Location:** \`${escapeMarkdown(finding.location.file)}:${finding.location.line}:${finding.location.column}\``, + "", + finding.description, + "", + `**Recommendation:** ${finding.recommendation}`, + "", + "**Evidence:**", + ); + for (const evidence of finding.evidence) { + lines.push(`- ${escapeMarkdown(evidence.description)} (${evidence.location.line}:${evidence.location.column})`); + } + if (finding.assumptions.length) { + lines.push("", "**Assumptions:**"); + for (const assumption of finding.assumptions) lines.push(`- ${escapeMarkdown(assumption)}`); + } + lines.push(""); +} + +function sortValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortValue); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, sortValue(child)])); + } + return value; +} + +function escapeMarkdown(value: string): string { + return value.replace(/[|]/g, "\\|").replace(/[\r\n]+/g, " "); +} diff --git a/packages/core/src/returndata/slither-merge.ts b/packages/core/src/returndata/slither-merge.ts new file mode 100644 index 0000000..d52731a --- /dev/null +++ b/packages/core/src/returndata/slither-merge.ts @@ -0,0 +1,77 @@ +import type { Finding } from "../types"; +import type { ReturndataFinding } from "./types"; + +/** Slither detector IDs related to unchecked return values. */ +const SLITHER_RETURN_DETECTORS = new Set([ + "unchecked-transfer", + "unchecked-lowlevel", + "unchecked-send", + "return-value", + "unused-return", +]); + +export interface SlitherReturnFinding { + id: string; + check: string; + impact: string; + confidence: string; + line: number; +} + +/** Merge equivalent Slither findings while preserving ChainProof evidence. */ +export function mergeSlitherReturnFindings( + chainproofFindings: ReturndataFinding[], + slitherFindings: SlitherReturnFinding[], +): ReturndataFinding[] { + const merged = [...chainproofFindings]; + const coveredLines = new Set(chainproofFindings.map((f) => f.location.line)); + + for (const slither of slitherFindings) { + if (!SLITHER_RETURN_DETECTORS.has(slither.check) && !/return|transfer|lowlevel|send/i.test(slither.check)) { + continue; + } + if (coveredLines.has(slither.line)) continue; + merged.push({ + ruleId: "CP-RTD-004", + title: `Slither: ${slither.check}`, + description: slither.impact, + recommendation: "Check the return value or use a SafeERC20/Address wrapper.", + severity: slither.impact.toLowerCase().includes("high") ? "high" : "medium", + confidence: slither.confidence === "High" ? "high" : "medium", + category: "slither-merge", + contract: "", + location: { file: "", line: slither.line, column: 1 }, + evidence: [{ kind: "adapter", description: `Slither detector: ${slither.check}`, location: { file: "", line: slither.line, column: 1 } }], + assumptions: ["Slither static analysis is available"], + optionalCall: false, + }); + coveredLines.add(slither.line); + } + return merged.sort((a, b) => a.location.line - b.location.line || a.ruleId.localeCompare(b.ruleId)); +} + +/** Convert ChainProof Finding to check for Slither overlap. */ +export function isSlitherEquivalent(cp: ReturndataFinding, slither: SlitherReturnFinding): boolean { + return Math.abs(cp.location.line - slither.line) <= 2 && + /return|transfer|call|send/i.test(slither.check); +} + +export function toScanFinding(finding: ReturndataFinding, filePath: string): Finding { + return { + id: finding.ruleId, + title: finding.title, + description: finding.description, + recommendation: finding.recommendation, + severity: finding.severity, + file: filePath, + line: finding.location.line, + lineEnd: finding.location.lineEnd, + confidence: finding.confidence, + assumptions: finding.assumptions, + evidence: finding.evidence.map((e) => ({ + description: e.description + (finding.optionalCall ? " (optional call)" : ""), + file: e.location.file, + line: e.location.line, + })), + }; +} diff --git a/packages/core/src/returndata/types.ts b/packages/core/src/returndata/types.ts new file mode 100644 index 0000000..7f8a2e5 --- /dev/null +++ b/packages/core/src/returndata/types.ts @@ -0,0 +1,194 @@ +import type { Severity } from "../types"; + +export const RETURNDATA_REPORT_SCHEMA_VERSION = "1.0.0" as const; +export const RETURNDATA_CONFIG_SCHEMA_VERSION = 1 as const; + +export type ReturndataRuleId = + | "CP-RTD-001" | "CP-RTD-002" | "CP-RTD-003" | "CP-RTD-004" + | "CP-RTD-005" | "CP-RTD-006" | "CP-RTD-007" | "CP-RTD-008" + | "CP-RTD-009" | "CP-RTD-010" | "CP-RTD-011" | "CP-RTD-012" + | "CP-RTD-013" | "CP-RTD-014" | "CP-RTD-015" | "CP-RTD-016"; + +export type CallKind = + | "call" | "callcode" | "delegatecall" | "staticcall" + | "send" | "transfer" | "interface-call" | "unknown"; + +export type ReturndataVariableRole = + | "success-flag" | "return-buffer" | "return-length" | "decoded-value" + | "token-balance" | "allowance" | "unknown"; + +export type ReturndataFunctionRole = + | "external-call" | "token-transfer" | "token-transferFrom" + | "abi-decode" | "assembly-copy" | "multicall" | "try-catch-wrapper" + | "safe-wrapper" | "batch-operation" | "unknown"; + +export type ReturndataFrameworkAdapter = + | "safe-erc20-wrapper" | "address-utilities" | "assembly-wrapper" + | "multicall-batch" | "try-catch-guarded" | "generic-external-call" | "none"; + +export interface ReturndataFrameworkAdapterDefinition { + id: Exclude; + displayName: string; + requiredPatterns: string[]; + mitigations: string[]; + limitations: string[]; +} + +export interface ReturndataFrameworkMatch { + adapter: ReturndataFrameworkAdapter; + matchedPatterns: string[]; +} + +export interface ReturndataSourceLocation { + file: string; + line: number; + column: number; + lineEnd?: number; + columnEnd?: number; +} + +export interface ReturndataEvidence { + kind: + | "call-site" | "return-check" | "decode-site" | "guard" | "wrapper" + | "absence" | "taint-flow" | "batch-item" | "assembly" | "adapter"; + description: string; + location: ReturndataSourceLocation; + snippet?: string; +} + +export interface ReturndataStateVariable { + name: string; + typeName: string; + role: ReturndataVariableRole; + location: ReturndataSourceLocation; +} + +export interface ReturndataOperation { + order: number; + kind: "call" | "assignment" | "guard" | "decode" | "assembly" | "throw"; + callKind: CallKind; + name: string; + expression: string; + capturesReturn: boolean; + checksSuccess: boolean; + usesSafeWrapper: boolean; + location: ReturndataSourceLocation; +} + +export interface ReturndataTransition { + name: string; + role: ReturndataFunctionRole; + visibility: string; + modifiers: string[]; + parameters: string[]; + operations: ReturndataOperation[]; + location: ReturndataSourceLocation; + source: string; + guards: string[]; +} + +export interface ReturndataContractModel { + name: string; + file: string; + adapter: ReturndataFrameworkAdapter; + stateVariables: ReturndataStateVariable[]; + transitions: ReturndataTransition[]; + externalCalls: ReturndataOperation[]; + assumptions: string[]; + location: ReturndataSourceLocation; +} + +export interface ReturndataFinding { + ruleId: ReturndataRuleId; + title: string; + description: string; + recommendation: string; + severity: Severity; + confidence: "high" | "medium" | "low"; + category: string; + contract: string; + location: ReturndataSourceLocation; + evidence: ReturndataEvidence[]; + assumptions: string[]; + optionalCall: boolean; +} + +export interface ReturndataDiagnostic { + code: string; + severity: "error" | "warning" | "info"; + message: string; + location?: ReturndataSourceLocation; +} + +export interface ReturndataAnalysisLimits { + maxSourceBytes: number; + maxFiles: number; + maxContracts: number; + maxFunctionsPerFile: number; + maxFunctionsPerContract: number; + maxOperationsPerFunction: number; + maxFindings: number; + maxEvidencePerFinding: number; +} + +export interface ReturndataCancellationSignal { aborted?: boolean; } + +export interface ReturndataAnalysisOptions { + limits?: Partial; + includeRules?: ReturndataRuleId[]; + excludeRules?: ReturndataRuleId[]; + includeModels?: boolean; + signal?: ReturndataCancellationSignal; + mergeSlither?: boolean; +} + +export interface ReturndataSourceInput { file: string; source: string; } + +export interface ReturndataFileAnalysis { + file: string; + findings: ReturndataFinding[]; + diagnostics: ReturndataDiagnostic[]; + models?: ReturndataContractModel[]; +} + +export interface ReturndataAnalysisReport { + schemaVersion: typeof RETURNDATA_REPORT_SCHEMA_VERSION; + engineVersion: string; + files: ReturndataFileAnalysis[]; + summary: { + files: number; + contracts: number; + critical: number; + high: number; + medium: number; + low: number; + info: number; + total: number; + truncated: boolean; + }; +} + +export interface ReturndataAnalysisConfigV1 { + schemaVersion: typeof RETURNDATA_CONFIG_SCHEMA_VERSION; + limits?: Partial; + includeModels?: boolean; + includeRules?: ReturndataRuleId[]; + excludeRules?: ReturndataRuleId[]; + mergeSlither?: boolean; +} + +export interface ReturndataAnalysisConfigV0 { + schemaVersion?: 0; + version?: 0; + maxFileSize?: number; + maxIssues?: number; + detectors?: ReturndataRuleId[]; + includeModels?: boolean; +} + +export type ReturndataAnalysisConfigInput = ReturndataAnalysisConfigV1 | ReturndataAnalysisConfigV0; + +export interface ValidatedReturndataConfig { + config: ReturndataAnalysisConfigV1; + diagnostics: ReturndataDiagnostic[]; +} diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index 7a162a5..3aa4c7d 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -30,6 +30,7 @@ import { detectVaultInflation } from "./rules/cp122-vault-inflation"; import { detectCallbackReentrancy } from "./rules/callback-analysis"; import { detectStakingAccounting } from "./staking"; import { detectGovernanceSafety } from "./governance"; +import { detectReturndataSafety } from "./returndata"; import { RuleOptions } from "./rules/rule-context"; import { detectGasIssues } from "./rules/gas-optimizer"; import { enhanceFindingsWithLLM } from "./llm/enhancer"; @@ -184,6 +185,9 @@ async function scanFile( // here rather than once per merged inheritance view, which would duplicate findings. findings.push(...detectGovernanceSafety(ast, source, filePath)); + // Returndata safety analysis runs once per physical file. + findings.push(...detectReturndataSafety(ast, source, filePath)); + if (config.plugins) { for (const plugin of config.plugins) { for (const rule of plugin.rules) { diff --git a/packages/server/src/rules-registry.ts b/packages/server/src/rules-registry.ts index 31dce59..84063ec 100644 --- a/packages/server/src/rules-registry.ts +++ b/packages/server/src/rules-registry.ts @@ -152,6 +152,15 @@ export const RULES: RuleMeta[] = [ "If the input to keccak256() is a compile-time constant, precompute it as a " + "constant bytes32 to save ~30 gas per call.", }, + { + id: "CP-RTD-001", + title: "Ignored external call success flag", + severity: "high", + category: "security", + description: + "Detects .call(), .send(), .delegatecall(), and .staticcall() invocations whose success " + + "boolean is not captured or checked, allowing silent failure.", + }, { id: "GAS-SMALL-UINT", title: "Small integer type in storage",