From beaf3afa8bd6fb07c6a8f46cd76f6b1816112396 Mon Sep 17 00:00:00 2001 From: Retkatmun Date: Sat, 29 Aug 2026 19:21:49 +0100 Subject: [PATCH] feat(validation): fork-aware concrete validation and exploit reproduction harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full validation engine described in issue #92: Core engine (packages/core/src/validation/): - types.ts: versioned ValidationScenario, ChainContext, AccountSpec, ContractSpec, CallSpec, StorageAssertion, BalanceAssertion, EventAssertion, ValidationResult, ValidationReport, MinimizationResult, ValidationPlan, typed errors (ValidationError, ValidationTimeoutError, AdapterCrashError, ForkUnavailableError, CorruptBundleError, ScenarioValidationError), createCancellationSignal, resolveResourceLimits, sanitizeErrorMessage - adapter.ts: EvmAdapter interface + shared JSON-RPC utilities (jsonRpcCall, waitForRpc, encodeFunctionCall, keccak256Selector, keccak256Pure, decodeLogEntries, normalizeHex, hexToDecimalString) - anvil-adapter.ts: AnvilAdapter — process-isolated Anvil backend with fork, snapshot/revert, storage/balance override, bounded resources - hardhat-adapter.ts: HardhatAdapter — process-isolated Hardhat Network backend with equivalent capability surface - scaffold.ts: planValidation translator — static Finding → ValidationScenario scaffolds for CP-107, CP-115, CP-101, CP-104, CP-122, CP-CB-* families; serializeValidationPlan / parseValidationPlan with corruption detection - runner.ts: ValidationRunner (account setup, deploy, ordered call execution, snapshot/replay, storage/balance/event assertion evaluation, outcome classification); minimizeScenario (greedy backward elimination); runValidationPlan (per-scenario process isolation, batch report assembly); sanitizeScenario (strips private keys and fork URLs before persistence) - report.ts: serializeValidationReport (deterministic JSON), parseValidationReport (schema + corruption check), generateValidationMarkdown, generateValidationResultMarkdown CLI (packages/cli/src/commands/validate.ts): - chainproof validate plan — translate scan JSON → ValidationPlan - chainproof validate run — execute plan/scenario against Anvil or Hardhat - chainproof validate replay — restore snapshot and re-run - chainproof validate minimize — greedy call minimization - chainproof validate report — reformat saved report as JSON or Markdown Integrations: - packages/core/src/index.ts: exports all validation public APIs - packages/cli/src/cli.ts: registers validate command - packages/server/src/routes/validate.ts + server.ts: POST /validate/plan, POST /validate/run, GET /validate/report/:id REST endpoints - packages/github-action/action.yml + action.ts: validate-plan and validate-run steps, fail-on-validation-failure gate - packages/vscode-extension: validate commands and result display Fixtures and documentation: - examples/contracts/validation/: ValidationVulnerableVault.sol, ValidationSecureVault.sol, ValidationReentrantAttacker.sol - docs/validation.md: architecture, threat model, limitations, configuration, migration, troubleshooting Tests (461 pass, 0 lint errors): - packages/core/src/__tests__/validation.test.ts: types, utilities, mock-adapter runner, scaffold, minimizer, serialization round-trips, cancellation, fixture file checks, scanner integration - packages/core/src/__tests__/validation-adversarial.test.ts: fork unavailability, adapter crash, malicious input, error sanitization, replay integrity, boundary conditions - packages/cli/src/__tests__/validate.test.ts: all five subcommands via compiled binary, offline adapter guard for integration path Lint fixes applied across validation/runner.ts, validation/scaffold.ts, governance/__tests__/api.test.ts, plugins.ts to resolve all 13 ESLint errors (no-var-requires, no-regex-spaces) introduced by this PR. Closes #92 --- docs/validation.md | 782 +++++++++++++++ .../ValidationReentrantAttacker.sol | 76 ++ .../validation/ValidationSecureVault.sol | 85 ++ .../validation/ValidationVulnerableVault.sol | 57 ++ packages/cli/src/__tests__/validate.test.ts | 727 ++++++++++++++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/validate.ts | 481 +++++++++ .../__tests__/validation-adversarial.test.ts | 540 ++++++++++ .../core/src/__tests__/validation.test.ts | 920 ++++++++++++++++++ .../core/src/governance/__tests__/api.test.ts | 2 +- packages/core/src/index.ts | 80 ++ packages/core/src/plugins.ts | 1 + packages/core/src/validation/adapter.ts | 355 +++++++ packages/core/src/validation/anvil-adapter.ts | 458 +++++++++ .../core/src/validation/hardhat-adapter.ts | 409 ++++++++ packages/core/src/validation/index.ts | 116 +++ packages/core/src/validation/report.ts | 224 +++++ packages/core/src/validation/runner.ts | 648 ++++++++++++ packages/core/src/validation/scaffold.ts | 616 ++++++++++++ packages/core/src/validation/types.ts | 709 ++++++++++++++ packages/github-action/action.yml | 19 + packages/github-action/src/action.ts | 76 ++ packages/server/src/routes/validate.ts | 239 +++++ packages/server/src/server.ts | 5 + packages/vscode-extension/package.json | 4 + packages/vscode-extension/src/extension.ts | 101 ++ 26 files changed, 7731 insertions(+), 1 deletion(-) create mode 100644 docs/validation.md create mode 100644 examples/contracts/validation/ValidationReentrantAttacker.sol create mode 100644 examples/contracts/validation/ValidationSecureVault.sol create mode 100644 examples/contracts/validation/ValidationVulnerableVault.sol create mode 100644 packages/cli/src/__tests__/validate.test.ts create mode 100644 packages/cli/src/commands/validate.ts create mode 100644 packages/core/src/__tests__/validation-adversarial.test.ts create mode 100644 packages/core/src/__tests__/validation.test.ts create mode 100644 packages/core/src/validation/adapter.ts create mode 100644 packages/core/src/validation/anvil-adapter.ts create mode 100644 packages/core/src/validation/hardhat-adapter.ts create mode 100644 packages/core/src/validation/index.ts create mode 100644 packages/core/src/validation/report.ts create mode 100644 packages/core/src/validation/runner.ts create mode 100644 packages/core/src/validation/scaffold.ts create mode 100644 packages/core/src/validation/types.ts create mode 100644 packages/server/src/routes/validate.ts diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..4e14875 --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,782 @@ +# ChainProof Concrete Validation Guide + +Fork-aware concrete validation and exploit reproduction harness for ChainProof. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Threat Model](#threat-model) +- [Quick Start](#quick-start) +- [CLI Reference](#cli-reference) +- [API Reference](#api-reference) +- [Validation Scenarios](#validation-scenarios) +- [EVM Adapters](#evm-adapters) +- [Report Format](#report-format) +- [Security Boundaries](#security-boundaries) +- [Limitations](#limitations) +- [Configuration](#configuration) +- [Troubleshooting](#troubleshooting) +- [Compatibility](#compatibility) +- [Migration](#migration) + +--- + +## Overview + +ChainProof's static analysis pipeline produces `Finding` objects with severity, file, and line information. The validation engine bridges the gap between a static finding and concrete EVM execution: + +1. **Plan** — translate static findings into parameterized `ValidationScenario` scaffolds +2. **Run** — execute scenarios against a process-isolated EVM backend (Anvil or Hardhat Network) +3. **Replay** — restore a snapshot and re-execute deterministically +4. **Minimize** — remove redundant calls while preserving the outcome +5. **Report** — produce structured JSON or Markdown output + +This is **finding validation**, not coverage correlation or AI-generated testing. The engine does not claim a finding is automatically exploitable — it provides a reproducible scaffold for a researcher to confirm or refute. + +--- + +## Architecture + +``` +Static Findings (Finding[]) + │ + ▼ + planValidation() ← scaffold.ts + │ + ▼ + ValidationScenario[] ← types.ts + │ + ▼ + runValidationPlan() ← runner.ts + │ │ + ▼ ▼ +AnvilAdapter HardhatAdapter ← anvil-adapter.ts / hardhat-adapter.ts + │ │ + ▼ ▼ + JSON-RPC (eth_sendTransaction, evm_snapshot, ...) + │ + ▼ + ValidationResult[] + │ + ▼ + ValidationReport ← types.ts + │ + ▼ + JSON / Markdown ← report.ts +``` + +### Key design decisions + +- **Process isolation**: each scenario runs in a fresh EVM process that is killed after completion. No state leaks between scenarios. +- **No external dependencies**: the JSON-RPC client is implemented using Node's built-in `http` module. The keccak-256 implementation is pure JavaScript. No ethers.js or web3.js is required. +- **Determinism**: scenarios pin `chainId`, `forkBlockNumber`, and optionally `timestamp`. The same scenario produces byte-identical results on the same adapter version. +- **Bounded execution**: adapters enforce `timeoutMs`, `maxCalls`, and `maxGasPerCall`. Exceeding these limits produces a `ValidationTimeoutError` or `RESOURCE_EXCEEDED` error, never a hang. +- **Portable bundles**: `ValidationResult` and `ValidationReport` contain everything needed to understand the run. Private keys and fork URLs are never serialized. + +--- + +## Threat Model + +### Assets + +| Asset | Description | +|-------|-------------| +| Fork URL / RPC credentials | Passed transiently to the adapter; never persisted | +| Private keys | Accepted for devnet accounts; never serialized into bundles | +| Local file paths | Scenario sources reference relative paths; sanitized in error messages | +| Deployed bytecode | Embedded verbatim in scenarios; treat as untrusted if sourced externally | + +### Adversary capabilities + +The threat model considers two adversaries: + +1. **Malicious scenario file** — A user is tricked into running a crafted `ValidationScenario` JSON that contains adversarial bytecode or calls targeting the researcher's machine. **Mitigation**: the EVM adapter runs as an isolated child process. Bytecode execution is sandboxed within the EVM. The adapter has no ability to read host files. + +2. **Malicious RPC endpoint** — A fork URL resolves to an adversarially-controlled server that returns crafted responses. **Mitigation**: the JSON-RPC client only makes outbound HTTP POST requests. It does not follow redirects. RPC responses are parsed as JSON; no eval or exec paths are taken. + +### Assumptions + +- The researcher controls the `forkUrl` they supply. ChainProof does not validate or denylist RPC endpoints. +- Adapter binaries (`anvil`, `npx hardhat`) are trusted; they must be sourced from trusted package managers. +- The EVM adapter process runs with the same OS user privileges as the ChainProof process. Scenarios should not be run as root. +- Network isolation (network namespaces, `iptables`) is the responsibility of the CI operator, not ChainProof. + +### What is NOT in scope + +- Cross-contract reentrancy tracing across separately-deployed protocols +- Symbolic execution or formal verification +- Automated exploitation — scenarios are scaffolds, not exploits +- Live mainnet state beyond the pinned fork block + +--- + +## Quick Start + +### 1. Scan and save findings as JSON + +```bash +chainproof scan contracts/ --format json --output findings.json +``` + +### 2. Generate validation scaffolds + +```bash +chainproof validate plan findings.json --output validation-plan.json +``` + +### 3. Run scenarios (requires Anvil or Hardhat Network) + +```bash +# With Anvil (Foundry) +chainproof validate run validation-plan.json --adapter anvil --output report.json + +# With Hardhat Network +chainproof validate run validation-plan.json --adapter hardhat --output report.json +``` + +### 4. Format the report + +```bash +chainproof validate report report.json --format markdown +``` + +### 5. Programmatic usage + +```typescript +import { scan } from '@chainproof/core'; +import { + planValidation, + runValidationPlan, + generateValidationMarkdown, +} from '@chainproof/core'; + +const scanResult = await scan({ targets: ['contracts/'], useSlither: false, useLLM: false, useMetrics: false }); +const findings = scanResult.files.flatMap(f => f.findings); + +const plan = planValidation(findings, { minSeverity: 'high' }); +const report = await runValidationPlan(plan.scenarios, { adapterType: 'anvil' }); +console.log(generateValidationMarkdown(report)); +``` + +--- + +## CLI Reference + +### `chainproof validate plan ` + +Translate static findings from a JSON scan result into reproduction scenario scaffolds. + +| Option | Default | Description | +|--------|---------|-------------| +| `--output ` | stdout | Write the `ValidationPlan` JSON to a file | +| `--min-severity ` | `low` | Only scaffold findings at or above this severity | +| `--deduplicate-by-file` | off | Deduplicate by `(id, file, line)` instead of `(id, file)` | +| `--format ` | `json` | Output format: `json` or `table` | + +**Output**: a `ValidationPlan` JSON object containing `scenarios[]` and `unsupportedFindings[]`. + +**Notes**: +- Gas findings are always excluded. +- Findings with unsupported IDs appear in `unsupportedFindings` with an explanation. +- Scenario scaffolds contain placeholder bytecode (`0x`). You must supply real compiled bytecode before running. + +--- + +### `chainproof validate run ` + +Execute scenarios against an EVM backend. Accepts a `ValidationPlan` JSON file or a single `ValidationScenario` JSON file. + +| Option | Default | Description | +|--------|---------|-------------| +| `--adapter ` | auto | `anvil` or `hardhat`; auto-detected if omitted | +| `--adapter-bin ` | `$PATH` | Explicit binary path | +| `--fork-url ` | scenario chain | Fork RPC URL | +| `--fork-block ` | latest | Fork block number | +| `--chain-id ` | scenario chain | Chain ID | +| `--timeout ` | `30000` | Per-scenario timeout in milliseconds | +| `--output ` | stdout | Write `ValidationReport` JSON to file | +| `--format ` | `json` | `json` or `markdown` | +| `--fail-on-failure` | off | Exit 1 if any scenario fails or errors | + +**Exit codes**: +- `0` — all scenarios passed (or `--fail-on-failure` not set) +- `1` — one or more scenarios failed/errored (with `--fail-on-failure`) +- `2` — infrastructure error (adapter not found, corrupt plan file) + +--- + +### `chainproof validate replay ` + +Restore state from a previous result and re-run the scenario from scratch. + +| Option | Default | Description | +|--------|---------|-------------| +| `--adapter ` | from result | `anvil` or `hardhat` | +| `--adapter-bin ` | `$PATH` | Explicit binary path | +| `--fork-url ` | — | Required if the original scenario used a fork | +| `--output ` | stdout | Write the replay result | +| `--format ` | `json` | `json` or `markdown` | + +**Note**: `forkUrl` is not persisted in results (it is replaced with `[redacted]`). You must re-supply it with `--fork-url` to replay a forked scenario. + +--- + +### `chainproof validate minimize ` + +Remove redundant calls from a scenario while preserving the outcome. + +| Option | Default | Description | +|--------|---------|-------------| +| `--adapter ` | auto | `anvil` or `hardhat` | +| `--adapter-bin ` | `$PATH` | Explicit binary path | +| `--fork-url ` | scenario | Fork RPC URL | +| `--max-trials ` | `50` | Maximum EVM re-executions | +| `--output ` | stdout | Write the minimized scenario | + +**Algorithm**: greedy backward elimination. Tries removing each call from the end of the list first; keeps the removal if the outcome still matches. Stops when the trial budget is exhausted. + +--- + +### `chainproof validate report ` + +Format a saved `ValidationReport` as Markdown or JSON. + +| Option | Default | Description | +|--------|---------|-------------| +| `--format ` | `markdown` | `json` or `markdown` | +| `--output ` | stdout | Write to file | +| `--fail-on-failure` | off | Exit 1 if any scenario failed | + +--- + +## API Reference + +### Types + +#### `ValidationScenario` + +The core unit of work. Describes the full EVM state needed to reproduce a finding. + +```typescript +interface ValidationScenario { + schemaVersion: string; // e.g. "1.0.0" + id: string; // e.g. "scenario-CP-107-Vault-withdraw-a1b2c3d4" + title: string; + description?: string; + findingId?: string; // e.g. "CP-107" + findingFile?: string; + findingLine?: number; + chain: ChainContext; // chainId, forkUrl, forkBlockNumber, timestamp + accounts: AccountSpec[]; // funded accounts + contracts: ContractSpec[]; // contracts to deploy + calls: CallSpec[]; // ordered transactions + storageAssertions?: StorageAssertion[]; + balanceAssertions?: BalanceAssertion[]; + eventAssertions?: EventAssertion[]; + expectedOutcome: "exploit-succeeds" | "exploit-reverts" | "secure-baseline" | "custom"; + limits?: ScenarioResourceLimits; + tags?: string[]; + createdAt?: string; +} +``` + +#### `ValidationResult` + +The complete result of executing one `ValidationScenario`. Safe to serialize. + +#### `ValidationReport` + +Aggregate report covering multiple results. + +### Functions + +#### `planValidation(findings, opts?)` + +```typescript +function planValidation(findings: Finding[], opts?: PlanValidationOptions): ValidationPlan +``` + +Translates static findings into scenario scaffolds. + +#### `runValidationPlan(scenarios, opts?)` + +```typescript +async function runValidationPlan( + scenarios: ValidationScenario[], + opts?: RunValidationOptions, +): Promise +``` + +Runs scenarios, managing adapter lifecycle automatically. Each scenario gets a fresh adapter process. + +#### `minimizeScenario(scenario, adapter, opts?)` + +```typescript +async function minimizeScenario( + scenario: ValidationScenario, + adapter: EvmAdapter, + opts?: MinimizerOptions, +): Promise +``` + +#### `generateValidationMarkdown(report)` + +```typescript +function generateValidationMarkdown(report: ValidationReport): string +``` + +#### `serializeValidationReport(report)` + +```typescript +function serializeValidationReport(report: ValidationReport): string +``` + +Deterministic JSON serialization. + +### Errors + +| Class | Code | When thrown | +|-------|------|-------------| +| `ValidationError` | various | Base class | +| `ValidationTimeoutError` | `TIMEOUT` | Scenario exceeded time limit | +| `AdapterCrashError` | `ADAPTER_CRASH` | EVM process crashed | +| `ForkUnavailableError` | `FORK_UNAVAILABLE` | Fork RPC unreachable | +| `CorruptBundleError` | `CORRUPT_BUNDLE` | Invalid/unsupported JSON file | +| `ScenarioValidationError` | `SCENARIO_INVALID` | Schema validation failure | + +All errors sanitize their messages to remove file paths, URLs, and long hex strings. + +--- + +## Validation Scenarios + +### Scenario schema + +```json +{ + "schemaVersion": "1.0.0", + "id": "scenario-CP-107-Vault-withdraw-a1b2c3d4", + "title": "Reentrancy scaffold: Vault.sol L42", + "findingId": "CP-107", + "findingFile": "contracts/Vault.sol", + "findingLine": 42, + "chain": { "chainId": 31337 }, + "accounts": [ + { "address": "0xf39...", "balance": "10000000000000000000", "label": "deployer" }, + { "address": "0x709...", "balance": "10000000000000000000", "label": "attacker" } + ], + "contracts": [ + { + "name": "Vault", + "bytecode": "0x608060405234801561001...", + "abi": "[{\"type\":\"function\",\"name\":\"deposit\"...}]", + "deployer": "deployer" + }, + { + "name": "Attacker", + "bytecode": "0x608060405234801561001...", + "abi": "[...]", + "deployer": "attacker" + } + ], + "calls": [ + { + "to": "Vault", + "signature": "deposit()", + "value": "1000000000000000000", + "from": "deployer", + "description": "Victim deposits 1 ETH" + }, + { + "to": "Attacker", + "signature": "attack()", + "from": "attacker", + "description": "Attacker triggers reentrancy" + } + ], + "balanceAssertions": [ + { + "account": "Attacker", + "op": "gt", + "value": "1000000000000000000", + "description": "Attacker gained ETH" + } + ], + "expectedOutcome": "exploit-succeeds" +} +``` + +### Supplying bytecode + +Scaffold scenarios contain placeholder bytecode (`0x`). To execute them: + +1. Compile the contract with `solc` or Foundry/Hardhat +2. Extract the deployment bytecode from the artifact +3. Replace `"bytecode": "0x"` with the actual bytecode in the scenario JSON + +```bash +# Using solc +solc --bin contracts/Vault.sol | grep -A1 "Binary:" | tail -1 +``` + +```bash +# Using Foundry +forge build && cat out/Vault.sol/Vault.json | jq -r '.bytecode.object' +``` + +### Storage overrides (fork mode) + +When using a fork, you can pre-set storage slots to bypass initialization or set up specific state: + +```json +{ + "contracts": [ + { + "name": "Vault", + "address": "0x1234...existing_vault...", + "storageOverrides": { + "0x0000000000000000000000000000000000000000000000000000000000000000": "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266" + } + } + ] +} +``` + +--- + +## EVM Adapters + +### Anvil (Foundry) + +Anvil is the preferred adapter. It is faster, supports `debug_traceTransaction` for detailed call traces, and provides `anvil_setStorageAt` for precise state manipulation. + +**Install**: +```bash +curl -L https://foundry.paradigm.xyz | bash +foundryup +anvil --version +``` + +**Fork mode**: +```bash +chainproof validate run plan.json \ + --adapter anvil \ + --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY \ + --fork-block 19000000 +``` + +### Hardhat Network + +Hardhat Network is available whenever Hardhat is installed. It is slightly slower to start but requires no additional install if the project already uses Hardhat. + +**Install**: +```bash +npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox +``` + +**Use**: +```bash +chainproof validate run plan.json --adapter hardhat +``` + +### Adapter availability detection + +The CLI auto-detects adapters in order: Anvil first, then Hardhat. To force a specific adapter: + +```bash +chainproof validate run plan.json --adapter anvil +chainproof validate run plan.json --adapter hardhat +``` + +--- + +## Report Format + +### JSON report (`ValidationReport`) + +```json +{ + "schemaVersion": "1.0.0", + "timestamp": "2024-01-15T12:00:00.000Z", + "total": 3, + "passed": 2, + "failed": 1, + "errored": 0, + "adapterType": "anvil", + "totalDurationMs": 45000, + "results": [ + { + "schemaVersion": "1.0.0", + "scenario": { ... }, + "adapterType": "anvil", + "adapterVersion": "anvil/0.2.0", + "snapshotId": "snap-1", + "snapshotBlock": 100, + "callResults": [ + { + "callIndex": 0, + "reverted": false, + "returnData": "0x", + "gasUsed": 21000, + "logs": [], + "storageDiff": [] + } + ], + "outcomeMatched": true, + "outcomeSummary": "Exploit scenario completed without reverts and all assertions passed", + "storageAssertionResults": [], + "balanceAssertionResults": [], + "eventAssertionResults": [], + "totalGasUsed": 21000, + "startedAt": "2024-01-15T12:00:00.000Z", + "completedAt": "2024-01-15T12:00:15.000Z", + "durationMs": 15000, + "warnings": [] + } + ] +} +``` + +--- + +## Security Boundaries + +### What is safe to share + +- Serialized `ValidationPlan` files (no secrets; bytecode is researcher-supplied) +- Serialized `ValidationReport` files (no secrets; fork URLs are redacted) +- Minimized scenarios + +### What is NOT safe to share + +- Scenarios with real private keys in `accounts[].privateKey` (use labels instead) +- Fork URLs embedded in scenario `chain.forkUrl` (redacted automatically on serialization) + +### Input validation + +- Scenario call counts are bounded by `maxCalls` (default: 100) +- Gas per call is bounded by `maxGasPerCall` (default: 30M) +- Log entries captured per call are bounded by `maxLogs` (default: 1,000) +- Total execution time is bounded by `timeoutMs` (default: 30s) + +### Process isolation + +Each scenario runs in a fresh EVM process. The process is killed (SIGTERM then SIGKILL) when the scenario completes or times out. No EVM state persists between scenarios. + +--- + +## Limitations + +1. **Scaffold placeholders**: generated scenarios contain `bytecode: "0x"`. You must compile and supply real bytecode. ChainProof does not have a Solidity compiler dependency. + +2. **No multi-hop cross-contract analysis**: the runner executes the calls you provide. It does not automatically trace reentrancy across separately-deployed contracts not listed in `scenario.contracts`. + +3. **No symbolic execution**: the engine executes concrete inputs. It does not search for inputs that trigger a vulnerability. + +4. **Fork determinism**: fork mode is only deterministic if `forkBlockNumber` is pinned. Without it, the adapter fetches latest, which changes between runs. + +5. **Gas estimation**: `gasUsed` values are EVM measurements. They depend on the adapter implementation and may differ between Anvil and Hardhat for the same scenario. + +6. **ABI encoding**: the built-in encoder supports `uint256`, `int256`, `address`, `bool`, `bytes`, `bytes32`, and `string` value types. For complex types (arrays, tuples, structs), supply pre-encoded `calldata` directly. + +7. **Snapshot consumption**: `evm_revert` consumes the snapshot in most EVM implementations. The runner re-takes a snapshot after revert so that replays work, but the original `snapshotId` in a `ValidationResult` is not guaranteed to be reusable after the adapter process restarts. + +8. **Windows compatibility**: the adapter process management uses POSIX signals (`SIGTERM`, `SIGKILL`). On Windows, process termination uses `process.kill()` which may behave differently. + +--- + +## Configuration + +### Per-scenario resource limits + +```json +{ + "schemaVersion": "1.0.0", + "id": "...", + "limits": { + "timeoutMs": 60000, + "maxCalls": 20, + "maxGasPerCall": 15000000, + "maxLogs": 500, + "maxMemoryBytes": 268435456 + }, + ... +} +``` + +### Global limits via CLI + +```bash +chainproof validate run plan.json --timeout 60000 +``` + +### Default limits + +| Limit | Default | +|-------|---------| +| `timeoutMs` | 30,000 ms | +| `maxCalls` | 100 | +| `maxGasPerCall` | 30,000,000 | +| `maxLogs` | 1,000 | +| `maxMemoryBytes` | 512 MB | + +--- + +## Troubleshooting + +### `No EVM adapter found` + +Install Foundry or Hardhat: + +```bash +# Foundry (Anvil) +curl -L https://foundry.paradigm.xyz | bash && foundryup + +# Hardhat +npm install --save-dev hardhat +``` + +Or specify the binary path explicitly: + +```bash +chainproof validate run plan.json --adapter-bin /usr/local/bin/anvil +``` + +### `Fork RPC unavailable` + +- Verify the RPC URL is correct and the provider is up +- Use `--fork-url` to supply the URL (it is not stored in plan files) +- Check that the RPC endpoint allows `eth_getBlockByNumber` and `eth_getStorageAt` + +### `Adapter crashed` + +- Check that no other process is using the same port +- Increase the timeout: `--timeout 60000` +- Enable verbose output: `--verbosity 2` (if supported by the adapter CLI) + +### `Scenario exceeded time limit` + +- Increase `--timeout` for complex scenarios +- Use `chainproof validate minimize` to reduce the number of calls + +### `Transaction not mined within Xms` + +Anvil is configured in `--no-mining` mode for determinism. The adapter mines a block after each transaction. If mining fails, check that the adapter process is still running (not killed by the watchdog timer). + +### `Deployment of X produced no contractAddress` + +The bytecode is invalid or the deployment reverted. Check: +1. The bytecode is correct (0x-prefixed, non-empty) +2. The deployer has sufficient balance +3. The constructor arguments are ABI-encoded correctly + +--- + +## Compatibility + +| Component | Minimum version | +|-----------|----------------| +| Node.js | 18.0.0 | +| Anvil (Foundry) | 0.1.0 | +| Hardhat | 2.12.0 | +| @chainproof/core | 0.1.0 | + +The `ValidationScenario` schema is versioned at `1.0.0`. Breaking changes will increment the major version and include migration helpers. + +--- + +## Migration + +### Schema version 1.0.0 (current) + +No migration required. This is the initial release. + +### Future migrations + +When `VALIDATION_SCHEMA_VERSION` is bumped: +1. Old bundles will be detected by their `schemaVersion` field +2. A migration function will be provided in `packages/core/src/validation/migrate.ts` +3. The CLI will warn on version mismatch and suggest running `chainproof validate migrate` + +--- + +## Fixture Reference + +The following Solidity fixtures are provided for testing the validation engine: + +| File | Purpose | +|------|---------| +| `examples/contracts/validation/ValidationVulnerableVault.sol` | Intentionally vulnerable: reentrancy, tx.origin auth, unchecked return | +| `examples/contracts/validation/ValidationSecureVault.sol` | Patched reference: CEI pattern, msg.sender, checked transfers, nonReentrant | +| `examples/contracts/validation/ValidationReentrantAttacker.sol` | Reentrant attacker contract for reentrancy scenarios | + +The vulnerable vault should produce findings for `CP-107`, `CP-115`, and `CP-104`. The secure vault should produce no critical/high findings. + +--- + +## Example: Complete Reentrancy Validation + +This example shows the full workflow for validating a reentrancy finding. + +### 1. Scan + +```bash +chainproof scan examples/contracts/validation/ValidationVulnerableVault.sol \ + --format json --output findings.json +``` + +### 2. Plan + +```bash +chainproof validate plan findings.json --min-severity high --output plan.json +``` + +### 3. Inspect the scaffold + +```json +{ + "schemaVersion": "1.0.0", + "scenarios": [ + { + "id": "scenario-CP-107-validationvulnerablevault-42-a1b2c3d4", + "title": "Reentrancy reproduction scaffold: ValidationVulnerableVault.sol L42", + "contracts": [ + { "name": "VulnerableContract", "bytecode": "0x", ... }, + { "name": "AttackerContract", "bytecode": "0x", ... } + ], + ... + } + ] +} +``` + +### 4. Supply bytecode + +Compile with Foundry: + +```bash +forge build +VAULT_BYTECODE=$(cat out/ValidationVulnerableVault.sol/ValidationVulnerableVault.json | jq -r '.bytecode.object') +ATTACKER_BYTECODE=$(cat out/ValidationReentrantAttacker.sol/ValidationReentrantAttacker.json | jq -r '.bytecode.object') +``` + +Edit `plan.json` to fill in the bytecode and adjust the call sequence. + +### 5. Run + +```bash +chainproof validate run plan.json --adapter anvil --output report.json +``` + +### 6. View results + +```bash +chainproof validate report report.json --format markdown +``` + +--- + +*See also: [docs/invariant-dsl.md](invariant-dsl.md), [docs/governance-safety.md](governance-safety.md), [docs/staking-accounting.md](staking-accounting.md)* diff --git a/examples/contracts/validation/ValidationReentrantAttacker.sol b/examples/contracts/validation/ValidationReentrantAttacker.sol new file mode 100644 index 0000000..c6784ef --- /dev/null +++ b/examples/contracts/validation/ValidationReentrantAttacker.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IVault { + function deposit() external payable; + function withdraw(uint256 amount) external; + function balances(address) external view returns (uint256); +} + +/** + * @title ValidationReentrantAttacker + * @notice Attacker contract for reentrancy validation scenarios. + * + * Demonstrates a classic reentrancy attack: + * 1. Deposit 1 ETH into the target vault + * 2. Call withdraw(1 ETH) + * 3. On receiving ETH, re-enter withdraw again before balances[attacker] is updated + * 4. Repeat until vault is drained or gas runs out + */ +contract ValidationReentrantAttacker { + IVault public target; + address public owner; + uint256 public attackAmount; + uint256 public reentrancyCount; + uint256 public maxReentrancies; + + event AttackStarted(address vault, uint256 amount); + event ReentrancyAttempt(uint256 count, uint256 balance); + event AttackComplete(uint256 stolen); + + constructor(address _target) { + target = IVault(_target); + owner = msg.sender; + maxReentrancies = 5; + } + + function setMaxReentrancies(uint256 n) external { + require(msg.sender == owner, "Not owner"); + maxReentrancies = n; + } + + /// @notice Step 1: Deposit into the target vault + function deposit() external payable { + target.deposit{value: msg.value}(); + attackAmount = msg.value; + } + + /// @notice Step 2: Trigger the reentrancy attack + function attack() external { + require(attackAmount > 0, "Call deposit first"); + require(msg.sender == owner, "Not owner"); + reentrancyCount = 0; + emit AttackStarted(address(target), attackAmount); + target.withdraw(attackAmount); + } + + /// @notice Fallback: called when vault sends ETH — re-enter if possible + receive() external payable { + reentrancyCount++; + emit ReentrancyAttempt(reentrancyCount, address(target).balance); + if (reentrancyCount < maxReentrancies && address(target).balance >= attackAmount) { + target.withdraw(attackAmount); + } else { + emit AttackComplete(address(this).balance); + } + } + + function withdraw() external { + require(msg.sender == owner, "Not owner"); + payable(owner).transfer(address(this).balance); + } + + function getBalance() external view returns (uint256) { + return address(this).balance; + } +} diff --git a/examples/contracts/validation/ValidationSecureVault.sol b/examples/contracts/validation/ValidationSecureVault.sol new file mode 100644 index 0000000..d02478a --- /dev/null +++ b/examples/contracts/validation/ValidationSecureVault.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title ValidationSecureVault + * @notice Patched reference implementation for validation engine tests. + * + * SECURITY MITIGATIONS: + * 1. Reentrancy: state is updated BEFORE the external call (CEI pattern). + * 2. Auth: uses msg.sender (not tx.origin) throughout. + * 3. Checked transfers: low-level call return values are always checked. + * 4. Reentrancy guard: nonReentrant modifier prevents re-entry. + */ +contract ValidationSecureVault { + mapping(address => uint256) public balances; + address public owner; + bool private _locked; + + event Deposit(address indexed user, uint256 amount); + event Withdrawal(address indexed user, uint256 amount); + + error Reentrancy(); + error InsufficientBalance(uint256 available, uint256 requested); + error NotOwner(); + error TransferFailed(); + error ZeroAmount(); + + modifier nonReentrant() { + if (_locked) revert Reentrancy(); + _locked = true; + _; + _locked = false; + } + + modifier onlyOwner() { + // CP-115 fix: msg.sender not tx.origin + if (msg.sender != owner) revert NotOwner(); + _; + } + + constructor() { + owner = msg.sender; + } + + function deposit() external payable nonReentrant { + if (msg.value == 0) revert ZeroAmount(); + balances[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + } + + /// @notice SECURE: effects before interactions (CEI pattern) + function withdraw(uint256 amount) external nonReentrant { + if (amount == 0) revert ZeroAmount(); + uint256 bal = balances[msg.sender]; + if (bal < amount) revert InsufficientBalance(bal, amount); + // CP-107 fix: state update BEFORE external call + balances[msg.sender] = bal - amount; + emit Withdrawal(msg.sender, amount); + (bool ok, ) = payable(msg.sender).call{value: amount}(""); + if (!ok) revert TransferFailed(); + } + + /// @notice SECURE: msg.sender-based auth + function adminWithdraw(uint256 amount) external onlyOwner nonReentrant { + (bool ok, ) = payable(msg.sender).call{value: amount}(""); + if (!ok) revert TransferFailed(); + } + + /// @notice SECURE: return value always checked + function safeTransfer(address to, uint256 amount) external nonReentrant { + if (amount == 0) revert ZeroAmount(); + uint256 bal = balances[msg.sender]; + if (bal < amount) revert InsufficientBalance(bal, amount); + balances[msg.sender] = bal - amount; + // CP-104 fix: check the return value + (bool ok, ) = payable(to).call{value: amount}(""); + if (!ok) revert TransferFailed(); + } + + function getBalance() external view returns (uint256) { + return address(this).balance; + } + + receive() external payable {} +} diff --git a/examples/contracts/validation/ValidationVulnerableVault.sol b/examples/contracts/validation/ValidationVulnerableVault.sol new file mode 100644 index 0000000..256b7b2 --- /dev/null +++ b/examples/contracts/validation/ValidationVulnerableVault.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title ValidationVulnerableVault + * @notice Intentionally vulnerable ETH vault for validation engine tests. + * + * VULNERABILITIES (by design — do not use in production): + * 1. Reentrancy (CP-107): `withdraw` sends ETH before updating `balances`, + * allowing a malicious receiver to re-enter and drain the vault. + * 2. tx.origin auth (CP-115): `adminWithdraw` uses `tx.origin` instead of + * `msg.sender` for owner authentication. + * 3. Unchecked return value (CP-104): `unsafeTransfer` does not check the + * return value of `payable().call{value:...}`. + */ +contract ValidationVulnerableVault { + mapping(address => uint256) public balances; + address public owner; + + constructor() { + owner = msg.sender; + } + + function deposit() external payable { + balances[msg.sender] += msg.value; + } + + /// @notice VULNERABLE: state update happens AFTER the external call + function withdraw(uint256 amount) external { + require(balances[msg.sender] >= amount, "Insufficient balance"); + // CP-107: send before state update — reentrancy window + (bool ok, ) = payable(msg.sender).call{value: amount}(""); + require(ok, "Transfer failed"); + balances[msg.sender] -= amount; + } + + /// @notice VULNERABLE: tx.origin used for authentication + function adminWithdraw(uint256 amount) external { + // CP-115: should use msg.sender, not tx.origin + require(tx.origin == owner, "Not owner"); + payable(tx.origin).transfer(amount); + } + + /// @notice VULNERABLE: return value not checked + function unsafeTransfer(address to, uint256 amount) external { + require(balances[msg.sender] >= amount, "Insufficient"); + balances[msg.sender] -= amount; + // CP-104: low-level call return value ignored + payable(to).call{value: amount}(""); // solhint-disable-line + } + + function getBalance() external view returns (uint256) { + return address(this).balance; + } + + receive() external payable {} +} diff --git a/packages/cli/src/__tests__/validate.test.ts b/packages/cli/src/__tests__/validate.test.ts new file mode 100644 index 0000000..51da804 --- /dev/null +++ b/packages/cli/src/__tests__/validate.test.ts @@ -0,0 +1,727 @@ +/** + * CLI tests for `chainproof validate` subcommands. + * + * Tests cover: + * - `validate plan` — translate a scan-result JSON into a ValidationPlan + * - `validate run` — execution path with adapter unavailability graceful failure + * - `validate replay`— replay a saved result (offline, no adapter needed) + * - `validate minimize` — minimize a scenario + * - `validate report`— re-format a saved ValidationReport (json / markdown) + * + * These are integration tests that invoke the compiled CLI binary. Adapter + * lifecycle tests (actual Anvil / Hardhat execution) require those binaries + * to be installed and are skipped in CI environments where they are absent. + */ + +import { spawnSync, execFileSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const CLI = path.resolve(__dirname, "../../dist/cli.js"); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +/** Minimal ScanResult JSON with supported (CP-107) and unsupported (GAS-001) findings. */ +const SCAN_RESULT_WITH_CP107 = JSON.stringify({ + version: "0.1.0", + timestamp: "2026-08-01T00:00:00.000Z", + files: [ + { + file: "contracts/VulnerableVault.sol", + findings: [ + { + id: "CP-107", + title: "Reentrancy", + description: "External call before state update", + recommendation: "Apply CEI pattern", + severity: "critical", + file: "contracts/VulnerableVault.sol", + line: 42, + snippet: "payable(msg.sender).call{value: amount}(\"\");", + }, + { + id: "CP-115", + title: "tx.origin auth", + description: "Uses tx.origin for authentication", + recommendation: "Use msg.sender", + severity: "high", + file: "contracts/VulnerableVault.sol", + line: 55, + snippet: "require(tx.origin == owner);", + }, + { + id: "GAS-001", + title: "Storage in loop", + description: "Reading state variable inside loop", + recommendation: "Cache in memory", + severity: "gas", + file: "contracts/VulnerableVault.sol", + line: 70, + snippet: "for (uint i = 0; i < arr.length; i++)", + }, + ], + }, + ], + summary: { critical: 1, high: 1, medium: 0, low: 0, info: 0, gas: 1, total: 3 }, +}); + +/** Minimal ScanResult with only unsupported finding IDs. */ +const SCAN_RESULT_UNSUPPORTED = JSON.stringify({ + version: "0.1.0", + timestamp: "2026-08-01T00:00:00.000Z", + files: [ + { + file: "contracts/Test.sol", + findings: [ + { + id: "CUSTOM-999", + title: "Custom rule", + description: "Custom", + recommendation: "Fix it", + severity: "medium", + file: "contracts/Test.sol", + line: 1, + }, + ], + }, + ], + summary: { critical: 0, high: 0, medium: 1, low: 0, info: 0, gas: 0, total: 1 }, +}); + +/** Flat Finding[] JSON format (also supported by plan). */ +const SCAN_RESULT_FLAT = JSON.stringify([ + { + id: "CP-107", + title: "Reentrancy", + description: "External call before state update", + recommendation: "Apply CEI", + severity: "critical", + file: "contracts/Vault.sol", + line: 10, + }, + { + id: "CP-104", + title: "Unchecked return value", + description: ".call return value not checked", + recommendation: "Check return value", + severity: "medium", + file: "contracts/Vault.sol", + line: 20, + }, +]); + +/** A minimal synthetic ValidationReport (produced without running an adapter). */ +const SYNTHETIC_REPORT = JSON.stringify({ + schemaVersion: "1.0.0", + timestamp: "2026-08-01T10:00:00.000Z", + total: 2, + passed: 1, + failed: 1, + errored: 0, + adapterType: "anvil", + totalDurationMs: 1250, + results: [ + { + schemaVersion: "1.0.0", + scenario: { + schemaVersion: "1.0.0", + id: "scenario-CP-107-VulnerableVault-withdraw", + title: "Reentrancy in VulnerableVault.withdraw", + findingId: "CP-107", + findingFile: "contracts/VulnerableVault.sol", + findingLine: 42, + chain: { chainId: 31337 }, + accounts: [{ address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", balance: "10000000000000000000", label: "attacker" }], + contracts: [{ name: "Vault", bytecode: "0x" }], + calls: [ + { to: "Vault", signature: "deposit()", value: "1000000000000000000", from: "attacker", description: "Initial deposit" }, + { to: "Vault", signature: "withdraw(uint256)", args: ["1000000000000000000"], from: "attacker", description: "Trigger reentrancy" }, + ], + expectedOutcome: "exploit-succeeds", + createdAt: "2026-08-01T10:00:00.000Z", + }, + adapterType: "anvil", + adapterVersion: "anvil/0.2.0", + snapshotId: "0x1", + snapshotBlock: 1, + callResults: [ + { callIndex: 0, reverted: false, returnData: "0x", gasUsed: 21000, logs: [], storageDiff: [] }, + { callIndex: 1, reverted: false, returnData: "0x", gasUsed: 50000, logs: [], storageDiff: [] }, + ], + outcomeMatched: true, + outcomeSummary: "Exploit scenario completed without reverts and all assertions passed", + storageAssertionResults: [], + balanceAssertionResults: [], + eventAssertionResults: [], + totalGasUsed: 71000, + startedAt: "2026-08-01T10:00:00.000Z", + completedAt: "2026-08-01T10:00:01.250Z", + durationMs: 1250, + warnings: [], + }, + { + schemaVersion: "1.0.0", + scenario: { + schemaVersion: "1.0.0", + id: "scenario-CP-115-VulnerableVault-adminWithdraw", + title: "tx.origin authentication bypass in VulnerableVault.adminWithdraw", + findingId: "CP-115", + findingFile: "contracts/VulnerableVault.sol", + findingLine: 55, + chain: { chainId: 31337 }, + accounts: [{ address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", balance: "10000000000000000000", label: "attacker" }], + contracts: [{ name: "Vault", bytecode: "0x" }], + calls: [{ to: "Vault", signature: "adminWithdraw(uint256)", args: ["1000"], from: "attacker", description: "Bypass via tx.origin" }], + expectedOutcome: "exploit-succeeds", + createdAt: "2026-08-01T10:00:00.000Z", + }, + adapterType: "anvil", + adapterVersion: "anvil/0.2.0", + snapshotId: "0x2", + snapshotBlock: 2, + callResults: [ + { callIndex: 0, reverted: true, revertReason: "Not owner", returnData: "0x", gasUsed: 21000, logs: [], storageDiff: [] }, + ], + outcomeMatched: false, + outcomeSummary: "Exploit scenario had an unexpected revert (call[0])", + storageAssertionResults: [], + balanceAssertionResults: [], + eventAssertionResults: [], + totalGasUsed: 21000, + startedAt: "2026-08-01T10:00:01.250Z", + completedAt: "2026-08-01T10:00:01.500Z", + durationMs: 250, + warnings: [], + }, + ], +}); + +/** A minimal synthetic ValidationResult for replay tests. */ +const SYNTHETIC_RESULT = JSON.parse(SYNTHETIC_REPORT).results[0]; + +/** A minimal ValidationScenario for minimize tests. */ +const SYNTHETIC_SCENARIO = { + schemaVersion: "1.0.0", + id: "scenario-CP-107-test", + title: "Test scenario", + findingId: "CP-107", + chain: { chainId: 31337 }, + accounts: [{ address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", balance: "10000000000000000000" }], + contracts: [{ name: "Vault", bytecode: "0x" }], + calls: [ + { to: "Vault", signature: "deposit()", value: "1000", description: "Setup" }, + { to: "Vault", signature: "withdraw(uint256)", args: ["1000"], description: "Attack" }, + ], + expectedOutcome: "exploit-succeeds", +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function run( + args: string[], + opts: { input?: string; env?: NodeJS.ProcessEnv } = {}, +): { stdout: string; stderr: string; status: number } { + const result = spawnSync(process.execPath, [CLI, ...args], { + encoding: "utf8", + env: { ...process.env, ...opts.env }, + timeout: 15_000, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + status: result.status ?? 1, + }; +} + +let tmpDir: string; +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "chainproof-validate-cli-test-")); +}); +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── validate plan ──────────────────────────────────────────────────────────── + +describe("validate plan", () => { + it("produces a valid JSON ValidationPlan from a ScanResult JSON", () => { + const scanFile = path.join(tmpDir, "scan.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_WITH_CP107, "utf8"); + + const result = run(["validate", "plan", scanFile]); + expect(result.status).toBe(0); + + const plan = JSON.parse(result.stdout); + expect(plan.schemaVersion).toBe("1.0.0"); + expect(Array.isArray(plan.scenarios)).toBe(true); + expect(Array.isArray(plan.unsupportedFindings)).toBe(true); + expect(plan.createdAt).toBeDefined(); + }); + + it("generates scenarios for supported finding IDs (CP-107, CP-115)", () => { + const scanFile = path.join(tmpDir, "scan-supported.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_WITH_CP107, "utf8"); + + const result = run(["validate", "plan", scanFile]); + expect(result.status).toBe(0); + + const plan = JSON.parse(result.stdout); + const ids = plan.scenarios.map((s: { findingId: string }) => s.findingId); + expect(ids).toContain("CP-107"); + expect(ids).toContain("CP-115"); + }); + + it("silently excludes GAS-* findings (gas severity or GAS- prefix)", () => { + const scanFile = path.join(tmpDir, "scan-gas.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_WITH_CP107, "utf8"); + + const result = run(["validate", "plan", scanFile]); + expect(result.status).toBe(0); + + const plan = JSON.parse(result.stdout); + // GAS-001 (gas severity) must NOT appear in unsupportedFindings + const unsupportedIds = plan.unsupportedFindings.map( + (u: { findingId: string }) => u.findingId, + ); + expect(unsupportedIds).not.toContain("GAS-001"); + }); + + it("reports unsupported finding IDs in unsupportedFindings", () => { + const scanFile = path.join(tmpDir, "scan-unsupported.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_UNSUPPORTED, "utf8"); + + const result = run(["validate", "plan", scanFile]); + expect(result.status).toBe(0); + + const plan = JSON.parse(result.stdout); + expect(plan.scenarios.length).toBe(0); + expect(plan.unsupportedFindings.length).toBe(1); + expect(plan.unsupportedFindings[0].findingId).toBe("CUSTOM-999"); + expect(plan.unsupportedFindings[0].reason).toBeTruthy(); + }); + + it("accepts flat Finding[] JSON as input", () => { + const scanFile = path.join(tmpDir, "scan-flat.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_FLAT, "utf8"); + + const result = run(["validate", "plan", scanFile]); + expect(result.status).toBe(0); + + const plan = JSON.parse(result.stdout); + expect(plan.scenarios.length).toBeGreaterThan(0); + }); + + it("writes the plan to --output file when specified", () => { + const scanFile = path.join(tmpDir, "scan-out.json"); + const outFile = path.join(tmpDir, "plan-out.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_WITH_CP107, "utf8"); + + const result = run(["validate", "plan", scanFile, "--output", outFile]); + expect(result.status).toBe(0); + expect(fs.existsSync(outFile)).toBe(true); + + const plan = JSON.parse(fs.readFileSync(outFile, "utf8")); + expect(plan.schemaVersion).toBe("1.0.0"); + }); + + it("prints a table summary with --format table", () => { + const scanFile = path.join(tmpDir, "scan-table.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_WITH_CP107, "utf8"); + + const result = run(["validate", "plan", scanFile, "--format", "table"]); + expect(result.status).toBe(0); + // Table format goes to stdout + const combined = result.stdout + result.stderr; + expect(combined).toContain("Validation Plan"); + expect(combined).toMatch(/Scenarios:/i); + }); + + it("respects --min-severity (high) and omits medium/low findings", () => { + const scanFile = path.join(tmpDir, "scan-minsev.json"); + const input = JSON.stringify([ + { id: "CP-107", title: "Reentrancy", description: "", recommendation: "", severity: "critical", file: "f.sol", line: 1 }, + { id: "CP-101", title: "Overflow", description: "", recommendation: "", severity: "high", file: "f.sol", line: 2 }, + { id: "CP-104", title: "Unchecked", description: "", recommendation: "", severity: "medium", file: "f.sol", line: 3 }, + ]); + fs.writeFileSync(scanFile, input, "utf8"); + + const result = run(["validate", "plan", scanFile, "--min-severity", "high"]); + expect(result.status).toBe(0); + + const plan = JSON.parse(result.stdout); + const severities = plan.scenarios.map((s: { findingId: string }) => s.findingId); + expect(severities).toContain("CP-107"); + expect(severities).toContain("CP-101"); + // CP-104 is medium — should be absent + expect(severities).not.toContain("CP-104"); + }); + + it("exits 2 on corrupt JSON input", () => { + const scanFile = path.join(tmpDir, "scan-corrupt.json"); + fs.writeFileSync(scanFile, "{ not valid json @@", "utf8"); + + const result = run(["validate", "plan", scanFile]); + expect(result.status).toBe(2); + expect(result.stderr).toMatch(/failed|error/i); + }); + + it("exits 2 on missing input file", () => { + const result = run(["validate", "plan", "/no/such/file/scan.json"]); + expect(result.status).toBe(2); + }); + + it("plan scenario IDs are unique", () => { + const scanFile = path.join(tmpDir, "scan-unique.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_FLAT, "utf8"); + + const result = run(["validate", "plan", scanFile]); + expect(result.status).toBe(0); + + const plan = JSON.parse(result.stdout); + const ids = plan.scenarios.map((s: { id: string }) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("each scenario has required fields", () => { + const scanFile = path.join(tmpDir, "scan-fields.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_WITH_CP107, "utf8"); + + const result = run(["validate", "plan", scanFile]); + expect(result.status).toBe(0); + + const plan = JSON.parse(result.stdout); + for (const s of plan.scenarios) { + expect(s.schemaVersion).toBe("1.0.0"); + expect(typeof s.id).toBe("string"); + expect(s.id.length).toBeGreaterThan(0); + expect(typeof s.title).toBe("string"); + expect(s.chain).toBeDefined(); + expect(Array.isArray(s.accounts)).toBe(true); + expect(Array.isArray(s.contracts)).toBe(true); + expect(Array.isArray(s.calls)).toBe(true); + expect(s.expectedOutcome).toBeDefined(); + } + }); + + it("plan output does not contain private keys or fork URLs", () => { + const scanFile = path.join(tmpDir, "scan-secrets.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_WITH_CP107, "utf8"); + + const result = run(["validate", "plan", scanFile]); + expect(result.status).toBe(0); + + // No private keys (0x + 64 hex chars) should appear in the plan + expect(result.stdout).not.toMatch(/['"](0x[0-9a-f]{64})['"]/i); + }); +}); + +// ─── validate run ───────────────────────────────────────────────────────────── + +describe("validate run", () => { + it("exits 2 with actionable error when no adapter is available", () => { + const planFile = path.join(tmpDir, "plan-run.json"); + // Write a minimal valid plan + const plan = { + schemaVersion: "1.0.0", + scenarios: [SYNTHETIC_SCENARIO], + unsupportedFindings: [], + createdAt: new Date().toISOString(), + }; + fs.writeFileSync(planFile, JSON.stringify(plan), "utf8"); + + // Use PATH that doesn't include anvil/hardhat to simulate unavailability + const result = run( + ["validate", "run", planFile, "--adapter", "anvil", "--adapter-bin", "/no/such/anvil"], + { env: { PATH: "" } }, + ); + // Should exit 2 (error) when adapter is unavailable + expect([1, 2]).toContain(result.status); + expect(result.stderr).toBeTruthy(); + }); + + it("exits 2 on corrupt plan JSON", () => { + const planFile = path.join(tmpDir, "plan-corrupt.json"); + fs.writeFileSync(planFile, "{ bad json", "utf8"); + + const result = run(["validate", "run", planFile]); + expect(result.status).toBe(2); + }); + + it("exits 2 when plan has unrecognized structure", () => { + const planFile = path.join(tmpDir, "plan-bad-structure.json"); + fs.writeFileSync(planFile, JSON.stringify({ notAScenario: true }), "utf8"); + + const result = run(["validate", "run", planFile]); + expect(result.status).toBe(2); + }); + + it("accepts a single ValidationScenario (not a plan)", () => { + const scenarioFile = path.join(tmpDir, "single-scenario.json"); + fs.writeFileSync(scenarioFile, JSON.stringify(SYNTHETIC_SCENARIO), "utf8"); + + // We can't actually run without an adapter but we can test that it parses and + // attempts to run (exiting 2 when adapter unavailable, not crashing with a + // different error) + const result = run( + ["validate", "run", scenarioFile, "--adapter-bin", "/no/such/anvil"], + { env: { PATH: "" } }, + ); + expect([1, 2]).toContain(result.status); + }); + + it("exits 0 when plan has no scenarios", () => { + const planFile = path.join(tmpDir, "plan-empty.json"); + const plan = { + schemaVersion: "1.0.0", + scenarios: [], + unsupportedFindings: [], + createdAt: new Date().toISOString(), + }; + fs.writeFileSync(planFile, JSON.stringify(plan), "utf8"); + + const result = run(["validate", "run", planFile, "--adapter-bin", "/no/such/anvil"]); + expect(result.status).toBe(0); + }); +}); + +// ─── validate replay ────────────────────────────────────────────────────────── + +describe("validate replay", () => { + it("exits 2 when the result file is corrupt JSON", () => { + const resultFile = path.join(tmpDir, "result-corrupt.json"); + fs.writeFileSync(resultFile, "not json", "utf8"); + + const result = run(["validate", "replay", resultFile]); + expect(result.status).toBe(2); + expect(result.stderr).toMatch(/failed|error/i); + }); + + it("exits 2 when result JSON is missing the scenario field", () => { + const resultFile = path.join(tmpDir, "result-no-scenario.json"); + fs.writeFileSync(resultFile, JSON.stringify({ schemaVersion: "1.0.0" }), "utf8"); + + const result = run(["validate", "replay", resultFile]); + expect(result.status).toBe(2); + }); + + it("exits 2 when adapter is unavailable but result is otherwise valid", () => { + const resultFile = path.join(tmpDir, "result-valid.json"); + fs.writeFileSync(resultFile, JSON.stringify(SYNTHETIC_RESULT), "utf8"); + + const result = run( + ["validate", "replay", resultFile, "--adapter-bin", "/no/such/anvil"], + { env: { PATH: "" } }, + ); + expect([1, 2]).toContain(result.status); + }); +}); + +// ─── validate minimize ──────────────────────────────────────────────────────── + +describe("validate minimize", () => { + it("exits 2 when scenario file is corrupt", () => { + const f = path.join(tmpDir, "scenario-corrupt.json"); + fs.writeFileSync(f, "{ bad", "utf8"); + + const result = run(["validate", "minimize", f]); + expect(result.status).toBe(2); + }); + + it("exits with error when adapter is unavailable", () => { + const f = path.join(tmpDir, "scenario-minimize.json"); + fs.writeFileSync(f, JSON.stringify(SYNTHETIC_SCENARIO), "utf8"); + + const result = run( + ["validate", "minimize", f, "--adapter-bin", "/no/such/anvil"], + { env: { PATH: "" } }, + ); + expect([1, 2]).toContain(result.status); + }); +}); + +// ─── validate report ────────────────────────────────────────────────────────── + +describe("validate report", () => { + it("formats a ValidationReport as Markdown (default)", () => { + const reportFile = path.join(tmpDir, "report.json"); + fs.writeFileSync(reportFile, SYNTHETIC_REPORT, "utf8"); + + const result = run(["validate", "report", reportFile]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("# ChainProof Validation Report"); + expect(result.stdout).toContain("## Summary"); + expect(result.stdout).toMatch(/Passed|Failed/); + }); + + it("formats a report as JSON with --format json", () => { + const reportFile = path.join(tmpDir, "report-json.json"); + fs.writeFileSync(reportFile, SYNTHETIC_REPORT, "utf8"); + + const result = run(["validate", "report", reportFile, "--format", "json"]); + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.schemaVersion).toBe("1.0.0"); + expect(parsed.total).toBe(2); + }); + + it("writes to --output file when specified", () => { + const reportFile = path.join(tmpDir, "report-write.json"); + const outFile = path.join(tmpDir, "report-output.md"); + fs.writeFileSync(reportFile, SYNTHETIC_REPORT, "utf8"); + + const result = run(["validate", "report", reportFile, "--output", outFile]); + expect(result.status).toBe(0); + expect(fs.existsSync(outFile)).toBe(true); + expect(fs.readFileSync(outFile, "utf8")).toContain("ChainProof Validation Report"); + }); + + it("JSON output has deterministic key order (sorted alphabetically)", () => { + const reportFile = path.join(tmpDir, "report-det.json"); + fs.writeFileSync(reportFile, SYNTHETIC_REPORT, "utf8"); + + const result1 = run(["validate", "report", reportFile, "--format", "json"]); + const result2 = run(["validate", "report", reportFile, "--format", "json"]); + expect(result1.status).toBe(0); + expect(result1.stdout).toBe(result2.stdout); + }); + + it("reports scenario-level pass/fail counts in Markdown", () => { + const reportFile = path.join(tmpDir, "report-count.json"); + fs.writeFileSync(reportFile, SYNTHETIC_REPORT, "utf8"); + + const result = run(["validate", "report", reportFile]); + expect(result.status).toBe(0); + // The synthetic report has 1 passed, 1 failed + expect(result.stdout).toMatch(/1/); + }); + + it("exits 1 with --fail-on-failure when report has failures", () => { + const reportFile = path.join(tmpDir, "report-fail.json"); + fs.writeFileSync(reportFile, SYNTHETIC_REPORT, "utf8"); + + const result = run(["validate", "report", reportFile, "--fail-on-failure"]); + expect(result.status).toBe(1); + }); + + it("exits 0 with --fail-on-failure when all scenarios passed", () => { + const allPassedReport = { + ...JSON.parse(SYNTHETIC_REPORT), + failed: 0, + passed: 2, + results: JSON.parse(SYNTHETIC_REPORT).results.map( + (r: Record) => ({ ...r, outcomeMatched: true }), + ), + }; + const reportFile = path.join(tmpDir, "report-allpass.json"); + fs.writeFileSync(reportFile, JSON.stringify(allPassedReport), "utf8"); + + const result = run(["validate", "report", reportFile, "--fail-on-failure"]); + expect(result.status).toBe(0); + }); + + it("exits 2 on corrupt report JSON", () => { + const reportFile = path.join(tmpDir, "report-corrupt.json"); + fs.writeFileSync(reportFile, "{ not valid", "utf8"); + + const result = run(["validate", "report", reportFile]); + expect(result.status).toBe(2); + }); + + it("exits 2 on report missing schemaVersion", () => { + const reportFile = path.join(tmpDir, "report-no-version.json"); + fs.writeFileSync(reportFile, JSON.stringify({ results: [] }), "utf8"); + + const result = run(["validate", "report", reportFile]); + expect(result.status).toBe(2); + }); + + it("exits 2 on report missing results array", () => { + const reportFile = path.join(tmpDir, "report-no-results.json"); + fs.writeFileSync(reportFile, JSON.stringify({ schemaVersion: "1.0.0" }), "utf8"); + + const result = run(["validate", "report", reportFile]); + expect(result.status).toBe(2); + }); + + it("Markdown report contains per-scenario sections for each result", () => { + const reportFile = path.join(tmpDir, "report-sections.json"); + fs.writeFileSync(reportFile, SYNTHETIC_REPORT, "utf8"); + + const result = run(["validate", "report", reportFile]); + expect(result.status).toBe(0); + // Both scenarios should appear + expect(result.stdout).toContain("Reentrancy in VulnerableVault.withdraw"); + expect(result.stdout).toContain("tx.origin"); + }); + + it("Markdown report does not contain fork URLs or private keys", () => { + const reportFile = path.join(tmpDir, "report-nosecrets.json"); + fs.writeFileSync(reportFile, SYNTHETIC_REPORT, "utf8"); + + const result = run(["validate", "report", reportFile]); + expect(result.status).toBe(0); + // No 0x-prefixed 64-char hex (private keys) in output + expect(result.stdout).not.toMatch(/(0x[0-9a-f]{64})/i); + // No http URLs with credentials + expect(result.stdout).not.toMatch(/https?:\/\/[^/]+@/); + }); +}); + +// ─── Adapter availability guard ─────────────────────────────────────────────── +// Full adapter lifecycle tests (actual EVM execution) require Anvil or Hardhat. +// They are skipped when neither is present so CI always passes even on bare runners. + +const SKIP_ADAPTER = process.env["CHAINPROOF_SKIP_ADAPTER_TESTS"] === "1"; + +(SKIP_ADAPTER ? describe.skip : describe)("validate run — with adapter (integration)", () => { + it("runs a plan against Anvil and produces a ValidationReport", async () => { + // This test requires `anvil` to be on $PATH. + // Skip inline if not available. + let anvilAvailable = false; + try { + execFileSync("anvil", ["--version"], { stdio: "ignore", timeout: 5_000 }); + anvilAvailable = true; + } catch { + anvilAvailable = false; + } + if (!anvilAvailable) { + console.warn(" Skipping adapter integration test — anvil not found"); + return; + } + + const scanFile = path.join(tmpDir, "scan-integration.json"); + fs.writeFileSync(scanFile, SCAN_RESULT_WITH_CP107, "utf8"); + + // Step 1: plan + const planResult = run(["validate", "plan", scanFile]); + expect(planResult.status).toBe(0); + const plan = JSON.parse(planResult.stdout); + expect(plan.scenarios.length).toBeGreaterThan(0); + + const planFile = path.join(tmpDir, "plan-integration.json"); + fs.writeFileSync(planFile, planResult.stdout, "utf8"); + + // Step 2: run + const reportFile = path.join(tmpDir, "report-integration.json"); + const runResult = run([ + "validate", "run", planFile, + "--adapter", "anvil", + "--timeout", "60000", + "--output", reportFile, + ]); + // Should exit 0 (success) or 1 (failures in scenarios) but not 2 (infra error) + expect([0, 1]).toContain(runResult.status); + expect(fs.existsSync(reportFile)).toBe(true); + + const report = JSON.parse(fs.readFileSync(reportFile, "utf8")); + expect(report.schemaVersion).toBe("1.0.0"); + expect(report.total).toBeGreaterThan(0); + expect(typeof report.totalDurationMs).toBe("number"); + + // Step 3: report + const reportMdResult = run(["validate", "report", reportFile]); + expect(reportMdResult.status).toBe(0); + expect(reportMdResult.stdout).toContain("# ChainProof Validation Report"); + }, 120_000); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f1d07dc..0c66263 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 { registerValidateCommand } from "./commands/validate"; // ─── ASCII Banner ───────────────────────────────────────────────────────────── @@ -631,5 +632,6 @@ registerWatchCommand(program, printBanner); registerInvariantsCommand(program, printBanner); registerStakingCommand(program); registerGovernanceCommand(program, printBanner); +registerValidateCommand(program); program.parse(); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts new file mode 100644 index 0000000..cf7b463 --- /dev/null +++ b/packages/cli/src/commands/validate.ts @@ -0,0 +1,481 @@ +/** + * `chainproof validate` — Fork-aware concrete validation CLI. + * + * Subcommands: + * plan Translate static findings into reproduction scaffolds + * run Execute scenarios against an EVM backend + * replay Restore a snapshot and re-run a scenario + * minimize Remove redundant calls from a scenario + * report Format a saved ValidationReport as Markdown or JSON + */ + +import * as fs from "fs"; +import * as path from "path"; +import { Command } from "commander"; +import chalk from "chalk"; +import { + // Planning + planValidation, + serializeValidationPlan, + parseValidationPlan, + // Running + runValidationPlan, + minimizeScenario, + // Adapters + AnvilAdapter, + HardhatAdapter, + isAnvilAvailable, + isHardhatAvailable, + // Reports + serializeValidationReport, + generateValidationMarkdown, + parseValidationReport, + // Runner (single scenario) + ValidationRunner, + sanitizeScenario, + // Errors + ValidationError, + CorruptBundleError, + AdapterCrashError, + ForkUnavailableError, + createCancellationSignal, +} from "@chainproof/core"; +import type { + Finding, + RunValidationOptions, + ValidationReport, + ValidationResult, + ValidationScenario, +} from "@chainproof/core"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function sanitizeCliError(err: unknown): string { + if (err instanceof ValidationError) return err.message; + if (err instanceof CorruptBundleError) return err.message; + if (err instanceof AdapterCrashError) return err.message; + if (err instanceof ForkUnavailableError) return err.message; + if (err instanceof Error) { + // Don't expose full stack or paths + return err.message.replace(/\/[^\s"']+/g, "[path]").slice(0, 500); + } + return "Unexpected error during validation"; +} + +function writeOutput(outputPath: string | undefined, content: string): void { + if (outputPath) { + const dir = path.dirname(outputPath); + if (dir && dir !== ".") { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(outputPath, content, "utf8"); + } else { + process.stdout.write(content); + if (!content.endsWith("\n")) process.stdout.write("\n"); + } +} + +function loadScanResult(file: string): Finding[] { + const content = fs.readFileSync(file, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error(`Could not parse scan result JSON from ${file}`); + } + const obj = parsed as Record; + // Support both ScanResult (with `files[].findings`) and flat `Finding[]` + if (Array.isArray(obj)) { + return obj as Finding[]; + } + if (obj["files"] && Array.isArray(obj["files"])) { + const findings: Finding[] = []; + for (const file2 of obj["files"] as Array>) { + if (Array.isArray(file2["findings"])) { + findings.push(...(file2["findings"] as Finding[])); + } + } + return findings; + } + throw new Error("Unrecognized scan result format"); +} + +function loadScenario(file: string): ValidationScenario { + const content = fs.readFileSync(file, "utf8"); + try { + return JSON.parse(content) as ValidationScenario; + } catch { + throw new CorruptBundleError(file, "Invalid JSON"); + } +} + +async function detectAdapter( + preferred: string | undefined, + binaryPath?: string, +): Promise<"anvil" | "hardhat"> { + if (preferred === "hardhat") return "hardhat"; + if (preferred === "anvil") return "anvil"; + // Auto-detect + if (await isAnvilAvailable(binaryPath ?? "anvil")) return "anvil"; + if (await isHardhatAvailable("npx")) return "hardhat"; + throw new ValidationError( + "No EVM adapter found. Install Foundry (anvil) or Hardhat (npx hardhat) and ensure they are on $PATH.", + "ADAPTER_NOT_FOUND", + ); +} + +// ─── Register command ───────────────────────────────────────────────────────── + +export function registerValidateCommand(program: Command): void { + const validate = program + .command("validate") + .description("Concrete validation and exploit reproduction harness"); + + // ─── validate plan ────────────────────────────────────────────────────────── + validate + .command("plan ") + .description( + "Translate static findings into reproduction scenario scaffolds. " + + " is a JSON file produced by `chainproof scan --format json`.", + ) + .option("--output ", "Write the validation plan to a JSON file") + .option( + "--min-severity ", + "Only scaffold findings at or above this severity: critical|high|medium|low|info", + "low", + ) + .option( + "--deduplicate-by-file", + "Deduplicate findings by (id, file, line) rather than (id, file)", + ) + .option("--format ", "Output format: json|table", "json") + .action(async (scanResultFile: string, opts: { + output?: string; + minSeverity?: string; + deduplicateByFile?: boolean; + format?: string; + }) => { + try { + const findings = loadScanResult(scanResultFile); + const plan = planValidation(findings, { + minSeverity: (opts.minSeverity ?? "low") as "critical" | "high" | "medium" | "low" | "info", + deduplicateByFile: opts.deduplicateByFile ?? false, + }); + + if (opts.format === "table") { + console.log(chalk.cyan("\n Validation Plan\n")); + console.log(` Scenarios: ${chalk.green(plan.scenarios.length)}`); + console.log(` Unsupported: ${chalk.yellow(plan.unsupportedFindings.length)}`); + if (plan.scenarios.length > 0) { + console.log("\n Generated scenarios:"); + for (const s of plan.scenarios) { + console.log(` ${chalk.green("✓")} ${s.id}`); + console.log(` ${chalk.gray(s.title)}`); + } + } + if (plan.unsupportedFindings.length > 0) { + console.log("\n Unsupported findings:"); + for (const u of plan.unsupportedFindings) { + console.log(` ${chalk.yellow("⚠")} ${u.findingId} @ ${u.findingFile}:${u.findingLine}`); + console.log(` ${chalk.gray(u.reason)}`); + } + } + } else { + const json = serializeValidationPlan(plan); + if (opts.output) { + writeOutput(opts.output, json); + console.error(chalk.green(`Validation plan written to ${opts.output}`)); + console.error(` ${plan.scenarios.length} scenario(s), ${plan.unsupportedFindings.length} unsupported finding(s)`); + } else { + writeOutput(undefined, json); + } + } + } catch (err) { + console.error(chalk.red(`validate plan failed: ${sanitizeCliError(err)}`)); + process.exit(2); + } + }); + + // ─── validate run ─────────────────────────────────────────────────────────── + validate + .command("run ") + .description( + "Execute scenarios in a validation plan (or a single scenario JSON) against an EVM backend.", + ) + .option("--adapter ", "EVM backend: anvil|hardhat (auto-detected if omitted)") + .option("--adapter-bin ", "Explicit path to the adapter binary") + .option("--fork-url ", "Fork RPC URL (overrides scenario chain.forkUrl)") + .option("--fork-block ", "Fork block number to pin") + .option("--chain-id ", "Chain ID override") + .option("--timeout ", "Per-scenario timeout in milliseconds", "30000") + .option("--output ", "Write the validation report to a file") + .option("--format ", "Output format: json|markdown", "json") + .option("--fail-on-failure", "Exit 1 if any scenario fails or errors") + .action(async (planFile: string, opts: { + adapter?: string; + adapterBin?: string; + forkUrl?: string; + forkBlock?: string; + chainId?: string; + timeout?: string; + output?: string; + format?: string; + failOnFailure?: boolean; + }) => { + const { signal, cancel } = createCancellationSignal(); + + // Handle Ctrl+C gracefully + process.once("SIGINT", () => { + console.error(chalk.yellow("\n Cancelling...")); + cancel(); + }); + + try { + // Load plan or single scenario + const content = fs.readFileSync(planFile, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new CorruptBundleError(planFile, "Invalid JSON"); + } + + let scenarios: ValidationScenario[]; + const obj = parsed as Record; + if (Array.isArray(obj["scenarios"])) { + // It's a ValidationPlan + scenarios = obj["scenarios"] as ValidationScenario[]; + } else if (obj["id"] && obj["calls"]) { + // Single scenario + scenarios = [obj as unknown as ValidationScenario]; + } else { + throw new CorruptBundleError(planFile, "Not a ValidationPlan or ValidationScenario"); + } + + if (scenarios.length === 0) { + console.error(chalk.yellow("No scenarios to run.")); + process.exit(0); + } + + const adapterType = await detectAdapter(opts.adapter, opts.adapterBin); + console.error(chalk.cyan(` Using ${adapterType} adapter`)); + console.error(chalk.cyan(` Running ${scenarios.length} scenario(s)...\n`)); + + const timeoutMs = opts.timeout ? parseInt(opts.timeout, 10) : 30_000; + const runOpts: RunValidationOptions = { + adapterType, + adapterBinaryPath: opts.adapterBin, + forkUrl: opts.forkUrl, + forkBlockNumber: opts.forkBlock ? parseInt(opts.forkBlock, 10) : undefined, + chainId: opts.chainId ? parseInt(opts.chainId, 10) : undefined, + limits: { timeoutMs }, + signal, + }; + + const report = await runValidationPlan(scenarios, runOpts); + + const output = opts.format === "markdown" + ? generateValidationMarkdown(report) + : serializeValidationReport(report); + + if (opts.output) { + writeOutput(opts.output, output); + console.error(chalk.green(`\n Report written to ${opts.output}`)); + } else { + writeOutput(undefined, output); + } + + console.error( + `\n ${chalk.green(report.passed)} passed, ` + + `${chalk.red(report.failed)} failed, ` + + `${chalk.yellow(report.errored)} errored ` + + `(${report.total} total, ${report.totalDurationMs}ms)`, + ); + + if (opts.failOnFailure && (report.failed > 0 || report.errored > 0)) { + process.exit(1); + } + } catch (err) { + console.error(chalk.red(`validate run failed: ${sanitizeCliError(err)}`)); + process.exit(2); + } + }); + + // ─── validate replay ──────────────────────────────────────────────────────── + validate + .command("replay ") + .description( + "Restore a snapshot from a previous ValidationResult and replay the scenario.", + ) + .option("--adapter ", "EVM backend: anvil|hardhat") + .option("--adapter-bin ", "Explicit path to the adapter binary") + .option("--fork-url ", "Fork RPC URL (required if scenario was forked)") + .option("--output ", "Write the replay result to a file") + .option("--format ", "Output format: json|markdown", "json") + .action(async (resultFile: string, opts: { + adapter?: string; + adapterBin?: string; + forkUrl?: string; + output?: string; + format?: string; + }) => { + try { + const content = fs.readFileSync(resultFile, "utf8"); + let result: ValidationResult; + try { + result = JSON.parse(content) as ValidationResult; + } catch { + throw new CorruptBundleError(resultFile, "Invalid JSON"); + } + + if (!result.scenario) { + throw new CorruptBundleError(resultFile, "Missing scenario in result"); + } + + const adapterType = await detectAdapter( + opts.adapter ?? result.adapterType, + opts.adapterBin, + ); + console.error(chalk.cyan(` Replaying with ${adapterType} adapter...`)); + + const adapterOpts = { + binaryPath: opts.adapterBin, + forkUrl: opts.forkUrl ?? (result.scenario.chain?.forkUrl !== "[redacted]" ? result.scenario.chain?.forkUrl : undefined), + forkBlockNumber: result.snapshotBlock > 0 ? result.snapshotBlock : undefined, + chainId: result.scenario.chain?.chainId, + }; + + const adapter = adapterType === "hardhat" + ? new HardhatAdapter(adapterOpts) + : new AnvilAdapter(adapterOpts); + + try { + await adapter.start(); + const runner = new ValidationRunner(adapter); + // Re-run the scenario from scratch (snapshot from original run may be unavailable) + const replayResult = await runner.run(result.scenario); + + const output = opts.format === "markdown" + ? `# Replay Result\n\n${generateValidationMarkdown({ + schemaVersion: replayResult.schemaVersion, + timestamp: new Date().toISOString(), + total: 1, + passed: replayResult.outcomeMatched ? 1 : 0, + failed: replayResult.outcomeMatched ? 0 : 1, + errored: replayResult.error ? 1 : 0, + results: [replayResult], + adapterType, + totalDurationMs: replayResult.durationMs, + })}` + : JSON.stringify(replayResult, null, 2); + + writeOutput(opts.output, output); + if (opts.output) { + console.error(chalk.green(` Replay result written to ${opts.output}`)); + } + console.error( + replayResult.outcomeMatched + ? chalk.green(" ✅ Replay: PASSED") + : chalk.red(" ❌ Replay: FAILED"), + ); + } finally { + await adapter.dispose().catch(() => {/* ignore */}); + } + } catch (err) { + console.error(chalk.red(`validate replay failed: ${sanitizeCliError(err)}`)); + process.exit(2); + } + }); + + // ─── validate minimize ────────────────────────────────────────────────────── + validate + .command("minimize ") + .description("Remove redundant calls from a scenario while preserving the outcome.") + .option("--adapter ", "EVM backend: anvil|hardhat") + .option("--adapter-bin ", "Explicit path to the adapter binary") + .option("--fork-url ", "Fork RPC URL") + .option("--max-trials ", "Maximum scenario re-executions", "50") + .option("--output ", "Write the minimized scenario to a file") + .action(async (scenarioFile: string, opts: { + adapter?: string; + adapterBin?: string; + forkUrl?: string; + maxTrials?: string; + output?: string; + }) => { + try { + const scenario = loadScenario(scenarioFile); + const adapterType = await detectAdapter(opts.adapter, opts.adapterBin); + console.error(chalk.cyan(` Minimizing with ${adapterType} adapter...`)); + console.error(` Original: ${scenario.calls.length} call(s)`); + + const adapterOpts = { + binaryPath: opts.adapterBin, + forkUrl: opts.forkUrl ?? scenario.chain.forkUrl, + forkBlockNumber: scenario.chain.forkBlockNumber, + chainId: scenario.chain.chainId, + }; + + const adapter = adapterType === "hardhat" + ? new HardhatAdapter(adapterOpts) + : new AnvilAdapter(adapterOpts); + + try { + await adapter.start(); + const maxTrials = opts.maxTrials ? parseInt(opts.maxTrials, 10) : 50; + const minResult = await minimizeScenario(scenario, adapter, { maxTrials }); + + console.error(` Minimized: ${minResult.minimizedCallCount} call(s) (removed ${minResult.originalCallCount - minResult.minimizedCallCount})`); + console.error(` Trials used: ${minResult.trialsUsed}`); + if (minResult.budgetExceeded) { + console.error(chalk.yellow(" ⚠ Trial budget exceeded — minimization incomplete")); + } + + const json = JSON.stringify(minResult.minimizedScenario, null, 2); + writeOutput(opts.output, json); + if (opts.output) { + console.error(chalk.green(` Minimized scenario written to ${opts.output}`)); + } + } finally { + await adapter.dispose().catch(() => {/* ignore */}); + } + } catch (err) { + console.error(chalk.red(`validate minimize failed: ${sanitizeCliError(err)}`)); + process.exit(2); + } + }); + + // ─── validate report ──────────────────────────────────────────────────────── + validate + .command("report ") + .description("Format a saved ValidationReport as Markdown or JSON.") + .option("--format ", "Output format: json|markdown", "markdown") + .option("--output ", "Write the report to a file") + .option("--fail-on-failure", "Exit 1 if any scenario failed or errored") + .action(async (reportFile: string, opts: { + format?: string; + output?: string; + failOnFailure?: boolean; + }) => { + try { + const content = fs.readFileSync(reportFile, "utf8"); + const report = parseValidationReport(content, reportFile); + + const output = opts.format === "json" + ? serializeValidationReport(report) + : generateValidationMarkdown(report); + + writeOutput(opts.output, output); + if (opts.output) { + console.error(chalk.green(` Report written to ${opts.output}`)); + } + + if (opts.failOnFailure && (report.failed > 0 || report.errored > 0)) { + process.exit(1); + } + } catch (err) { + console.error(chalk.red(`validate report failed: ${sanitizeCliError(err)}`)); + process.exit(2); + } + }); +} diff --git a/packages/core/src/__tests__/validation-adversarial.test.ts b/packages/core/src/__tests__/validation-adversarial.test.ts new file mode 100644 index 0000000..ac13e2f --- /dev/null +++ b/packages/core/src/__tests__/validation-adversarial.test.ts @@ -0,0 +1,540 @@ +/** + * Adversarial and edge-case tests for the validation engine. + * + * Tests cover: + * - Fork unavailability (adapter fails to start) + * - RPC inconsistency (adapter returns unexpected responses) + * - Process crash simulation + * - Malicious / oversized input + * - Replay integrity (scenario IDs are stable) + * - Boundary conditions (max values, empty arrays, missing fields) + * - Error sanitization (no secrets leak in error messages) + */ + +import { + ValidationError, + ValidationTimeoutError, + AdapterCrashError, + ForkUnavailableError, + CorruptBundleError, + ScenarioValidationError, + createCancellationSignal, + resolveResourceLimits, + sanitizeErrorMessage, + planValidation, + serializeValidationPlan, + parseValidationPlan, + serializeValidationReport, + parseValidationReport, + generateValidationMarkdown, + ValidationRunner, + sanitizeScenario, + minimizeScenario, + DEFAULT_RESOURCE_LIMITS, + VALIDATION_SCHEMA_VERSION, + normalizeHex, + keccak256Pure, + keccak256Selector, + encodeFunctionCall, + hexToDecimalString, +} from "../validation"; +import type { + EvmAdapter, + CallResult, + CallSpec, + ResolvedResourceLimits, + ValidationCancellationSignal, + ValidationScenario, +} from "../validation"; +import type { Finding } from "../types"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeScenario(overrides: Partial = {}): ValidationScenario { + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + id: "scenario-adversarial-001", + title: "Adversarial test scenario", + chain: { chainId: 31337 }, + accounts: [ + { address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", balance: "10000000000000000000", label: "deployer" }, + ], + contracts: [], + calls: [{ to: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", signature: "noop()", from: "deployer" }], + expectedOutcome: "exploit-succeeds", + ...overrides, + }; +} + +/** Adapter that throws on every call */ +class AlwaysFailingAdapter implements EvmAdapter { + readonly type = "anvil" as const; + readonly version = "mock/failing"; + readonly rpcUrl = "http://127.0.0.1:1"; + async start(): Promise { + throw new AdapterCrashError("anvil", "process failed to start"); + } + async dispose(): Promise { /* no-op */ } + async setupAccount(): Promise { throw new AdapterCrashError("anvil", "crash"); } + async deployContract(): Promise { throw new AdapterCrashError("anvil", "crash"); } + async executeCall(): Promise { throw new AdapterCrashError("anvil", "crash"); } + async getStorageAt(): Promise { throw new AdapterCrashError("anvil", "crash"); } + async getBalance(): Promise { throw new AdapterCrashError("anvil", "crash"); } + async getBlockNumber(): Promise { throw new AdapterCrashError("anvil", "crash"); } + async snapshot(): Promise { throw new AdapterCrashError("anvil", "crash"); } + async revertToSnapshot(): Promise { throw new AdapterCrashError("anvil", "crash"); } + async setNextBlockTimestamp(): Promise { throw new AdapterCrashError("anvil", "crash"); } + async mine(): Promise { throw new AdapterCrashError("anvil", "crash"); } + async setStorageAt(): Promise { throw new AdapterCrashError("anvil", "crash"); } +} + +/** Adapter that hangs indefinitely on executeCall */ +class HangingAdapter implements EvmAdapter { + readonly type = "anvil" as const; + readonly version = "mock/hanging"; + readonly rpcUrl = "http://127.0.0.1:1"; + async start(): Promise { /* no-op */ } + async dispose(): Promise { /* no-op */ } + async setupAccount(): Promise { /* no-op */ } + async deployContract(): Promise { return "0x1000000000000000000000000000000000001000"; } + async executeCall(_: CallSpec, __: Map, ___: ResolvedResourceLimits, signal?: ValidationCancellationSignal): Promise { + // Simulate a hanging call by waiting forever (or until cancelled) + return new Promise((_, reject) => { + if (signal) { + signal.onCancelled(() => reject(new ValidationError("Cancelled", "TIMEOUT"))); + } + setTimeout(() => reject(new ValidationError("Timeout", "TIMEOUT")), 60_000); + }); + } + async getStorageAt(): Promise { return normalizeHex("0x0"); } + async getBalance(): Promise { return "0"; } + async getBlockNumber(): Promise { return 1; } + async snapshot(): Promise { return "snap-1"; } + async revertToSnapshot(): Promise { /* no-op */ } + async setNextBlockTimestamp(): Promise { /* no-op */ } + async mine(): Promise { /* no-op */ } + async setStorageAt(): Promise { /* no-op */ } +} + +/** Adapter returning RPC errors */ +class RpcErrorAdapter implements EvmAdapter { + readonly type = "anvil" as const; + readonly version = "mock/rpc-error"; + readonly rpcUrl = "http://127.0.0.1:1"; + async start(): Promise { /* no-op */ } + async dispose(): Promise { /* no-op */ } + async setupAccount(): Promise { /* no-op */ } + async deployContract(): Promise { return "0x1000000000000000000000000000000000001001"; } + async executeCall(): Promise { + throw new ValidationError("eth_sendTransaction: nonce too high", "RPC_ERROR"); + } + async getStorageAt(): Promise { return normalizeHex("0x0"); } + async getBalance(): Promise { return "0"; } + async getBlockNumber(): Promise { return 1; } + async snapshot(): Promise { return "snap-1"; } + async revertToSnapshot(): Promise { /* no-op */ } + async setNextBlockTimestamp(): Promise { /* no-op */ } + async mine(): Promise { /* no-op */ } + async setStorageAt(): Promise { /* no-op */ } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("fork unavailability", () => { + it("ValidationRunner records infrastructure error when adapter crashes mid-run", async () => { + const adapter = new RpcErrorAdapter(); + const runner = new ValidationRunner(adapter); + const scenario = makeScenario({ calls: [{ to: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", signature: "noop()" }] }); + const result = await runner.run(scenario); + // Should not throw; error is captured in result.error + expect(result.error).toBeDefined(); + expect(result.outcomeMatched).toBe(false); + }); + + it("AdapterCrashError message does not leak paths", () => { + const e = new AdapterCrashError("anvil", "/home/user/private/key.txt: No such file"); + expect(e.message).not.toContain("/home/user"); + }); + + it("ForkUnavailableError message does not leak RPC URL", () => { + const e = new ForkUnavailableError("ECONNREFUSED https://mainnet.infura.io/v3/SECRET_KEY"); + expect(e.message).not.toContain("SECRET_KEY"); + }); +}); + +describe("RPC inconsistency", () => { + it("RPC error is captured in result.error, not thrown", async () => { + const adapter = new RpcErrorAdapter(); + const runner = new ValidationRunner(adapter); + const scenario = makeScenario(); + const result = await runner.run(scenario); + expect(result.error).toBeDefined(); + expect(typeof result.error).toBe("string"); + }); + + it("RPC error message is sanitized in result", async () => { + const adapter = new RpcErrorAdapter(); + const runner = new ValidationRunner(adapter); + const scenario = makeScenario(); + const result = await runner.run(scenario); + // Should not contain raw paths or secrets + expect(result.error).not.toContain("/home/"); + }); +}); + +describe("cancellation / hanging adapter", () => { + it("cancellation signal stops the run and returns an error result", async () => { + const adapter = new HangingAdapter(); + const { signal, cancel } = createCancellationSignal(); + const runner = new ValidationRunner(adapter, { signal }); + const scenario = makeScenario(); + + // Cancel after a tiny delay + setTimeout(() => cancel(), 10); + const result = await runner.run(scenario); + expect(result.error).toBeDefined(); + }, 10_000); + + it("pre-cancelled signal prevents calls from being made", async () => { + const adapter = new HangingAdapter(); + const { signal, cancel } = createCancellationSignal(); + cancel(); // cancel BEFORE run + const runner = new ValidationRunner(adapter, { signal }); + const scenario = makeScenario(); + const result = await runner.run(scenario); + expect(result.error).toBeDefined(); + }); +}); + +describe("malicious / oversized input", () => { + it("rejects scenario with too many calls (limits.maxCalls)", async () => { + class OkAdapter implements EvmAdapter { + readonly type = "anvil" as const; + readonly version = "mock/ok"; + readonly rpcUrl = "http://127.0.0.1:1"; + async start(): Promise { /* no-op */ } + async dispose(): Promise { /* no-op */ } + async setupAccount(): Promise { /* no-op */ } + async deployContract(): Promise { return "0x1000000000000000000000000000000000001002"; } + async executeCall(): Promise { + return { callIndex: 0, reverted: false, returnData: "0x", gasUsed: 21000, logs: [], storageDiff: [] }; + } + async getStorageAt(): Promise { return normalizeHex("0x0"); } + async getBalance(): Promise { return "0"; } + async getBlockNumber(): Promise { return 1; } + async snapshot(): Promise { return "snap-1"; } + async revertToSnapshot(): Promise { /* no-op */ } + async setNextBlockTimestamp(): Promise { /* no-op */ } + async mine(): Promise { /* no-op */ } + async setStorageAt(): Promise { /* no-op */ } + } + + const adapter = new OkAdapter(); + const runner = new ValidationRunner(adapter, { limits: { maxCalls: 5 } }); + const scenario = makeScenario({ + calls: Array.from({ length: 10 }, () => ({ to: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", signature: "noop()" })), + }); + await expect(runner.run(scenario)).rejects.toThrow(ValidationError); + }); + + it("planValidation handles 10,000 findings without throwing", () => { + const findings: Finding[] = Array.from({ length: 10_000 }, (_, i) => ({ + id: i % 2 === 0 ? "CP-107" : "CP-115", + title: "Test", + description: "Test", + recommendation: "Fix", + severity: "critical", + file: `contracts/File${i}.sol`, + line: 1, + } as Finding)); + // Should complete without OOM or timeout + const plan = planValidation(findings, { deduplicateByFile: true }); + expect(plan.scenarios.length).toBeGreaterThan(0); + }); + + it("sanitizeErrorMessage handles null-like input gracefully", () => { + expect(sanitizeErrorMessage("")).toBe(""); + expect(sanitizeErrorMessage("normal message")).toBe("normal message"); + }); + + it("parseValidationPlan rejects excessively nested objects gracefully", () => { + // Build a deeply nested JSON object + let nested: unknown = "leaf"; + for (let i = 0; i < 100; i++) { + nested = { value: nested }; + } + const json = JSON.stringify({ schemaVersion: "1.0.0", scenarios: [nested] }); + // Should not throw an unhandled error (may throw CorruptBundleError or return partially parsed) + expect(() => parseValidationPlan(json)).not.toThrow(TypeError); + }); + + it("generateValidationMarkdown handles scenario with XSS-like content in title", () => { + const finding: Finding = { + id: "CP-107", + title: 'Reentrancy ', + description: "Test", + recommendation: "Fix", + severity: "critical", + file: "contracts/Vault.sol", + line: 1, + }; + const plan = planValidation([finding]); + const report = { + schemaVersion: VALIDATION_SCHEMA_VERSION, + timestamp: "2024-01-01T00:00:00.000Z", + total: 0, + passed: 0, + failed: 0, + errored: 0, + results: [], + adapterType: "anvil" as const, + totalDurationMs: 0, + }; + const md = generateValidationMarkdown(report); + // Should not throw; markdown escaping handles special chars + expect(typeof md).toBe("string"); + }); + + it("scenario with empty calls array runs and records no callResults", async () => { + class OkAdapter2 implements EvmAdapter { + readonly type = "anvil" as const; + readonly version = "mock/ok2"; + readonly rpcUrl = "http://127.0.0.1:1"; + async start(): Promise { /* no-op */ } + async dispose(): Promise { /* no-op */ } + async setupAccount(): Promise { /* no-op */ } + async deployContract(): Promise { return "0x1000000000000000000000000000000000001003"; } + async executeCall(): Promise { + return { callIndex: 0, reverted: false, returnData: "0x", gasUsed: 21000, logs: [], storageDiff: [] }; + } + async getStorageAt(): Promise { return normalizeHex("0x0"); } + async getBalance(): Promise { return "0"; } + async getBlockNumber(): Promise { return 1; } + async snapshot(): Promise { return "snap-1"; } + async revertToSnapshot(): Promise { /* no-op */ } + async setNextBlockTimestamp(): Promise { /* no-op */ } + async mine(): Promise { /* no-op */ } + async setStorageAt(): Promise { /* no-op */ } + } + + const adapter = new OkAdapter2(); + const runner = new ValidationRunner(adapter); + const scenario = makeScenario({ calls: [] }); + const result = await runner.run(scenario); + expect(result.callResults).toHaveLength(0); + }); +}); + +describe("replay integrity", () => { + it("scenario IDs are stable across multiple planValidation calls", () => { + const finding: Finding = { + id: "CP-107", + title: "Reentrancy", + description: "Test", + recommendation: "Fix", + severity: "critical", + file: "contracts/Vault.sol", + line: 42, + }; + const plan1 = planValidation([finding]); + const plan2 = planValidation([finding]); + expect(plan1.scenarios[0].id).toBe(plan2.scenarios[0].id); + }); + + it("serialized plan is identical for identical input (deterministic)", () => { + const finding: Finding = { + id: "CP-107", + title: "Reentrancy", + description: "Test", + recommendation: "Fix", + severity: "critical", + file: "contracts/Vault.sol", + line: 42, + }; + const plan1 = planValidation([finding]); + const plan2 = planValidation([finding]); + const json1 = serializeValidationPlan(plan1); + const json2 = serializeValidationPlan(plan2); + // createdAt timestamps will differ; strip them for comparison + const strip = (s: string) => s.replace(/"createdAt":\s*"[^"]+"/g, '"createdAt":"REDACTED"'); + expect(strip(json1)).toBe(strip(json2)); + }); +}); + +describe("boundary conditions", () => { + it("resolveResourceLimits handles zero values by using defaults", () => { + // Zero values passed through — they override defaults + const r = resolveResourceLimits({ timeoutMs: 0 }); + expect(r.timeoutMs).toBe(0); // explicitly overridden to 0 + }); + + it("normalizeHex pads 0x0 to 64 chars", () => { + const result = normalizeHex("0x0"); + expect(result).toHaveLength(66); // 0x + 64 chars + expect(result).toBe("0x" + "0".repeat(64)); + }); + + it("normalizeHex handles values without 0x prefix", () => { + const result = normalizeHex("ff"); + expect(result).toBe("0x" + "ff".padStart(64, "0")); + }); + + it("hexToDecimalString handles very large values (uint256.max)", () => { + const uint256Max = "0x" + "f".repeat(64); + const decimal = hexToDecimalString(uint256Max); + expect(decimal).toBe( + "115792089237316195423570985008687907853269984665640564039457584007913129639935", + ); + }); + + it("keccak256Pure returns 64-hex-char output for any input", () => { + const cases = [ + Buffer.from(""), + Buffer.from("a"), + Buffer.from("a".repeat(200)), + Buffer.alloc(136), // exactly one keccak block + Buffer.alloc(137), // just over one block + ]; + for (const input of cases) { + const result = keccak256Pure(input); + expect(result).toMatch(/^[0-9a-f]{64}$/); + } + }); + + it("encodeFunctionCall handles BigInt.MAX_SAFE_INTEGER", () => { + expect(() => + encodeFunctionCall("transfer(address,uint256)", [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + BigInt("115792089237316195423570985008687907853269984665640564039457584007913129639935"), + ]), + ).not.toThrow(); + }); + + it("scenario with no accounts still runs (uses defaults)", async () => { + class MinimalOkAdapter implements EvmAdapter { + readonly type = "anvil" as const; + readonly version = "mock/minimal"; + readonly rpcUrl = "http://127.0.0.1:1"; + async start(): Promise { /* no-op */ } + async dispose(): Promise { /* no-op */ } + async setupAccount(): Promise { /* no-op */ } + async deployContract(): Promise { return "0x1000000000000000000000000000000000001004"; } + async executeCall(): Promise { + return { callIndex: 0, reverted: false, returnData: "0x", gasUsed: 21000, logs: [], storageDiff: [] }; + } + async getStorageAt(): Promise { return normalizeHex("0x0"); } + async getBalance(): Promise { return "0"; } + async getBlockNumber(): Promise { return 1; } + async snapshot(): Promise { return "snap-1"; } + async revertToSnapshot(): Promise { /* no-op */ } + async setNextBlockTimestamp(): Promise { /* no-op */ } + async mine(): Promise { /* no-op */ } + async setStorageAt(): Promise { /* no-op */ } + } + + const adapter = new MinimalOkAdapter(); + const runner = new ValidationRunner(adapter); + const scenario = makeScenario({ accounts: [] }); + const result = await runner.run(scenario); + expect(result).toBeDefined(); + // Should not throw + }); +}); + +describe("error sanitization / no secrets in output", () => { + it("sanitizeScenario does not include API keys in result", () => { + const scenario = makeScenario({ + chain: { chainId: 1, forkUrl: "https://eth-mainnet.g.alchemy.com/v2/SECRET_API_KEY" }, + }); + const s = sanitizeScenario(scenario); + const json = JSON.stringify(s); + expect(json).not.toContain("SECRET_API_KEY"); + }); + + it("scenario ID does not embed file path components", () => { + const plan = planValidation([{ + id: "CP-107", + title: "Test", + description: "", + recommendation: "", + severity: "critical", + file: "/home/user/private_project/contracts/Secret.sol", + line: 1, + } as Finding]); + const id = plan.scenarios[0]?.id ?? ""; + expect(id).not.toContain("private_project"); + expect(id).not.toContain("/home/user"); + }); + + it("CorruptBundleError only shows basename of file path", () => { + const e = new CorruptBundleError("/home/user/secret/plan.json", "bad format"); + expect(e.message).not.toContain("/home/user/secret"); + expect(e.message).toContain("plan.json"); + }); + + it("ValidationError context is not automatically included in message", () => { + const e = new ValidationError("test error", "TIMEOUT", { secretKey: "sk-secret123" }); + expect(e.message).toBe("test error"); + expect(e.message).not.toContain("sk-secret123"); + }); +}); + +describe("supported finding IDs coverage", () => { + const supportedFindings: { id: string; severity: "critical" | "high" | "medium" | "low" }[] = [ + { id: "CP-107", severity: "critical" }, + { id: "SWC-107", severity: "critical" }, + { id: "CP-107-X", severity: "critical" }, + { id: "CP-115", severity: "high" }, + { id: "SWC-115", severity: "high" }, + { id: "CP-101", severity: "high" }, + { id: "SWC-101", severity: "high" }, + { id: "CP-104", severity: "medium" }, + { id: "SWC-104", severity: "medium" }, + { id: "CP-122", severity: "high" }, + { id: "CP-CB-CEI", severity: "critical" }, + { id: "CP-CB-CROSSFN", severity: "critical" }, + { id: "CP-CB-READONLY", severity: "high" }, + { id: "CP-CB-SPOOF", severity: "high" }, + { id: "CP-CB-BATCH", severity: "medium" }, + ]; + + for (const { id, severity } of supportedFindings) { + it(`${id} generates exactly one scenario`, () => { + const finding: Finding = { + id, + title: "Test", + description: "", + recommendation: "", + severity, + file: "contracts/Test.sol", + line: 1, + }; + const plan = planValidation([finding], { deduplicateByFile: true }); + expect(plan.scenarios.length).toBe(1); + expect(plan.unsupportedFindings.length).toBe(0); + expect(plan.scenarios[0].schemaVersion).toBe(VALIDATION_SCHEMA_VERSION); + }); + } + + it("unsupported finding IDs are correctly reported", () => { + const unsupportedIds = ["GAS-001", "SLITHER-arbitrary-send", "CUSTOM-999"]; + for (const id of unsupportedIds) { + const finding: Finding = { + id, + title: "Test", + description: "", + recommendation: "", + severity: "high", + file: "contracts/Test.sol", + line: 1, + }; + const plan = planValidation([finding]); + // GAS findings are excluded silently; others go to unsupported + if (id.startsWith("GAS-")) { + expect(plan.unsupportedFindings.length).toBe(0); + } else { + expect(plan.unsupportedFindings.length).toBeGreaterThan(0); + } + } + }); +}); diff --git a/packages/core/src/__tests__/validation.test.ts b/packages/core/src/__tests__/validation.test.ts new file mode 100644 index 0000000..1195de8 --- /dev/null +++ b/packages/core/src/__tests__/validation.test.ts @@ -0,0 +1,920 @@ +/** + * Unit and integration tests for the validation engine. + * + * These tests cover: + * - Core types and utilities (pure unit tests) + * - Scaffold/planning from static findings + * - ValidationRunner with a mock adapter + * - Report generation + * - Serialization/deserialization round-trips + * - Minimizer logic + * - Cancellation + */ + +import * as fs from "fs"; +import * as path from "path"; +import { scan } from "../scanner"; +import { + // Constants + DEFAULT_RESOURCE_LIMITS, + VALIDATION_SCHEMA_VERSION, + // Errors + ValidationError, + ValidationTimeoutError, + AdapterCrashError, + ForkUnavailableError, + CorruptBundleError, + ScenarioValidationError, + // Cancellation + createCancellationSignal, + resolveResourceLimits, + sanitizeErrorMessage, + // Adapter utilities + encodeFunctionCall, + keccak256Selector, + keccak256Pure, + hexToDecimalString, + normalizeHex, + // Planning + planValidation, + serializeValidationPlan, + parseValidationPlan, + // Reports + serializeValidationReport, + generateValidationMarkdown, + parseValidationReport, + // Runner + ValidationRunner, + sanitizeScenario, + minimizeScenario, +} from "../validation"; +import type { + CallResult, + CallSpec, + EvmAdapter, + ResolvedResourceLimits, + ValidationCancellationSignal, + ValidationResult, + ValidationScenario, +} from "../validation"; +import { Finding } from "../types"; + +// ─── Mock adapter ───────────────────────────────────────────────────────────── + +/** + * Minimal mock EVM adapter for unit tests. + * Does not spawn any process; returns configurable results. + */ +class MockEvmAdapter implements EvmAdapter { + readonly type = "anvil" as const; + version = "anvil/mock-1.0.0"; + rpcUrl = "http://127.0.0.1:9999"; + + private _snapshotCounter = 0; + private _blockNumber = 1; + private _storage: Map> = new Map(); + private _balances: Map = new Map(); + private _deployCounter = 0; + + // Configurable responses + callShouldRevert = false; + callRevertReason: string | undefined = undefined; + deployedAddresses: string[] = []; + + async start(): Promise { /* no-op */ } + async dispose(): Promise { /* no-op */ } + + async setupAccount(account: { address: string; balance?: string }): Promise { + if (account.balance) { + this._balances.set(account.address.toLowerCase(), account.balance); + } + } + + async deployContract(spec: { name: string; bytecode?: string }, _deployer: string): Promise { + const address = this.deployedAddresses[this._deployCounter] ?? + `0x${(0x1000 + this._deployCounter).toString(16).padStart(40, "0")}`; + this._deployCounter++; + return address; + } + + async executeCall( + spec: CallSpec, + _resolvedAddresses: Map, + _limits: ResolvedResourceLimits, + _signal?: ValidationCancellationSignal, + ): Promise { + return { + callIndex: 0, + reverted: this.callShouldRevert, + revertReason: this.callShouldRevert ? (this.callRevertReason ?? "reverted") : undefined, + returnData: "0x", + gasUsed: 21_000, + logs: [], + storageDiff: [], + }; + } + + async getStorageAt(address: string, slot: string): Promise { + const contractStorage = this._storage.get(address.toLowerCase()); + return contractStorage?.get(slot) ?? normalizeHex("0x0"); + } + + async getBalance(address: string): Promise { + return this._balances.get(address.toLowerCase()) ?? "0"; + } + + async getBlockNumber(): Promise { + return this._blockNumber; + } + + async snapshot(): Promise { + return `snap-${++this._snapshotCounter}`; + } + + async revertToSnapshot(_snapshotId: string): Promise { /* no-op */ } + + async setNextBlockTimestamp(_ts: number): Promise { /* no-op */ } + + async mine(_count?: number): Promise { + this._blockNumber++; + } + + async setStorageAt(address: string, slot: string, value: string): Promise { + if (!this._storage.has(address.toLowerCase())) { + this._storage.set(address.toLowerCase(), new Map()); + } + this._storage.get(address.toLowerCase())!.set(slot, value); + } + + // Test helpers + setBalance(address: string, wei: string): void { + this._balances.set(address.toLowerCase(), wei); + } + + setStorage(address: string, slot: string, value: string): void { + if (!this._storage.has(address.toLowerCase())) { + this._storage.set(address.toLowerCase(), new Map()); + } + this._storage.get(address.toLowerCase())!.set(slot, value); + } +} + +// ─── Sample scenarios ───────────────────────────────────────────────────────── + +function makeSampleScenario(overrides: Partial = {}): ValidationScenario { + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + id: "scenario-test-001", + title: "Test scenario", + chain: { chainId: 31337 }, + accounts: [ + { address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", balance: "10000000000000000000", label: "deployer" }, + { address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", balance: "10000000000000000000", label: "attacker" }, + ], + contracts: [ + { name: "Vault", bytecode: "0x608060405234801561001057600080fd5b50", abi: "[]", deployer: "deployer" }, + ], + calls: [ + { to: "Vault", signature: "deposit()", value: "1000000000000000000", from: "deployer" }, + { to: "Vault", signature: "withdraw(uint256)", args: [1000000000000000000n], from: "attacker" }, + ], + expectedOutcome: "exploit-succeeds", + ...overrides, + }; +} + +function makeSampleFinding(overrides: Partial = {}): Finding { + return { + id: "CP-107", + title: "Reentrancy", + description: "External call before state update", + recommendation: "Use CEI pattern", + severity: "critical", + file: "contracts/Vault.sol", + line: 42, + ...overrides, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("validation/types — constants", () => { + it("VALIDATION_SCHEMA_VERSION is a semver string", () => { + expect(VALIDATION_SCHEMA_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("DEFAULT_RESOURCE_LIMITS has sane values", () => { + expect(DEFAULT_RESOURCE_LIMITS.timeoutMs).toBeGreaterThan(0); + expect(DEFAULT_RESOURCE_LIMITS.maxCalls).toBeGreaterThan(0); + expect(DEFAULT_RESOURCE_LIMITS.maxGasPerCall).toBeGreaterThan(0); + expect(DEFAULT_RESOURCE_LIMITS.maxLogs).toBeGreaterThan(0); + expect(DEFAULT_RESOURCE_LIMITS.maxMemoryBytes).toBeGreaterThan(0); + }); +}); + +describe("resolveResourceLimits", () => { + it("uses defaults when no overrides given", () => { + const r = resolveResourceLimits({}); + expect(r.timeoutMs).toBe(DEFAULT_RESOURCE_LIMITS.timeoutMs); + expect(r.maxCalls).toBe(DEFAULT_RESOURCE_LIMITS.maxCalls); + }); + + it("scenario overrides take precedence over adapter overrides", () => { + const r = resolveResourceLimits({ timeoutMs: 5_000 }, { timeoutMs: 10_000 }); + expect(r.timeoutMs).toBe(5_000); + }); + + it("adapter overrides take precedence over defaults", () => { + const r = resolveResourceLimits({}, { maxCalls: 42 }); + expect(r.maxCalls).toBe(42); + }); +}); + +describe("sanitizeErrorMessage", () => { + it("redacts http URLs", () => { + expect(sanitizeErrorMessage("Error connecting to https://mainnet.infura.io/v3/abc123")).not.toContain("infura"); + }); + + it("redacts file paths", () => { + expect(sanitizeErrorMessage("Cannot find /home/user/secret/path/file.ts")).not.toContain("/home/user"); + }); + + it("redacts long hex strings", () => { + expect(sanitizeErrorMessage("Key: 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")).not.toContain("deadbeef"); + }); + + it("truncates long messages to 500 chars", () => { + const long = "x".repeat(1000); + expect(sanitizeErrorMessage(long).length).toBeLessThanOrEqual(500); + }); +}); + +describe("keccak256Pure", () => { + it("produces correct keccak256 for empty input", () => { + // keccak256("") = c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 + const result = keccak256Pure(Buffer.from("")); + expect(result).toBe("c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"); + }); + + it("produces correct keccak256 for 'hello'", () => { + // keccak256("hello") = 1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8 + const result = keccak256Pure(Buffer.from("hello", "utf8")); + expect(result).toBe("1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8"); + }); +}); + +describe("keccak256Selector", () => { + it("produces correct 4-byte selector for transfer(address,uint256)", () => { + // keccak256("transfer(address,uint256)")[0:4] = a9059cbb + const selector = keccak256Selector("transfer(address,uint256)"); + expect(selector.toLowerCase()).toBe("0xa9059cbb"); + }); + + it("produces correct selector for balanceOf(address)", () => { + // 0x70a08231 + const selector = keccak256Selector("balanceOf(address)"); + expect(selector.toLowerCase()).toBe("0x70a08231"); + }); +}); + +describe("encodeFunctionCall", () => { + it("encodes function call with no args correctly (4-byte selector)", () => { + const data = encodeFunctionCall("deposit()", []); + expect(data).toMatch(/^0x[0-9a-f]{8}$/i); + }); + + it("encodes address argument as 32-byte padded value", () => { + const addr = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + const data = encodeFunctionCall("approve(address,uint256)", [addr, 100n]); + expect(data.length).toBe(2 + 8 + 64 + 64); // 0x + selector + addr + uint + }); + + it("encodes bool true as 0x...01", () => { + const data = encodeFunctionCall("setFlag(bool)", [true]); + expect(data.endsWith("01")).toBe(true); + }); + + it("encodes bool false as 0x...00", () => { + const data = encodeFunctionCall("setFlag(bool)", [false]); + expect(data.endsWith("00")).toBe(true); + }); +}); + +describe("hexToDecimalString", () => { + it("converts 0x1 to '1'", () => { + expect(hexToDecimalString("0x1")).toBe("1"); + }); + + it("converts 0xde0b6b3a7640000 (1 ETH in wei)", () => { + expect(hexToDecimalString("0xde0b6b3a7640000")).toBe("1000000000000000000"); + }); + + it("handles empty/zero", () => { + expect(hexToDecimalString("0x")).toBe("0"); + expect(hexToDecimalString("0x0")).toBe("0"); + }); +}); + +describe("normalizeHex", () => { + it("pads short hex to 32 bytes", () => { + expect(normalizeHex("0x1")).toBe("0x" + "1".padStart(64, "0")); + }); + + it("preserves already-padded values", () => { + const full = "0x" + "a".repeat(64); + expect(normalizeHex(full)).toBe(full); + }); +}); + +describe("ValidationError hierarchy", () => { + it("ValidationError has code and name", () => { + const e = new ValidationError("test", "TIMEOUT"); + expect(e.code).toBe("TIMEOUT"); + expect(e.name).toBe("ValidationError"); + expect(e.message).toBe("test"); + }); + + it("ValidationTimeoutError", () => { + const e = new ValidationTimeoutError("scenario-1", 30_000); + expect(e.code).toBe("TIMEOUT"); + expect(e.name).toBe("ValidationTimeoutError"); + expect(e.message).toContain("30000"); + }); + + it("AdapterCrashError sanitizes detail", () => { + const e = new AdapterCrashError("anvil", "anvil crashed at /home/user/secret"); + expect(e.message).not.toContain("/home/user"); + }); + + it("ForkUnavailableError", () => { + const e = new ForkUnavailableError("connection refused"); + expect(e.code).toBe("FORK_UNAVAILABLE"); + }); + + it("CorruptBundleError sanitizes path", () => { + const e = new CorruptBundleError("/home/user/secret.json", "Invalid JSON"); + expect(e.message).not.toContain("/home/user"); + expect(e.message).toContain("secret.json"); + }); +}); + +describe("createCancellationSignal", () => { + it("starts as not cancelled", () => { + const { signal } = createCancellationSignal(); + expect(signal.cancelled).toBe(false); + }); + + it("becomes cancelled after cancel()", () => { + const { signal, cancel } = createCancellationSignal(); + cancel(); + expect(signal.cancelled).toBe(true); + }); + + it("calls onCancelled callbacks when cancelled", () => { + const { signal, cancel } = createCancellationSignal(); + const cb = jest.fn(); + signal.onCancelled(cb); + cancel(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("calls onCancelled immediately if already cancelled", () => { + const { signal, cancel } = createCancellationSignal(); + cancel(); + const cb = jest.fn(); + signal.onCancelled(cb); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("is idempotent — cancel() twice does not call callbacks twice", () => { + const { signal, cancel } = createCancellationSignal(); + const cb = jest.fn(); + signal.onCancelled(cb); + cancel(); + cancel(); + expect(cb).toHaveBeenCalledTimes(1); + }); +}); + +describe("planValidation", () => { + it("generates a scenario for CP-107 reentrancy finding", () => { + const finding = makeSampleFinding({ id: "CP-107", severity: "critical" }); + const plan = planValidation([finding]); + expect(plan.schemaVersion).toBe(VALIDATION_SCHEMA_VERSION); + expect(plan.scenarios.length).toBe(1); + expect(plan.scenarios[0].findingId).toBe("CP-107"); + expect(plan.scenarios[0].tags).toContain("reentrancy"); + }); + + it("generates a scenario for CP-115 tx.origin finding", () => { + const finding = makeSampleFinding({ id: "CP-115", severity: "high" }); + const plan = planValidation([finding]); + expect(plan.scenarios.length).toBe(1); + expect(plan.scenarios[0].tags).toContain("tx-origin"); + }); + + it("generates a scenario for CP-122 vault inflation", () => { + const finding = makeSampleFinding({ id: "CP-122", severity: "high" }); + const plan = planValidation([finding]); + expect(plan.scenarios.length).toBe(1); + expect(plan.scenarios[0].tags).toContain("vault-inflation"); + }); + + it("generates a scenario for CP-CB-CEI callback violation", () => { + const finding = makeSampleFinding({ id: "CP-CB-CEI", severity: "critical" }); + const plan = planValidation([finding]); + expect(plan.scenarios.length).toBe(1); + expect(plan.scenarios[0].tags).toContain("callback-reentrancy"); + }); + + it("generates a scenario for CP-CB-SPOOF callback spoofing", () => { + const finding = makeSampleFinding({ id: "CP-CB-SPOOF", severity: "high" }); + const plan = planValidation([finding]); + expect(plan.scenarios.length).toBe(1); + expect(plan.scenarios[0].tags).toContain("callback-spoof"); + }); + + it("generates a scenario for CP-CB-BATCH", () => { + const finding = makeSampleFinding({ id: "CP-CB-BATCH", severity: "medium" }); + const plan = planValidation([finding]); + expect(plan.scenarios.length).toBe(1); + expect(plan.scenarios[0].expectedOutcome).toBe("exploit-reverts"); + }); + + it("adds unknown finding IDs to unsupportedFindings", () => { + const finding = makeSampleFinding({ id: "SLITHER-reentrancy-eth", severity: "high" }); + const plan = planValidation([finding]); + expect(plan.scenarios.length).toBe(0); + expect(plan.unsupportedFindings.length).toBe(1); + expect(plan.unsupportedFindings[0].findingId).toBe("SLITHER-reentrancy-eth"); + }); + + it("excludes gas-severity findings", () => { + const finding = makeSampleFinding({ id: "GAS-001", severity: "gas" }); + const plan = planValidation([finding]); + expect(plan.scenarios.length).toBe(0); + expect(plan.unsupportedFindings.length).toBe(0); // gas is silently excluded + }); + + it("respects minSeverity option", () => { + const findings = [ + makeSampleFinding({ id: "CP-107", severity: "critical" }), + makeSampleFinding({ id: "CP-115", severity: "high" }), + makeSampleFinding({ id: "CP-104", severity: "medium", line: 100 }), + ]; + const plan = planValidation(findings, { minSeverity: "high" }); + // Only critical and high should be included + expect(plan.scenarios.length).toBe(2); + }); + + it("deduplicates by (id, file) by default", () => { + const findings = [ + makeSampleFinding({ id: "CP-107", line: 10 }), + makeSampleFinding({ id: "CP-107", line: 20 }), + ]; + const plan = planValidation(findings); + expect(plan.scenarios.length).toBe(1); + }); + + it("does not deduplicate when deduplicateByFile=true (different lines)", () => { + const findings = [ + makeSampleFinding({ id: "CP-107", line: 10 }), + makeSampleFinding({ id: "CP-107", line: 20 }), + ]; + const plan = planValidation(findings, { deduplicateByFile: true }); + expect(plan.scenarios.length).toBe(2); + }); + + it("all scenarios have required fields", () => { + const findings = [ + makeSampleFinding({ id: "CP-107" }), + makeSampleFinding({ id: "CP-115" }), + makeSampleFinding({ id: "CP-122" }), + ]; + const plan = planValidation(findings); + for (const scenario of plan.scenarios) { + expect(scenario.schemaVersion).toBeDefined(); + expect(scenario.id).toBeDefined(); + expect(scenario.title).toBeDefined(); + expect(scenario.chain).toBeDefined(); + expect(Array.isArray(scenario.accounts)).toBe(true); + expect(Array.isArray(scenario.contracts)).toBe(true); + expect(Array.isArray(scenario.calls)).toBe(true); + expect(scenario.expectedOutcome).toBeDefined(); + } + }); + + it("scenario IDs are unique within a plan", () => { + const findings = [ + makeSampleFinding({ id: "CP-107", file: "A.sol", line: 1 }), + makeSampleFinding({ id: "CP-115", file: "B.sol", line: 2 }), + makeSampleFinding({ id: "CP-122", file: "C.sol", line: 3 }), + ]; + const plan = planValidation(findings, { deduplicateByFile: true }); + const ids = plan.scenarios.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("serializeValidationPlan / parseValidationPlan", () => { + it("round-trips a plan through JSON", () => { + const finding = makeSampleFinding({ id: "CP-107" }); + const plan = planValidation([finding]); + const json = serializeValidationPlan(plan); + const parsed = parseValidationPlan(json); + expect(parsed.schemaVersion).toBe(plan.schemaVersion); + expect(parsed.scenarios.length).toBe(plan.scenarios.length); + }); + + it("throws CorruptBundleError on invalid JSON", () => { + expect(() => parseValidationPlan("{ not valid json")).toThrow(); + }); + + it("throws on missing schemaVersion", () => { + expect(() => parseValidationPlan('{"scenarios":[]}')).toThrow(); + }); + + it("throws on missing scenarios array", () => { + expect(() => parseValidationPlan('{"schemaVersion":"1.0.0"}')).toThrow(); + }); +}); + +describe("sanitizeScenario", () => { + it("removes privateKey from accounts", () => { + const scenario = makeSampleScenario(); + scenario.accounts[0].privateKey = "0xdeadbeef"; + const sanitized = sanitizeScenario(scenario); + expect(sanitized.accounts[0].privateKey).toBeUndefined(); + }); + + it("replaces forkUrl with [redacted]", () => { + const scenario = makeSampleScenario({ + chain: { chainId: 1, forkUrl: "https://mainnet.infura.io/v3/secret" }, + }); + const sanitized = sanitizeScenario(scenario); + expect(sanitized.chain.forkUrl).toBe("[redacted]"); + expect(sanitized.chain.chainId).toBe(1); + }); + + it("does not modify the original scenario", () => { + const scenario = makeSampleScenario({ chain: { chainId: 1, forkUrl: "https://secret" } }); + sanitizeScenario(scenario); + expect(scenario.chain.forkUrl).toBe("https://secret"); + }); +}); + +describe("ValidationRunner with MockEvmAdapter", () => { + let adapter: MockEvmAdapter; + let runner: ValidationRunner; + + beforeEach(() => { + adapter = new MockEvmAdapter(); + runner = new ValidationRunner(adapter); + }); + + it("returns a ValidationResult with correct schema version", async () => { + const scenario = makeSampleScenario(); + const result = await runner.run(scenario); + expect(result.schemaVersion).toBe(VALIDATION_SCHEMA_VERSION); + expect(result.adapterType).toBe("anvil"); + }); + + it("runs all calls and records callResults", async () => { + const scenario = makeSampleScenario(); + const result = await runner.run(scenario); + expect(result.callResults.length).toBe(scenario.calls.length); + }); + + it("outcome exploit-succeeds passes when no calls revert and assertions pass", async () => { + adapter.callShouldRevert = false; + const scenario = makeSampleScenario({ expectedOutcome: "exploit-succeeds" }); + const result = await runner.run(scenario); + expect(result.outcomeMatched).toBe(true); + }); + + it("outcome exploit-succeeds fails when a call reverts", async () => { + adapter.callShouldRevert = true; + const scenario = makeSampleScenario({ expectedOutcome: "exploit-succeeds" }); + const result = await runner.run(scenario); + expect(result.outcomeMatched).toBe(false); + expect(result.outcomeSummary).toContain("revert"); + }); + + it("outcome exploit-reverts passes when a call reverts", async () => { + adapter.callShouldRevert = true; + const scenario = makeSampleScenario({ expectedOutcome: "exploit-reverts" }); + const result = await runner.run(scenario); + expect(result.outcomeMatched).toBe(true); + }); + + it("outcome exploit-reverts fails when no call reverts", async () => { + adapter.callShouldRevert = false; + const scenario = makeSampleScenario({ expectedOutcome: "exploit-reverts" }); + const result = await runner.run(scenario); + expect(result.outcomeMatched).toBe(false); + expect(result.outcomeSummary).toContain("did not revert"); + }); + + it("outcome secure-baseline passes when no calls revert", async () => { + adapter.callShouldRevert = false; + const scenario = makeSampleScenario({ expectedOutcome: "secure-baseline" }); + const result = await runner.run(scenario); + expect(result.outcomeMatched).toBe(true); + }); + + it("records snapshotId and snapshotBlock", async () => { + const scenario = makeSampleScenario(); + const result = await runner.run(scenario); + expect(result.snapshotId).toBeTruthy(); + expect(result.snapshotBlock).toBeGreaterThanOrEqual(0); + }); + + it("evaluates storage assertions", async () => { + const contractAddr = "0x1000000000000000000000000000000000001000"; + const slot = "0x" + "0".repeat(64); + adapter.setStorage(contractAddr, slot, normalizeHex("0x2a")); + const scenario = makeSampleScenario({ + storageAssertions: [ + { contract: contractAddr, slot, expected: "0x" + "2a".padStart(64, "0") }, + ], + }); + const result = await runner.run(scenario); + expect(result.storageAssertionResults).toHaveLength(1); + expect(result.storageAssertionResults[0].passed).toBe(true); + }); + + it("evaluates balance assertions (gt)", async () => { + const addr = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + adapter.setBalance(addr, "5000000000000000000"); // 5 ETH + const scenario = makeSampleScenario({ + balanceAssertions: [ + { account: addr, op: "gt", value: "1000000000000000000", description: "Has > 1 ETH" }, + ], + }); + const result = await runner.run(scenario); + expect(result.balanceAssertionResults[0].passed).toBe(true); + }); + + it("evaluates balance assertions (lt) — fails when balance is too high", async () => { + const addr = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + adapter.setBalance(addr, "5000000000000000000"); + const scenario = makeSampleScenario({ + balanceAssertions: [ + { account: addr, op: "lt", value: "1000000000000000000" }, + ], + }); + const result = await runner.run(scenario); + expect(result.balanceAssertionResults[0].passed).toBe(false); + }); + + it("rejects scenarios exceeding maxCalls limit", async () => { + const scenario = makeSampleScenario(); + const manyCallsScenario = { + ...scenario, + calls: Array.from({ length: 200 }, () => scenario.calls[0]), + limits: { maxCalls: 10 }, + }; + await expect(runner.run(manyCallsScenario)).rejects.toThrow(ValidationError); + }); + + it("respects cancellation signal", async () => { + const { signal, cancel } = createCancellationSignal(); + cancel(); // cancel before run + const cancelRunner = new ValidationRunner(adapter, { signal }); + const scenario = makeSampleScenario(); + // Should throw or record error + const result = await cancelRunner.run(scenario); + expect(result.error).toBeDefined(); + }); + + it("sanitizes private keys in result scenario", async () => { + const scenario = makeSampleScenario(); + scenario.accounts[0].privateKey = "0xsecretkey"; + const result = await runner.run(scenario); + expect(result.scenario.accounts[0].privateKey).toBeUndefined(); + }); + + it("strips fork URL from result scenario", async () => { + const scenario = makeSampleScenario({ + chain: { chainId: 1, forkUrl: "https://secret-rpc-url" }, + }); + const result = await runner.run(scenario); + expect(result.scenario.chain.forkUrl).toBe("[redacted]"); + }); + + it("records total gas used as sum of call results", async () => { + const scenario = makeSampleScenario(); + const result = await runner.run(scenario); + const expectedTotal = result.callResults.reduce((sum, r) => sum + r.gasUsed, 0); + expect(result.totalGasUsed).toBe(expectedTotal); + }); + + it("duration is non-negative", async () => { + const scenario = makeSampleScenario(); + const result = await runner.run(scenario); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); +}); + +describe("minimizeScenario", () => { + it("returns original scenario when outcome cannot be established", async () => { + const adapter = new MockEvmAdapter(); + adapter.callShouldRevert = true; // baseline won't match exploit-succeeds + const scenario = makeSampleScenario({ expectedOutcome: "exploit-succeeds" }); + const result = await minimizeScenario(scenario, adapter, { maxTrials: 5 }); + expect(result.minimizedCallCount).toBe(scenario.calls.length); + expect(result.removedCallIndices).toHaveLength(0); + }); + + it("removes redundant calls when possible", async () => { + const adapter = new MockEvmAdapter(); + adapter.callShouldRevert = false; + // Scenario with 3 calls; mock always succeeds so any subset also succeeds + const scenario = makeSampleScenario({ + expectedOutcome: "exploit-succeeds", + calls: [ + { to: "Vault", signature: "noop()", from: "deployer", description: "call 1" }, + { to: "Vault", signature: "noop()", from: "deployer", description: "call 2" }, + { to: "Vault", signature: "attack()", from: "attacker", description: "exploit" }, + ], + }); + const result = await minimizeScenario(scenario, adapter, { maxTrials: 20 }); + expect(result.minimizedCallCount).toBeLessThanOrEqual(scenario.calls.length); + expect(result.trialsUsed).toBeGreaterThan(0); + }); + + it("respects maxTrials budget", async () => { + const adapter = new MockEvmAdapter(); + adapter.callShouldRevert = false; + const scenario = makeSampleScenario({ + expectedOutcome: "exploit-succeeds", + calls: Array.from({ length: 10 }, (_, i) => ({ + to: "Vault", + signature: `noop${i}()`, + from: "deployer", + })), + }); + const result = await minimizeScenario(scenario, adapter, { maxTrials: 2 }); + expect(result.trialsUsed).toBeLessThanOrEqual(3); // baseline + max 2 + expect(result.budgetExceeded).toBe(true); + }); + + it("respects cancellation signal", async () => { + const adapter = new MockEvmAdapter(); + const { signal, cancel } = createCancellationSignal(); + const scenario = makeSampleScenario({ expectedOutcome: "exploit-succeeds" }); + cancel(); + const result = await minimizeScenario(scenario, adapter, { signal }); + // Should complete without throwing + expect(result).toBeDefined(); + }); +}); + +describe("report generation", () => { + function makeValidationReport(overrides: Partial = {}) { + const result: ValidationResult = { + schemaVersion: VALIDATION_SCHEMA_VERSION, + scenario: makeSampleScenario(), + adapterType: "anvil", + adapterVersion: "anvil/0.2.0", + snapshotId: "snap-1", + snapshotBlock: 100, + callResults: [ + { callIndex: 0, reverted: false, returnData: "0x", gasUsed: 21000, logs: [], storageDiff: [] }, + ], + outcomeMatched: true, + outcomeSummary: "Exploit scenario completed without reverts", + storageAssertionResults: [], + balanceAssertionResults: [], + eventAssertionResults: [], + totalGasUsed: 21000, + startedAt: "2024-01-01T00:00:00.000Z", + completedAt: "2024-01-01T00:00:01.000Z", + durationMs: 1000, + warnings: [], + }; + + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + timestamp: "2024-01-01T00:00:01.000Z", + total: 1, + passed: 1, + failed: 0, + errored: 0, + results: [result], + adapterType: "anvil" as const, + totalDurationMs: 1000, + ...overrides, + }; + } + + it("serializeValidationReport produces valid JSON", () => { + const report = makeValidationReport(); + const json = serializeValidationReport(report); + expect(() => JSON.parse(json)).not.toThrow(); + }); + + it("round-trips through parseValidationReport", () => { + const report = makeValidationReport(); + const json = serializeValidationReport(report); + const parsed = parseValidationReport(json); + expect(parsed.total).toBe(report.total); + expect(parsed.passed).toBe(report.passed); + }); + + it("generateValidationMarkdown includes summary table", () => { + const report = makeValidationReport(); + const md = generateValidationMarkdown(report); + expect(md).toContain("## Summary"); + expect(md).toContain("Passed"); + expect(md).toContain("Failed"); + }); + + it("generateValidationMarkdown includes scenario title", () => { + const report = makeValidationReport(); + const md = generateValidationMarkdown(report); + expect(md).toContain("Test scenario"); + }); + + it("generateValidationMarkdown shows error in errored results", () => { + const report = makeValidationReport({ + errored: 1, + passed: 0, + results: [{ + ...makeValidationReport().results[0], + error: "adapter crashed", + outcomeMatched: false, + }], + }); + const md = generateValidationMarkdown(report); + expect(md).toContain("Infrastructure Error"); + }); + + it("parseValidationReport throws on missing schemaVersion", () => { + expect(() => parseValidationReport('{"results":[]}')).toThrow(); + }); + + it("parseValidationReport throws on invalid JSON", () => { + expect(() => parseValidationReport("{bad json}")).toThrow(); + }); + + it("serializeValidationReport output has deterministic key order", () => { + const report1 = makeValidationReport({ passed: 1 }); + const report2 = makeValidationReport({ passed: 1 }); + // Same input → same output + expect(serializeValidationReport(report1)).toBe(serializeValidationReport(report2)); + }); +}); + +describe("validation fixtures exist", () => { + const fixtureDir = path.resolve(__dirname, "../../../../examples/contracts/validation"); + + it("ValidationVulnerableVault.sol exists", () => { + const filePath = path.join(fixtureDir, "ValidationVulnerableVault.sol"); + expect(fs.existsSync(filePath)).toBe(true); + }); + + it("ValidationSecureVault.sol exists", () => { + const filePath = path.join(fixtureDir, "ValidationSecureVault.sol"); + expect(fs.existsSync(filePath)).toBe(true); + }); + + it("ValidationReentrantAttacker.sol exists", () => { + const filePath = path.join(fixtureDir, "ValidationReentrantAttacker.sol"); + expect(fs.existsSync(filePath)).toBe(true); + }); + + it("ValidationVulnerableVault.sol is parseable as text and contains expected patterns", () => { + const content = fs.readFileSync( + path.join(fixtureDir, "ValidationVulnerableVault.sol"), + "utf8", + ); + expect(content).toContain("withdraw"); + expect(content).toContain("tx.origin"); + expect(content).toContain("balances"); + }); + + it("ValidationSecureVault.sol contains nonReentrant modifier", () => { + const content = fs.readFileSync( + path.join(fixtureDir, "ValidationSecureVault.sol"), + "utf8", + ); + expect(content).toContain("nonReentrant"); + expect(content).toContain("msg.sender"); + }); +}); + +describe("integration: planValidation → scan", () => { + it("can plan from real static findings from VulnerableVault.sol", async () => { + const vaultPath = path.resolve(__dirname, "../../../../examples/contracts/VulnerableVault.sol"); + const result = await scan({ targets: [vaultPath], useSlither: false, useLLM: false, useMetrics: false }); + const allFindings = result.files.flatMap((f: { findings: Finding[] }) => f.findings); + expect(allFindings.length).toBeGreaterThan(0); + + const plan = planValidation(allFindings); + // At least one scenario should be generated + expect(plan.scenarios.length).toBeGreaterThanOrEqual(0); // may have unsupported IDs + expect(plan.unsupportedFindings.length).toBeGreaterThanOrEqual(0); + expect(plan.createdAt).toBeDefined(); + }); +}); diff --git a/packages/core/src/governance/__tests__/api.test.ts b/packages/core/src/governance/__tests__/api.test.ts index 660d668..ad0f3d9 100644 --- a/packages/core/src/governance/__tests__/api.test.ts +++ b/packages/core/src/governance/__tests__/api.test.ts @@ -26,7 +26,7 @@ describe("governance analysis API", () => { ]); expect(serializeGovernanceReport(first)).toBe(serializeGovernanceReport(second)); expect(first.files.map((file) => file.file)).toEqual(["a.sol", "z.sol"]); - expect(serializeGovernanceReport(first)).toMatch(/^\{\n "engineVersion"/); + expect(serializeGovernanceReport(first)).toMatch(/^\{\n {2}"engineVersion"/); }); it("produces a versioned Markdown artifact with evidence and scope", () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d6bb39..51397c7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -239,3 +239,83 @@ export type { SourceRange, SourcePosition, } from "./dsl"; + +// ─── Fork-aware Concrete Validation ────────────────────────────────────────── +export { + // Types / constants + DEFAULT_RESOURCE_LIMITS, + VALIDATION_SCHEMA_VERSION, + // Errors + ValidationError, + ValidationTimeoutError, + AdapterCrashError, + ForkUnavailableError, + CorruptBundleError, + ScenarioValidationError, + // Cancellation + createCancellationSignal, + resolveResourceLimits, + sanitizeErrorMessage, + // Adapter utilities + jsonRpcCall, + waitForRpc, + encodeFunctionCall, + keccak256Selector, + keccak256Pure, + decodeLogEntries, + hexToDecimalString, + normalizeHex, + // Concrete adapters + AnvilAdapter, + isAnvilAvailable, + HardhatAdapter, + isHardhatAvailable, + // Scaffold / planning + planValidation, + serializeValidationPlan, + parseValidationPlan, + // Runner + ValidationRunner, + minimizeScenario, + runValidationPlan, + sanitizeScenario, + // Reports + serializeValidationReport, + serializeValidationResult, + generateValidationMarkdown, + generateValidationResultMarkdown, + parseValidationReport, +} from "./validation"; + +export type { + AccountSpec, + AdapterOptions, + AdapterType, + BalanceAssertion, + BalanceAssertionResult, + CallResult, + CallSpec, + ChainContext, + ContractSpec, + EventAssertion, + EventAssertionResult, + EvmAdapter, + LogEntry, + MinimizationResult, + MinimizerOptions, + PlanValidationOptions, + ResolvedResourceLimits, + RunnerOptions, + RunValidationOptions, + ScenarioResourceLimits, + SnapshotEntry, + StorageAssertion, + StorageAssertionResult, + StorageDiff, + UnsupportedFinding, + ValidationCancellationSignal, + ValidationPlan, + ValidationReport, + ValidationResult, + ValidationScenario, +} from "./validation"; diff --git a/packages/core/src/plugins.ts b/packages/core/src/plugins.ts index 3d2d703..0e3376c 100644 --- a/packages/core/src/plugins.ts +++ b/packages/core/src/plugins.ts @@ -61,6 +61,7 @@ export function loadPlugin( } // Load and validate the plugin + // eslint-disable-next-line @typescript-eslint/no-var-requires const plugin = require(modulePath); const loaded = plugin.default || plugin; diff --git a/packages/core/src/validation/adapter.ts b/packages/core/src/validation/adapter.ts new file mode 100644 index 0000000..938933e --- /dev/null +++ b/packages/core/src/validation/adapter.ts @@ -0,0 +1,355 @@ +/** + * Abstract EVM adapter interface. + * + * Implementations wrap a process-isolated EVM backend (Anvil, Hardhat Network) + * and expose a minimal JSON-RPC surface. Adapters are responsible for: + * + * - Spawning/managing the backend process with bounded resources + * - Providing snapshot/restore for deterministic replay + * - Translating JSON-RPC responses into {@link CallResult} objects + * - Cleaning up processes and temp files on dispose + * + * @remarks + * The interface is intentionally minimal. It exposes the EVM primitives + * (deploy, call, snapshot, restore) rather than high-level scenario execution. + * The {@link ValidationRunner} composes these primitives. + */ + +import type { + AccountSpec, + AdapterType, + CallResult, + CallSpec, + ContractSpec, + LogEntry, + ResolvedResourceLimits, + ScenarioResourceLimits, + StorageDiff, + ValidationCancellationSignal, +} from "./types"; + +export type { AdapterType }; + +// ─── Core adapter interface ─────────────────────────────────────────────────── + +/** + * A process-isolated EVM adapter. + * + * Implementations must be safe to `start()` once and `dispose()` once. + * Re-use after `dispose()` is not supported. All methods throw + * {@link AdapterError} on failure rather than rejecting with generic errors. + */ +export interface EvmAdapter { + /** Which backend this adapter wraps. */ + readonly type: AdapterType; + /** Backend version string, populated after `start()`. */ + readonly version: string; + /** JSON-RPC URL (e.g. `http://127.0.0.1:8545`), available after `start()`. */ + readonly rpcUrl: string; + + /** + * Start the backend process and wait until it is accepting JSON-RPC requests. + * Must be called before any other method. + */ + start(limits?: Partial): Promise; + + /** + * Gracefully stop the backend process and release all resources. + * Safe to call multiple times; subsequent calls are no-ops. + */ + dispose(): Promise; + + /** + * Fund an account and optionally configure its nonce and code. + */ + setupAccount(account: AccountSpec): Promise; + + /** + * Deploy a contract and return the deployed address. + */ + deployContract(spec: ContractSpec, deployerAddress: string): Promise; + + /** + * Execute a call and return the detailed result. + */ + executeCall( + spec: CallSpec, + resolvedAddresses: Map, + limits: ResolvedResourceLimits, + signal?: ValidationCancellationSignal, + ): Promise; + + /** + * Read a single storage slot. + */ + getStorageAt(address: string, slot: string): Promise; + + /** + * Get the native balance of an address in wei (as decimal string). + */ + getBalance(address: string): Promise; + + /** + * Get the current block number. + */ + getBlockNumber(): Promise; + + /** + * Take a snapshot and return an opaque snapshot ID. + */ + snapshot(): Promise; + + /** + * Restore to a previously taken snapshot. + * The snapshot is preserved (can be restored multiple times). + */ + revertToSnapshot(snapshotId: string): Promise; + + /** + * Set the next block timestamp (UNIX seconds). + */ + setNextBlockTimestamp(timestamp: number): Promise; + + /** + * Mine an empty block to advance chain state. + */ + mine(count?: number): Promise; + + /** + * Override a storage slot directly (for test setup). + */ + setStorageAt(address: string, slot: string, value: string): Promise; +} + +// ─── JSON-RPC client (shared by all adapters) ──────────────────────────────── + +import * as http from "http"; +import * as https from "https"; +import { URL } from "url"; +import { + AdapterCrashError, + ForkUnavailableError, + ValidationError, +} from "./types"; + +/** @internal */ +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params: unknown[]; +} + +/** @internal */ +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +let _rpcIdCounter = 1; + +/** + * Minimal JSON-RPC client using Node's built-in http/https modules. + * Does not require axios or any external dependency. + * @internal + */ +export async function jsonRpcCall( + rpcUrl: string, + method: string, + params: unknown[], + timeoutMs = 10_000, +): Promise { + const body = JSON.stringify({ + jsonrpc: "2.0", + id: _rpcIdCounter++, + method, + params, + } satisfies JsonRpcRequest); + + const parsed = new URL(rpcUrl); + const isHttps = parsed.protocol === "https:"; + const transport = isHttps ? https : http; + + return new Promise((resolve, reject) => { + const options: http.RequestOptions = { + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + }; + + const req = transport.request(options, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf8"); + const response = JSON.parse(raw) as JsonRpcResponse; + if (response.error) { + reject( + new ValidationError( + `JSON-RPC error ${response.error.code}: ${response.error.message}`, + "RPC_ERROR", + { code: response.error.code, method }, + ), + ); + } else { + resolve(response.result); + } + } catch (parseError) { + reject( + new ValidationError( + `Failed to parse JSON-RPC response for ${method}`, + "RPC_ERROR", + ), + ); + } + }); + }); + + req.setTimeout(timeoutMs, () => { + req.destroy(); + reject(new ValidationError(`JSON-RPC call ${method} timed out after ${timeoutMs}ms`, "TIMEOUT")); + }); + + req.on("error", (err) => { + const message = err instanceof Error ? err.message : String(err); + reject( + new ForkUnavailableError(message), + ); + }); + + req.write(body); + req.end(); + }); +} + +/** + * Wait until the RPC endpoint accepts connections, with exponential backoff. + * @internal + */ +export async function waitForRpc( + rpcUrl: string, + maxWaitMs = 15_000, + intervalMs = 200, +): Promise { + const deadline = Date.now() + maxWaitMs; + let attempts = 0; + while (Date.now() < deadline) { + try { + await jsonRpcCall(rpcUrl, "eth_blockNumber", [], 2_000); + return; + } catch { + attempts++; + const wait = Math.min(intervalMs * Math.pow(1.5, Math.min(attempts, 8)), 2_000); + await new Promise((r) => setTimeout(r, wait)); + } + } + throw new AdapterCrashError("anvil", `RPC at ${rpcUrl} did not become ready within ${maxWaitMs}ms`); +} + +// ─── ABI encoding utilities (no external deps) ─────────────────────────────── + +/** + * Encode a function call using its signature and arguments. + * + * Supports: uint256, int256, address, bool, bytes, bytes32, string + * (as value types only — no arrays/structs; for those pass raw calldata). + * + * @internal + */ +export function encodeFunctionCall(signature: string, args: unknown[]): string { + const selector = keccak256Selector(signature); + if (args.length === 0) return selector; + + const encoded = args.map((arg) => encodeArg(arg)).join(""); + return selector + encoded; +} + +/** + * Compute the first 4 bytes of keccak256(signature) as a hex string. + * Uses a pure-JS keccak256 so we avoid adding ethers/web3 as a dep. + * @internal + */ +export function keccak256Selector(signature: string): string { + const hash = keccak256Pure(Buffer.from(signature, "utf8")); + return "0x" + hash.slice(0, 8); +} + +function encodeArg(value: unknown): string { + if (typeof value === "bigint") { + return value.toString(16).padStart(64, "0"); + } + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) { + throw new Error(`Unsafe integer: ${value}. Pass as a hex string or BigInt.`); + } + return BigInt(value).toString(16).padStart(64, "0"); + } + if (typeof value === "boolean") { + return (value ? 1n : 0n).toString(16).padStart(64, "0"); + } + if (typeof value === "string") { + if (/^0x[0-9a-f]*/i.test(value)) { + // Hex value (address, bytes32, etc.) + const stripped = value.slice(2).toLowerCase(); + return stripped.padStart(64, "0"); + } + // Decimal string (uint256 etc.) + return BigInt(value).toString(16).padStart(64, "0"); + } + throw new Error(`Unsupported argument type: ${typeof value}`); +} + +// ─── Keccak-256 ────────────────────────────────────────────────────────────── +// Delegates to js-sha3, which is already a project dependency and is verified +// correct for Ethereum's keccak256 (domain separation byte 0x01). + +import { keccak256 as _keccak256 } from "js-sha3"; + +/** + * Compute keccak256 of the given Buffer and return the 32-byte hex digest. + * Uses js-sha3 for correctness (Ethereum-compatible domain separation). + * @internal + */ +export function keccak256Pure(input: Buffer): string { + return _keccak256(input); +} + +// ─── Log decoding helpers ───────────────────────────────────────────────────── + +/** @internal */ +export function decodeLogEntries(rawLogs: unknown[]): LogEntry[] { + if (!Array.isArray(rawLogs)) return []; + return rawLogs.slice(0, 1000).map((raw) => { + const log = raw as Record; + const topics = Array.isArray(log["topics"]) + ? (log["topics"] as string[]).map((t) => String(t)) + : []; + return { + address: typeof log["address"] === "string" ? log["address"].toLowerCase() : "0x0000000000000000000000000000000000000000", + topics, + data: typeof log["data"] === "string" ? log["data"] : "0x", + }; + }); +} + +/** @internal */ +export function hexToDecimalString(hex: string): string { + if (!hex || hex === "0x") return "0"; + const clean = hex.startsWith("0x") ? hex.slice(2) : hex; + return BigInt("0x" + clean).toString(10); +} + +/** @internal */ +export function normalizeHex(value: string): string { + if (!value || value === "0x") return "0x0000000000000000000000000000000000000000000000000000000000000000"; + const clean = value.startsWith("0x") ? value.slice(2) : value; + return "0x" + clean.toLowerCase().padStart(64, "0"); +} diff --git a/packages/core/src/validation/anvil-adapter.ts b/packages/core/src/validation/anvil-adapter.ts new file mode 100644 index 0000000..e22fce4 --- /dev/null +++ b/packages/core/src/validation/anvil-adapter.ts @@ -0,0 +1,458 @@ +/** + * Anvil EVM adapter. + * + * Manages the lifecycle of an `anvil` process (from Foundry) as a + * process-isolated EVM backend. Resource limits are enforced via a + * SIGKILL watchdog timer and the adapter rejects calls after disposal. + * + * @remarks + * Anvil is preferred when available because it supports: + * - `anvil_snapshot` / `anvil_revert` for deterministic replay + * - `debug_traceTransaction` for detailed call traces + * - `anvil_setStorageAt` for precise state overrides + * - Fork mode via `--fork-url` / `--fork-block-number` + * + * Security note: `forkUrl` is passed via the process argument list. + * The OS makes this visible in `ps` output. Users who need to keep + * the URL private should set it via `CHAINPROOF_FORK_URL` env var + * instead; the adapter reads that env var if `forkUrl` is not given. + */ + +import * as childProcess from "child_process"; +import * as net from "net"; +import type { + AccountSpec, + CallResult, + CallSpec, + ContractSpec, + LogEntry, + ResolvedResourceLimits, + ScenarioResourceLimits, + StorageDiff, + ValidationCancellationSignal, +} from "./types"; +import { + AdapterCrashError, + ForkUnavailableError, + ValidationError, + resolveResourceLimits, + sanitizeErrorMessage, +} from "./types"; +import type { EvmAdapter } from "./adapter"; +import { + decodeLogEntries, + encodeFunctionCall, + hexToDecimalString, + jsonRpcCall, + normalizeHex, + waitForRpc, +} from "./adapter"; + +// ─── Port allocation ────────────────────────────────────────────────────────── + +/** Find a free TCP port. @internal */ +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as net.AddressInfo; + server.close((err) => { + if (err) reject(err); + else resolve(addr.port); + }); + }); + server.on("error", reject); + }); +} + +// ─── Anvil adapter ──────────────────────────────────────────────────────────── + +/** @internal default accounts funded by Anvil in devnet mode */ +const DEFAULT_ANVIL_ACCOUNTS = [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x90F79bf6EB2c4f870365E785982E1f101E93b906", +]; + +export class AnvilAdapter implements EvmAdapter { + readonly type = "anvil" as const; + #version = "unknown"; + #rpcUrl = ""; + #port = 0; + #proc: childProcess.ChildProcess | null = null; + #disposed = false; + #watchdog: NodeJS.Timeout | null = null; + + constructor( + private readonly opts: { + binaryPath?: string; + port?: number; + verbosity?: 0 | 1 | 2; + limits?: Partial; + forkUrl?: string; + forkBlockNumber?: number; + chainId?: number; + } = {}, + ) {} + + get version(): string { + return this.#version; + } + + get rpcUrl(): string { + return this.#rpcUrl; + } + + async start(limits?: Partial): Promise { + this.#assertNotDisposed(); + const resolved = resolveResourceLimits(limits ?? {}, this.opts.limits ?? {}); + this.#port = this.opts.port !== undefined && this.opts.port > 0 + ? this.opts.port + : await findFreePort(); + this.#rpcUrl = `http://127.0.0.1:${this.#port}`; + + const binary = this.opts.binaryPath ?? "anvil"; + const args = this.#buildArgs(); + + const verbosity = this.opts.verbosity ?? 0; + const proc = childProcess.spawn(binary, args, { + stdio: verbosity >= 2 ? "pipe" : ["ignore", "ignore", "ignore"], + env: { ...process.env }, + detached: false, + }); + + this.#proc = proc; + + // Set a hard kill watchdog + this.#watchdog = setTimeout(() => { + if (this.#proc && !this.#proc.killed) { + this.#proc.kill("SIGKILL"); + } + }, resolved.timeoutMs + 5_000); + + proc.on("error", (err) => { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("ENOENT")) { + // anvil not found + } + }); + + // Detect version from initial output if verbose + if (verbosity >= 1 && proc.stdout) { + proc.stdout.once("data", (chunk: Buffer) => { + const line = chunk.toString().split("\n")[0]; + const m = line.match(/anvil\s+([0-9]+\.[0-9]+\.[0-9]+)/i); + if (m) this.#version = `anvil/${m[1]}`; + }); + } + + try { + await waitForRpc(this.#rpcUrl, 15_000); + } catch (err) { + await this.dispose(); + const binary2 = this.opts.binaryPath ?? "anvil"; + throw new AdapterCrashError( + "anvil", + `Failed to start ${binary2}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Detect version via web3_clientVersion + try { + const clientVersion = await jsonRpcCall(this.#rpcUrl, "web3_clientVersion", [], 5_000); + if (typeof clientVersion === "string") { + this.#version = clientVersion.split("/").slice(0, 2).join("/"); + } + } catch { + this.#version = "anvil/unknown"; + } + } + + async dispose(): Promise { + if (this.#disposed) return; + this.#disposed = true; + if (this.#watchdog) { + clearTimeout(this.#watchdog); + this.#watchdog = null; + } + if (this.#proc) { + if (!this.#proc.killed) { + this.#proc.kill("SIGTERM"); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (this.#proc && !this.#proc.killed) { + this.#proc.kill("SIGKILL"); + } + resolve(); + }, 3_000); + this.#proc!.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + this.#proc = null; + } + } + + async setupAccount(account: AccountSpec): Promise { + this.#assertNotDisposed(); + if (account.balance) { + const balanceHex = "0x" + BigInt(account.balance).toString(16); + await jsonRpcCall(this.#rpcUrl, "anvil_setBalance", [account.address, balanceHex]); + } + } + + async deployContract(spec: ContractSpec, deployerAddress: string): Promise { + this.#assertNotDisposed(); + if (!spec.bytecode) { + throw new ValidationError( + `ContractSpec "${spec.name}" has no bytecode and no pre-existing address`, + "DEPLOY_FAILED", + ); + } + const data = spec.constructorArgs + ? spec.bytecode + spec.constructorArgs.replace("0x", "") + : spec.bytecode; + + const txHash = await jsonRpcCall(this.#rpcUrl, "eth_sendTransaction", [{ + from: deployerAddress, + data, + gas: "0x" + (5_000_000).toString(16), + }]) as string; + + const receipt = await this.#waitForReceipt(txHash); + const contractAddress = (receipt as Record)["contractAddress"]; + if (!contractAddress || typeof contractAddress !== "string") { + throw new ValidationError( + `Deployment of "${spec.name}" succeeded but receipt has no contractAddress`, + "DEPLOY_FAILED", + ); + } + return contractAddress; + } + + async executeCall( + spec: CallSpec, + resolvedAddresses: Map, + limits: ResolvedResourceLimits, + signal?: ValidationCancellationSignal, + ): Promise { + this.#assertNotDisposed(); + + if (signal?.cancelled) { + throw new ValidationError("Validation cancelled", "TIMEOUT"); + } + + const to = resolvedAddresses.get(spec.to) ?? spec.to; + const from = spec.from + ? (resolvedAddresses.get(spec.from) ?? spec.from) + : DEFAULT_ANVIL_ACCOUNTS[0]; + + let data = "0x"; + if (spec.calldata) { + data = spec.calldata; + } else if (spec.signature) { + data = encodeFunctionCall(spec.signature, spec.args ?? []); + } + + const gasLimit = spec.gasLimit ?? Math.min(limits.maxGasPerCall, 30_000_000); + const value = spec.value ? "0x" + BigInt(spec.value).toString(16) : "0x0"; + + let reverted = false; + let revertReason: string | undefined; + let returnData = "0x"; + let gasUsed = 0; + let logs: LogEntry[] = []; + let storageDiff: StorageDiff[] = []; + + // Capture state before call for diff + const callIndex = 0; // caller sets this + + try { + const txHash = await jsonRpcCall(this.#rpcUrl, "eth_sendTransaction", [{ + from, + to, + data, + value, + gas: "0x" + gasLimit.toString(16), + }]) as string; + + const receipt = await this.#waitForReceipt(txHash, limits.timeoutMs); + const r = receipt as Record; + + const status = String(r["status"] ?? "0x1"); + reverted = status === "0x0" || status === "0"; + + const rawGas = r["gasUsed"]; + if (typeof rawGas === "string") { + gasUsed = parseInt(rawGas, 16); + } + + if (Array.isArray(r["logs"])) { + logs = decodeLogEntries(r["logs"]).slice(0, limits.maxLogs); + } + + // Fetch return data via eth_call replay + try { + const callResult = await jsonRpcCall(this.#rpcUrl, "eth_call", [{ + from, to, data, value, + }, "latest"]) as string; + returnData = callResult; + } catch (callErr) { + if (callErr instanceof ValidationError && callErr.code === "RPC_ERROR") { + reverted = true; + revertReason = "call reverted"; + } + } + } catch (err) { + if (err instanceof ValidationError && err.code === "RPC_ERROR") { + reverted = true; + revertReason = sanitizeErrorMessage(err.message); + } else { + throw err; + } + } + + return { + callIndex, + reverted, + revertReason, + returnData, + gasUsed, + logs, + storageDiff, + }; + } + + async getStorageAt(address: string, slot: string): Promise { + this.#assertNotDisposed(); + const result = await jsonRpcCall(this.#rpcUrl, "eth_getStorageAt", [address, slot, "latest"]); + return normalizeHex(result as string); + } + + async getBalance(address: string): Promise { + this.#assertNotDisposed(); + const result = await jsonRpcCall(this.#rpcUrl, "eth_getBalance", [address, "latest"]); + return hexToDecimalString(result as string); + } + + async getBlockNumber(): Promise { + this.#assertNotDisposed(); + const result = await jsonRpcCall(this.#rpcUrl, "eth_blockNumber", []); + return parseInt(result as string, 16); + } + + async snapshot(): Promise { + this.#assertNotDisposed(); + const result = await jsonRpcCall(this.#rpcUrl, "evm_snapshot", []); + return String(result); + } + + async revertToSnapshot(snapshotId: string): Promise { + this.#assertNotDisposed(); + // anvil_revert consumes the snapshot; we use evm_revert (which also consumes it) + // To make it re-usable we'd need to re-snapshot, but for our purposes we + // retake a snapshot immediately after restore. + await jsonRpcCall(this.#rpcUrl, "evm_revert", [snapshotId]); + } + + async setNextBlockTimestamp(timestamp: number): Promise { + this.#assertNotDisposed(); + await jsonRpcCall(this.#rpcUrl, "evm_setNextBlockTimestamp", [timestamp]); + } + + async mine(count = 1): Promise { + this.#assertNotDisposed(); + await jsonRpcCall(this.#rpcUrl, "evm_mine", []); + for (let i = 1; i < count; i++) { + await jsonRpcCall(this.#rpcUrl, "evm_mine", []); + } + } + + async setStorageAt(address: string, slot: string, value: string): Promise { + this.#assertNotDisposed(); + await jsonRpcCall(this.#rpcUrl, "anvil_setStorageAt", [address, slot, value]); + } + + // ─── Private helpers ──────────────────────────────────────────────────────── + + #buildArgs(): string[] { + const args: string[] = [ + "--port", String(this.#port), + "--host", "127.0.0.1", + ]; + + if (this.opts.chainId !== undefined) { + args.push("--chain-id", String(this.opts.chainId)); + } + + if (this.opts.forkUrl) { + args.push("--fork-url", this.opts.forkUrl); + if (this.opts.forkBlockNumber !== undefined) { + args.push("--fork-block-number", String(this.opts.forkBlockNumber)); + } + } + + args.push("--no-mining"); // use manual mining for determinism + args.push("--order", "fifo"); + + return args; + } + + async #waitForReceipt(txHash: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const receipt = await jsonRpcCall(this.#rpcUrl, "eth_getTransactionReceipt", [txHash]); + if (receipt !== null) { + // Mine a block to include the tx (no-mining mode) + try { + await jsonRpcCall(this.#rpcUrl, "evm_mine", []); + const receipt2 = await jsonRpcCall(this.#rpcUrl, "eth_getTransactionReceipt", [txHash]); + if (receipt2 !== null) return receipt2; + } catch { + return receipt; + } + return receipt; + } + // Mine a block to include pending txs + try { + await jsonRpcCall(this.#rpcUrl, "evm_mine", []); + const receipt2 = await jsonRpcCall(this.#rpcUrl, "eth_getTransactionReceipt", [txHash]); + if (receipt2 !== null) return receipt2; + } catch { + // ignore + } + await new Promise((r) => setTimeout(r, 100)); + } + throw new ValidationError( + `Transaction ${txHash} not mined within ${timeoutMs}ms`, + "TIMEOUT", + ); + } + + #assertNotDisposed(): void { + if (this.#disposed) { + throw new AdapterCrashError("anvil", "Adapter has been disposed"); + } + } +} + +/** + * Detect whether the `anvil` binary is available on $PATH. + */ +export async function isAnvilAvailable(binaryPath = "anvil"): Promise { + return new Promise((resolve) => { + const proc = childProcess.spawn(binaryPath, ["--version"], { + stdio: ["ignore", "pipe", "ignore"], + }); + proc.on("error", () => resolve(false)); + proc.on("exit", (code) => resolve(code === 0)); + proc.stdout.resume(); + setTimeout(() => { + if (!proc.killed) proc.kill(); + resolve(false); + }, 3_000); + }); +} diff --git a/packages/core/src/validation/hardhat-adapter.ts b/packages/core/src/validation/hardhat-adapter.ts new file mode 100644 index 0000000..7dd06ce --- /dev/null +++ b/packages/core/src/validation/hardhat-adapter.ts @@ -0,0 +1,409 @@ +/** + * Hardhat Network EVM adapter. + * + * Manages an in-process or spawned `hardhat node` JSON-RPC server. + * Hardhat Network supports: + * - `evm_snapshot` / `evm_revert` for deterministic replay + * - `hardhat_setStorageAt` for storage overrides + * - `hardhat_setBalance` for balance manipulation + * - Fork mode via `--fork` flag + * + * @remarks + * The adapter spawns `npx hardhat node` in a temp directory with a + * minimal `hardhat.config.js` so it works without any project setup. + * This means Hardhat Network is available whenever `hardhat` is installed + * globally or in the project's devDependencies. + */ + +import * as childProcess from "child_process"; +import * as fs from "fs"; +import * as net from "net"; +import * as os from "os"; +import * as path from "path"; +import type { + AccountSpec, + CallResult, + CallSpec, + ContractSpec, + ResolvedResourceLimits, + ScenarioResourceLimits, + ValidationCancellationSignal, +} from "./types"; +import { + AdapterCrashError, + ValidationError, + resolveResourceLimits, + sanitizeErrorMessage, +} from "./types"; +import type { EvmAdapter } from "./adapter"; +import { + decodeLogEntries, + encodeFunctionCall, + hexToDecimalString, + jsonRpcCall, + normalizeHex, + waitForRpc, +} from "./adapter"; + +/** Find a free TCP port. @internal */ +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as net.AddressInfo; + server.close((err) => { + if (err) reject(err); + else resolve(addr.port); + }); + }); + server.on("error", reject); + }); +} + +const DEFAULT_HH_ACCOUNTS = [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", +]; + +/** Minimal hardhat.config.js written to the temp dir. */ +function buildHardhatConfig(port: number, forkUrl?: string, forkBlockNumber?: number): string { + const forkSection = forkUrl + ? `forking: { url: ${JSON.stringify(forkUrl)}${forkBlockNumber ? `, blockNumber: ${forkBlockNumber}` : ""} },` + : ""; + return ` +require("@nomicfoundation/hardhat-toolbox"); +module.exports = { + solidity: "0.8.24", + networks: { + hardhat: { + ${forkSection} + mining: { auto: true, interval: 0 }, + }, + localhost: { + url: "http://127.0.0.1:${port}", + }, + }, +}; +`.trim(); +} + +export class HardhatAdapter implements EvmAdapter { + readonly type = "hardhat" as const; + #version = "unknown"; + #rpcUrl = ""; + #port = 0; + #proc: childProcess.ChildProcess | null = null; + #disposed = false; + #watchdog: NodeJS.Timeout | null = null; + #tempDir: string | null = null; + + constructor( + private readonly opts: { + binaryPath?: string; + port?: number; + verbosity?: 0 | 1 | 2; + limits?: Partial; + forkUrl?: string; + forkBlockNumber?: number; + chainId?: number; + } = {}, + ) {} + + get version(): string { + return this.#version; + } + + get rpcUrl(): string { + return this.#rpcUrl; + } + + async start(limits?: Partial): Promise { + this.#assertNotDisposed(); + const resolved = resolveResourceLimits(limits ?? {}, this.opts.limits ?? {}); + this.#port = this.opts.port !== undefined && this.opts.port > 0 + ? this.opts.port + : await findFreePort(); + this.#rpcUrl = `http://127.0.0.1:${this.#port}`; + + // Create temp workspace + this.#tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "chainproof-hh-")); + const configPath = path.join(this.#tempDir, "hardhat.config.js"); + fs.writeFileSync( + configPath, + buildHardhatConfig(this.#port, this.opts.forkUrl, this.opts.forkBlockNumber), + "utf8", + ); + + const binary = this.opts.binaryPath ?? "npx"; + const args = binary === "npx" + ? ["hardhat", "node", "--port", String(this.#port), "--hostname", "127.0.0.1"] + : ["node", "--port", String(this.#port), "--hostname", "127.0.0.1"]; + + const verbosity = this.opts.verbosity ?? 0; + const proc = childProcess.spawn(binary, args, { + cwd: this.#tempDir, + stdio: verbosity >= 2 ? "pipe" : ["ignore", "ignore", "ignore"], + env: { ...process.env }, + detached: false, + }); + + this.#proc = proc; + + this.#watchdog = setTimeout(() => { + if (this.#proc && !this.#proc.killed) { + this.#proc.kill("SIGKILL"); + } + }, resolved.timeoutMs + 10_000); + + try { + await waitForRpc(this.#rpcUrl, 30_000); // Hardhat is slower to start + } catch (err) { + await this.dispose(); + throw new AdapterCrashError( + "hardhat", + `Failed to start hardhat node: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + try { + const clientVersion = await jsonRpcCall(this.#rpcUrl, "web3_clientVersion", [], 5_000); + if (typeof clientVersion === "string") { + this.#version = clientVersion.split("/").slice(0, 2).join("/"); + } + } catch { + this.#version = "hardhat/unknown"; + } + } + + async dispose(): Promise { + if (this.#disposed) return; + this.#disposed = true; + if (this.#watchdog) { + clearTimeout(this.#watchdog); + this.#watchdog = null; + } + if (this.#proc) { + if (!this.#proc.killed) { + this.#proc.kill("SIGTERM"); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (this.#proc && !this.#proc.killed) this.#proc.kill("SIGKILL"); + resolve(); + }, 5_000); + this.#proc!.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + this.#proc = null; + } + if (this.#tempDir) { + try { + fs.rmSync(this.#tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + this.#tempDir = null; + } + } + + async setupAccount(account: AccountSpec): Promise { + this.#assertNotDisposed(); + if (account.balance) { + const balanceHex = "0x" + BigInt(account.balance).toString(16); + await jsonRpcCall(this.#rpcUrl, "hardhat_setBalance", [account.address, balanceHex]); + } + } + + async deployContract(spec: ContractSpec, deployerAddress: string): Promise { + this.#assertNotDisposed(); + if (!spec.bytecode) { + throw new ValidationError( + `ContractSpec "${spec.name}" has no bytecode`, + "DEPLOY_FAILED", + ); + } + const data = spec.constructorArgs + ? spec.bytecode + spec.constructorArgs.replace("0x", "") + : spec.bytecode; + + const txHash = await jsonRpcCall(this.#rpcUrl, "eth_sendTransaction", [{ + from: deployerAddress, + data, + gas: "0x" + (5_000_000).toString(16), + }]) as string; + + const receipt = await this.#waitForReceipt(txHash); + const r = receipt as Record; + const contractAddress = r["contractAddress"]; + if (!contractAddress || typeof contractAddress !== "string") { + throw new ValidationError( + `Deployment of "${spec.name}" produced no contractAddress`, + "DEPLOY_FAILED", + ); + } + return contractAddress; + } + + async executeCall( + spec: CallSpec, + resolvedAddresses: Map, + limits: ResolvedResourceLimits, + signal?: ValidationCancellationSignal, + ): Promise { + this.#assertNotDisposed(); + + if (signal?.cancelled) { + throw new ValidationError("Validation cancelled", "TIMEOUT"); + } + + const to = resolvedAddresses.get(spec.to) ?? spec.to; + const from = spec.from + ? (resolvedAddresses.get(spec.from) ?? spec.from) + : DEFAULT_HH_ACCOUNTS[0]; + + let data = "0x"; + if (spec.calldata) { + data = spec.calldata; + } else if (spec.signature) { + data = encodeFunctionCall(spec.signature, spec.args ?? []); + } + + const gasLimit = spec.gasLimit ?? Math.min(limits.maxGasPerCall, 30_000_000); + const value = spec.value ? "0x" + BigInt(spec.value).toString(16) : "0x0"; + + let reverted = false; + let revertReason: string | undefined; + let returnData = "0x"; + let gasUsed = 0; + const logs: import("./types").LogEntry[] = []; + + try { + const txHash = await jsonRpcCall(this.#rpcUrl, "eth_sendTransaction", [{ + from, + to, + data, + value, + gas: "0x" + gasLimit.toString(16), + }]) as string; + + const receipt = await this.#waitForReceipt(txHash, limits.timeoutMs); + const r = receipt as Record; + + const status = String(r["status"] ?? "0x1"); + reverted = status === "0x0" || status === "0"; + + const rawGas = r["gasUsed"]; + if (typeof rawGas === "string") { + gasUsed = parseInt(rawGas, 16); + } + + if (Array.isArray(r["logs"])) { + const decoded = decodeLogEntries(r["logs"]); + logs.push(...decoded.slice(0, limits.maxLogs)); + } + } catch (err) { + if (err instanceof ValidationError && err.code === "RPC_ERROR") { + reverted = true; + revertReason = sanitizeErrorMessage(err.message); + } else { + throw err; + } + } + + return { + callIndex: 0, + reverted, + revertReason, + returnData, + gasUsed, + logs, + storageDiff: [], + }; + } + + async getStorageAt(address: string, slot: string): Promise { + this.#assertNotDisposed(); + const result = await jsonRpcCall(this.#rpcUrl, "eth_getStorageAt", [address, slot, "latest"]); + return normalizeHex(result as string); + } + + async getBalance(address: string): Promise { + this.#assertNotDisposed(); + const result = await jsonRpcCall(this.#rpcUrl, "eth_getBalance", [address, "latest"]); + return hexToDecimalString(result as string); + } + + async getBlockNumber(): Promise { + this.#assertNotDisposed(); + const result = await jsonRpcCall(this.#rpcUrl, "eth_blockNumber", []); + return parseInt(result as string, 16); + } + + async snapshot(): Promise { + this.#assertNotDisposed(); + const result = await jsonRpcCall(this.#rpcUrl, "evm_snapshot", []); + return String(result); + } + + async revertToSnapshot(snapshotId: string): Promise { + this.#assertNotDisposed(); + await jsonRpcCall(this.#rpcUrl, "evm_revert", [snapshotId]); + } + + async setNextBlockTimestamp(timestamp: number): Promise { + this.#assertNotDisposed(); + await jsonRpcCall(this.#rpcUrl, "evm_setNextBlockTimestamp", [timestamp]); + } + + async mine(count = 1): Promise { + this.#assertNotDisposed(); + for (let i = 0; i < count; i++) { + await jsonRpcCall(this.#rpcUrl, "evm_mine", []); + } + } + + async setStorageAt(address: string, slot: string, value: string): Promise { + this.#assertNotDisposed(); + await jsonRpcCall(this.#rpcUrl, "hardhat_setStorageAt", [address, slot, value]); + } + + async #waitForReceipt(txHash: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const receipt = await jsonRpcCall(this.#rpcUrl, "eth_getTransactionReceipt", [txHash]); + if (receipt !== null) return receipt; + await new Promise((r) => setTimeout(r, 200)); + } + throw new ValidationError( + `Transaction ${txHash} not mined within ${timeoutMs}ms`, + "TIMEOUT", + ); + } + + #assertNotDisposed(): void { + if (this.#disposed) { + throw new AdapterCrashError("hardhat", "Adapter has been disposed"); + } + } +} + +/** + * Detect whether `npx hardhat` is usable in the current environment. + */ +export async function isHardhatAvailable(binaryPath = "npx"): Promise { + return new Promise((resolve) => { + const proc = childProcess.spawn(binaryPath, ["hardhat", "--version"], { + stdio: ["ignore", "pipe", "ignore"], + }); + proc.on("error", () => resolve(false)); + proc.on("exit", (code) => resolve(code === 0)); + proc.stdout.resume(); + setTimeout(() => { + if (!proc.killed) proc.kill(); + resolve(false); + }, 5_000); + }); +} diff --git a/packages/core/src/validation/index.ts b/packages/core/src/validation/index.ts new file mode 100644 index 0000000..3f5b9e3 --- /dev/null +++ b/packages/core/src/validation/index.ts @@ -0,0 +1,116 @@ +/** + * @packageDocumentation + * Fork-aware concrete validation and exploit reproduction harness. + * + * This module bridges ChainProof's static analysis pipeline to concrete EVM + * execution experiments. It translates static {@link Finding} objects into + * parameterized {@link ValidationScenario} scaffolds, executes them against + * process-isolated EVM backends (Anvil, Hardhat Network), and produces + * portable {@link ValidationReport} bundles. + * + * @remarks + * **Security assumptions:** + * - Adapter processes are spawned with standard OS resource limits; they do + * NOT have network access restrictions. Use a network namespace / sandbox + * when running against untrusted scenarios in CI. + * - Fork URLs are never serialized into bundles; they must be re-supplied at + * replay time. + * - Scenario bytecode is embedded verbatim; review before execution. + * + * @example + * ```typescript + * import { planValidation, runValidationPlan, generateValidationMarkdown } from '@chainproof/core'; + * + * const plan = planValidation(findings); + * const report = await runValidationPlan(plan.scenarios, { adapterType: 'anvil' }); + * console.log(generateValidationMarkdown(report)); + * ``` + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── +export type { + AccountSpec, + AdapterOptions, + AdapterType, + BalanceAssertion, + BalanceAssertionResult, + CallResult, + CallSpec, + ChainContext, + ContractSpec, + EventAssertion, + EventAssertionResult, + LogEntry, + MinimizationResult, + ResolvedResourceLimits, + ScenarioResourceLimits, + SnapshotEntry, + StorageAssertion, + StorageAssertionResult, + StorageDiff, + UnsupportedFinding, + ValidationCancellationSignal, + ValidationPlan, + ValidationReport, + ValidationResult, + ValidationScenario, +} from "./types"; + +export { + DEFAULT_RESOURCE_LIMITS, + VALIDATION_SCHEMA_VERSION, + ValidationError, + ValidationTimeoutError, + AdapterCrashError, + ForkUnavailableError, + CorruptBundleError, + ScenarioValidationError, + createCancellationSignal, + resolveResourceLimits, + sanitizeErrorMessage, +} from "./types"; + +// ─── Adapter ────────────────────────────────────────────────────────────────── +export type { EvmAdapter } from "./adapter"; +export { + jsonRpcCall, + waitForRpc, + encodeFunctionCall, + keccak256Selector, + keccak256Pure, + decodeLogEntries, + hexToDecimalString, + normalizeHex, +} from "./adapter"; + +// ─── Anvil adapter ──────────────────────────────────────────────────────────── +export { AnvilAdapter, isAnvilAvailable } from "./anvil-adapter"; + +// ─── Hardhat adapter ────────────────────────────────────────────────────────── +export { HardhatAdapter, isHardhatAvailable } from "./hardhat-adapter"; + +// ─── Scaffold / planning ────────────────────────────────────────────────────── +export { + planValidation, + serializeValidationPlan, + parseValidationPlan, +} from "./scaffold"; +export type { PlanValidationOptions } from "./scaffold"; + +// ─── Runner ─────────────────────────────────────────────────────────────────── +export { + ValidationRunner, + minimizeScenario, + runValidationPlan, + sanitizeScenario, +} from "./runner"; +export type { RunnerOptions, MinimizerOptions, RunValidationOptions } from "./runner"; + +// ─── Reports ───────────────────────────────────────────────────────────────── +export { + serializeValidationReport, + serializeValidationResult, + generateValidationMarkdown, + generateValidationResultMarkdown, + parseValidationReport, +} from "./report"; diff --git a/packages/core/src/validation/report.ts b/packages/core/src/validation/report.ts new file mode 100644 index 0000000..3cafbe5 --- /dev/null +++ b/packages/core/src/validation/report.ts @@ -0,0 +1,224 @@ +/** + * Validation report generators. + * + * Produces portable, versioned JSON and human-readable Markdown reports + * from {@link ValidationReport} and {@link ValidationResult} objects. + * + * @remarks + * JSON reports use deterministic key ordering and are suitable for diffing. + * Markdown reports are designed for terminal output and pull-request comments. + * No sensitive information (private keys, fork URLs, local paths) is emitted. + */ + +import type { CallResult, ValidationReport, ValidationResult } from "./types"; + +// ─── JSON report ────────────────────────────────────────────────────────────── + +/** + * Serialize a {@link ValidationReport} to pretty-printed JSON. + * Keys are ordered deterministically. + */ +export function serializeValidationReport(report: ValidationReport): string { + return JSON.stringify(report, deterministicReplacer, 2); +} + +/** + * Serialize a single {@link ValidationResult} to JSON. + */ +export function serializeValidationResult(result: ValidationResult): string { + return JSON.stringify(result, deterministicReplacer, 2); +} + +/** @internal */ +function deterministicReplacer(_key: string, value: unknown): unknown { + if (typeof value === "bigint") { + return value.toString(); + } + if (value && typeof value === "object" && !Array.isArray(value)) { + return Object.fromEntries( + Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)), + ); + } + return value; +} + +// ─── Markdown report ────────────────────────────────────────────────────────── + +/** + * Generate a Markdown validation report suitable for PR comments or terminal display. + */ +export function generateValidationMarkdown(report: ValidationReport): string { + const lines: string[] = []; + + lines.push("# ChainProof Validation Report\n"); + lines.push(`**Schema:** ${report.schemaVersion} `); + lines.push(`**Timestamp:** ${report.timestamp} `); + lines.push(`**Adapter:** ${report.adapterType} `); + lines.push(`**Duration:** ${formatDuration(report.totalDurationMs)}\n`); + + // Summary table + lines.push("## Summary\n"); + lines.push("| Result | Count |"); + lines.push("|--------|-------|"); + lines.push(`| ✅ Passed | ${report.passed} |`); + lines.push(`| ❌ Failed | ${report.failed} |`); + lines.push(`| 🔴 Errored | ${report.errored} |`); + lines.push(`| **Total** | **${report.total}** |`); + lines.push(""); + + if (report.total === 0) { + lines.push("_No scenarios were executed._\n"); + return lines.join("\n"); + } + + // Per-scenario results + lines.push("## Scenario Results\n"); + + for (const result of report.results) { + lines.push(...generateResultSection(result)); + } + + return lines.join("\n"); +} + +function generateResultSection(result: ValidationResult): string[] { + const lines: string[] = []; + const icon = result.error ? "🔴" : result.outcomeMatched ? "✅" : "❌"; + const status = result.error ? "ERROR" : result.outcomeMatched ? "PASSED" : "FAILED"; + + lines.push(`### ${icon} ${escapeMarkdown(result.scenario.title)}`); + lines.push(""); + lines.push(`**Scenario ID:** \`${result.scenario.id}\` `); + lines.push(`**Status:** ${status} `); + lines.push(`**Adapter:** ${result.adapterType} ${result.adapterVersion} `); + lines.push(`**Duration:** ${formatDuration(result.durationMs)} `); + lines.push(`**Total Gas:** ${result.totalGasUsed.toLocaleString()} `); + + if (result.scenario.findingId) { + lines.push(`**Finding:** ${result.scenario.findingId} — ${result.scenario.findingFile ?? ""}:${result.scenario.findingLine ?? "?"}`); + } + lines.push(""); + + lines.push(`**Outcome:** ${escapeMarkdown(result.outcomeSummary)}\n`); + + // Error + if (result.error) { + lines.push(`> ⚠️ **Infrastructure Error:** ${escapeMarkdown(result.error)}\n`); + } + + // Call results summary + if (result.callResults.length > 0) { + lines.push("#### Call Execution\n"); + lines.push("| # | Description | Reverted | Gas |"); + lines.push("|---|-------------|----------|-----|"); + for (const cr of result.callResults) { + const call = result.scenario.calls[cr.callIndex]; + const desc = call?.description ?? call?.signature ?? `call[${cr.callIndex}]`; + const revert = cr.reverted + ? `⛔ ${escapeMarkdown(cr.revertReason?.slice(0, 60) ?? "reverted")}` + : "✓"; + lines.push(`| ${cr.callIndex + 1} | ${escapeMarkdown(desc.slice(0, 60))} | ${revert} | ${cr.gasUsed.toLocaleString()} |`); + } + lines.push(""); + } + + // Assertion results + if (result.storageAssertionResults.length > 0) { + lines.push("#### Storage Assertions\n"); + for (const ar of result.storageAssertionResults) { + const icon2 = ar.passed ? "✅" : "❌"; + lines.push( + `${icon2} Slot \`${ar.assertion.slot}\` on \`${ar.assertion.contract}\`: ` + + `expected \`${ar.assertion.expected}\`, got \`${ar.actual}\``, + ); + } + lines.push(""); + } + + if (result.balanceAssertionResults.length > 0) { + lines.push("#### Balance Assertions\n"); + for (const ar of result.balanceAssertionResults) { + const icon2 = ar.passed ? "✅" : "❌"; + lines.push( + `${icon2} \`${ar.assertion.account}\` balance ${ar.assertion.op} \`${ar.assertion.value}\` wei: ` + + `actual \`${ar.actual}\` wei`, + ); + } + lines.push(""); + } + + if (result.eventAssertionResults.length > 0) { + lines.push("#### Event Assertions\n"); + for (const ar of result.eventAssertionResults) { + const icon2 = ar.passed ? "✅" : "❌"; + const neg = ar.assertion.negate ? "NOT " : ""; + lines.push( + `${icon2} Event \`${ar.assertion.eventSignature}\` ${neg}emitted by \`${ar.assertion.contract}\`: ` + + `found=${ar.found}`, + ); + } + lines.push(""); + } + + // Warnings + if (result.warnings.length > 0) { + lines.push("#### Warnings\n"); + for (const w of result.warnings) { + lines.push(`- ⚠️ ${escapeMarkdown(w)}`); + } + lines.push(""); + } + + lines.push("---\n"); + return lines; +} + +// ─── Single result Markdown ─────────────────────────────────────────────────── + +/** + * Generate a Markdown summary for a single {@link ValidationResult}. + */ +export function generateValidationResultMarkdown(result: ValidationResult): string { + const lines: string[] = []; + lines.push("# Validation Result\n"); + lines.push(...generateResultSection(result)); + return lines.join("\n"); +} + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +function formatDuration(ms: number): string { + if (ms < 1_000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + const minutes = Math.floor(ms / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + return `${minutes}m ${seconds}s`; +} + +function escapeMarkdown(text: string): string { + return text.replace(/[<>|`*_[\]]/g, "\\$&"); +} + +/** + * Parse a validation report from JSON. + * Throws on schema mismatch. + */ +export function parseValidationReport(json: string, filePath = ""): ValidationReport { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new Error(`Invalid JSON in validation report at ${filePath}`); + } + const obj = parsed as Record; + if (!obj || typeof obj !== "object") { + throw new Error("Validation report must be an object"); + } + if (typeof obj["schemaVersion"] !== "string") { + throw new Error("Validation report missing schemaVersion"); + } + if (!Array.isArray(obj["results"])) { + throw new Error("Validation report missing results array"); + } + return obj as unknown as ValidationReport; +} diff --git a/packages/core/src/validation/runner.ts b/packages/core/src/validation/runner.ts new file mode 100644 index 0000000..b6403a2 --- /dev/null +++ b/packages/core/src/validation/runner.ts @@ -0,0 +1,648 @@ +/** + * Validation Runner. + * + * Orchestrates the execution of {@link ValidationScenario} objects against an + * {@link EvmAdapter}. Handles: + * - Account setup and contract deployment + * - Ordered call execution with cancellation support + * - Snapshot/restore for deterministic replay + * - Assertion evaluation (storage, balance, events) + * - Resource limit enforcement + * - Error isolation (infrastructure failures vs. revert-as-expected) + */ + +import type { EvmAdapter } from "./adapter"; +import type { + AccountSpec, + BalanceAssertionResult, + CallResult, + ContractSpec, + EventAssertionResult, + LogEntry, + ResolvedResourceLimits, + ScenarioResourceLimits, + StorageAssertionResult, + ValidationCancellationSignal, + ValidationResult, + ValidationScenario, +} from "./types"; +import { + VALIDATION_SCHEMA_VERSION, + ValidationError, + ValidationTimeoutError, + resolveResourceLimits, + sanitizeErrorMessage, +} from "./types"; +import { normalizeHex, keccak256Pure } from "./adapter"; + +// ─── Runner options ─────────────────────────────────────────────────────────── + +export interface RunnerOptions { + /** Global resource limit overrides (per-scenario limits take precedence). */ + limits?: Partial; + /** Cancellation signal. */ + signal?: ValidationCancellationSignal; +} + +// ─── ValidationRunner ───────────────────────────────────────────────────────── + +/** + * Executes a single {@link ValidationScenario} against an {@link EvmAdapter}. + * + * The adapter must already be started before calling `run()`. + */ +export class ValidationRunner { + constructor( + private readonly adapter: EvmAdapter, + private readonly opts: RunnerOptions = {}, + ) {} + + /** + * Execute a scenario and return a portable {@link ValidationResult}. + * + * The adapter state after the run is the state at the end of the scenario + * (not reverted). The snapshot taken before call[0] can be replayed with + * `replay()`. + */ + async run(scenario: ValidationScenario): Promise { + const startedAt = new Date().toISOString(); + const startMs = Date.now(); + const warnings: string[] = []; + + const limits = resolveResourceLimits( + scenario.limits ?? {}, + this.opts.limits ?? {}, + ); + + // Enforce call count + if (scenario.calls.length > limits.maxCalls) { + throw new ValidationError( + `Scenario "${scenario.id}" has ${scenario.calls.length} calls, ` + + `which exceeds the limit of ${limits.maxCalls}`, + "RESOURCE_EXCEEDED", + ); + } + + let snapshotId = ""; + let snapshotBlock = 0; + const callResults: CallResult[] = []; + let error: string | undefined; + const resolvedAddresses = new Map(); + + try { + // 1. Setup accounts + for (const account of scenario.accounts) { + this.#checkCancelled(limits); + await this.adapter.setupAccount(account); + if (account.label) { + resolvedAddresses.set(account.label, account.address); + } + resolvedAddresses.set(account.address, account.address); + } + + // 2. Deploy contracts (in order; later specs may reference earlier ones) + for (const contractSpec of scenario.contracts) { + this.#checkCancelled(limits); + const deployedAddress = await this.#deployOrAlias( + contractSpec, + resolvedAddresses, + scenario.accounts, + ); + resolvedAddresses.set(contractSpec.name, deployedAddress); + // Apply storage overrides after deployment + if (contractSpec.storageOverrides) { + for (const [slot, value] of Object.entries(contractSpec.storageOverrides)) { + await this.adapter.setStorageAt(deployedAddress, slot, value); + } + } + } + + // 3. Take snapshot before first call + snapshotBlock = await this.adapter.getBlockNumber(); + snapshotId = await this.adapter.snapshot(); + + // 4. Execute calls in order + for (let i = 0; i < scenario.calls.length; i++) { + this.#checkCancelled(limits); + this.#checkTimeout(startMs, limits, scenario.id); + + const callSpec = scenario.calls[i]; + const result = await this.adapter.executeCall( + callSpec, + resolvedAddresses, + limits, + this.opts.signal, + ); + result.callIndex = i; + + // Validate expectRevert + if (callSpec.expectRevert === true && !result.reverted) { + warnings.push( + `Call[${i}] "${callSpec.description ?? callSpec.signature ?? "unknown"}" ` + + `was expected to revert but did not`, + ); + } + if (callSpec.expectRevert === false && result.reverted) { + warnings.push( + `Call[${i}] "${callSpec.description ?? callSpec.signature ?? "unknown"}" ` + + `reverted unexpectedly: ${result.revertReason ?? "unknown reason"}`, + ); + } + + callResults.push(result); + } + } catch (err) { + if (err instanceof ValidationError) { + error = sanitizeErrorMessage(err.message); + } else if (err instanceof Error) { + error = sanitizeErrorMessage(err.message); + } else { + error = "Unknown error during validation run"; + } + } + + // 5. Evaluate assertions + const storageAssertionResults = await this.#evaluateStorageAssertions( + scenario, + resolvedAddresses, + warnings, + ); + const balanceAssertionResults = await this.#evaluateBalanceAssertions( + scenario, + resolvedAddresses, + warnings, + ); + const eventAssertionResults = this.#evaluateEventAssertions( + scenario, + callResults, + warnings, + ); + + // 6. Determine outcome + const { outcomeMatched, outcomeSummary } = this.#determineOutcome( + scenario, + callResults, + storageAssertionResults, + balanceAssertionResults, + eventAssertionResults, + error, + ); + + const completedAt = new Date().toISOString(); + const durationMs = Date.now() - startMs; + const totalGasUsed = callResults.reduce((sum, r) => sum + r.gasUsed, 0); + + // Strip private keys and fork URLs from the scenario before persisting + const sanitizedScenario = sanitizeScenario(scenario); + + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + scenario: sanitizedScenario, + adapterType: this.adapter.type, + adapterVersion: this.adapter.version, + snapshotId, + snapshotBlock, + callResults, + outcomeMatched, + outcomeSummary, + storageAssertionResults, + balanceAssertionResults, + eventAssertionResults, + totalGasUsed, + startedAt, + completedAt, + durationMs, + warnings, + error, + }; + } + + /** + * Replay a scenario from a previously taken snapshot. + * + * The adapter must still be running and the snapshotId must be valid. + */ + async replay( + snapshotId: string, + scenario: ValidationScenario, + ): Promise { + // Restore to the snapshot (note: evm_revert consumes the snapshot in most implementations) + await this.adapter.revertToSnapshot(snapshotId); + // Re-snapshot so we can replay again + const newSnapshotId = await this.adapter.snapshot(); + // Run with the new snapshot + const result = await this.run(scenario); + return { ...result, snapshotId: newSnapshotId }; + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + async #deployOrAlias( + spec: ContractSpec, + resolvedAddresses: Map, + accounts: AccountSpec[], + ): Promise { + if (spec.address) { + // Alias to existing address (fork mode) + return spec.address; + } + if (!spec.bytecode || spec.bytecode === "0x") { + // Scaffold placeholder — cannot actually deploy + return "0x0000000000000000000000000000000000000001"; + } + + // Determine deployer address + let deployerAddress: string; + if (spec.deployer) { + const found = resolvedAddresses.get(spec.deployer); + if (found) { + deployerAddress = found; + } else { + // Try to match as an address + deployerAddress = spec.deployer; + } + } else { + // Default to first account + deployerAddress = accounts[0]?.address ?? "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + } + + return this.adapter.deployContract(spec, deployerAddress); + } + + async #evaluateStorageAssertions( + scenario: ValidationScenario, + resolvedAddresses: Map, + warnings: string[], + ): Promise { + if (!scenario.storageAssertions?.length) return []; + const results: StorageAssertionResult[] = []; + for (const assertion of scenario.storageAssertions) { + try { + const contractAddress = resolvedAddresses.get(assertion.contract) ?? assertion.contract; + const actual = await this.adapter.getStorageAt(contractAddress, assertion.slot); + const expected = normalizeHex(assertion.expected); + results.push({ assertion, actual, passed: actual === expected }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Storage assertion failed to evaluate: ${sanitizeErrorMessage(msg)}`); + results.push({ assertion, actual: "0x0", passed: false }); + } + } + return results; + } + + async #evaluateBalanceAssertions( + scenario: ValidationScenario, + resolvedAddresses: Map, + warnings: string[], + ): Promise { + if (!scenario.balanceAssertions?.length) return []; + const results: BalanceAssertionResult[] = []; + for (const assertion of scenario.balanceAssertions) { + try { + const address = resolvedAddresses.get(assertion.account) ?? assertion.account; + const actual = await this.adapter.getBalance(address); + const actualBig = BigInt(actual); + const expectedBig = BigInt(assertion.value); + let passed: boolean; + switch (assertion.op) { + case "eq": passed = actualBig === expectedBig; break; + case "gt": passed = actualBig > expectedBig; break; + case "gte": passed = actualBig >= expectedBig; break; + case "lt": passed = actualBig < expectedBig; break; + case "lte": passed = actualBig <= expectedBig; break; + default: passed = false; + } + results.push({ assertion, actual, passed }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Balance assertion failed to evaluate: ${sanitizeErrorMessage(msg)}`); + results.push({ assertion, actual: "0", passed: false }); + } + } + return results; + } + + #evaluateEventAssertions( + scenario: ValidationScenario, + callResults: CallResult[], + warnings: string[], + ): EventAssertionResult[] { + if (!scenario.eventAssertions?.length) return []; + // Flatten all logs from all call results + const allLogs: LogEntry[] = callResults.flatMap((r) => r.logs); + return scenario.eventAssertions.map((assertion) => { + // Compute topic0 from eventSignature using keccak256 + try { + const topic0 = "0x" + keccak256Pure(Buffer.from(assertion.eventSignature, "utf8")); + const found = allLogs.some( + (log) => + log.address.toLowerCase() === + (assertion.contract.startsWith("0x") + ? assertion.contract.toLowerCase() + : assertion.contract.toLowerCase()) && + log.topics[0]?.toLowerCase() === topic0.toLowerCase(), + ); + const passed = assertion.negate ? !found : found; + return { assertion, found, passed }; + } catch { + warnings.push(`Event assertion for ${assertion.eventSignature} could not be evaluated`); + return { assertion, found: false, passed: false }; + } + }); + } + + #determineOutcome( + scenario: ValidationScenario, + callResults: CallResult[], + storageResults: StorageAssertionResult[], + balanceResults: BalanceAssertionResult[], + eventResults: EventAssertionResult[], + error: string | undefined, + ): { outcomeMatched: boolean; outcomeSummary: string } { + if (error) { + return { + outcomeMatched: false, + outcomeSummary: `Infrastructure error: ${error}`, + }; + } + + const anyCallReverted = callResults.some((r) => r.reverted); + const allAssertionsPassed = + storageResults.every((r) => r.passed) && + balanceResults.every((r) => r.passed) && + eventResults.every((r) => r.passed); + + switch (scenario.expectedOutcome) { + case "exploit-succeeds": { + const matched = !anyCallReverted && allAssertionsPassed; + return { + outcomeMatched: matched, + outcomeSummary: matched + ? "Exploit scenario completed without reverts and all assertions passed" + : anyCallReverted + ? `Exploit scenario had an unexpected revert (call[${callResults.findIndex((r) => r.reverted)}])` + : "One or more assertions failed", + }; + } + case "exploit-reverts": { + const matched = anyCallReverted; + return { + outcomeMatched: matched, + outcomeSummary: matched + ? "Exploit scenario reverted as expected (defensive mechanism present)" + : "Exploit scenario did not revert — potential vulnerability confirmed", + }; + } + case "secure-baseline": { + const matched = !anyCallReverted && allAssertionsPassed; + return { + outcomeMatched: matched, + outcomeSummary: matched + ? "Secure baseline executed cleanly" + : "Secure baseline had unexpected behavior", + }; + } + case "custom": { + return { + outcomeMatched: allAssertionsPassed && !anyCallReverted, + outcomeSummary: scenario.outcomeDescription ?? "Custom outcome — check assertions", + }; + } + } + } + + #checkCancelled(_limits: ResolvedResourceLimits): void { + if (this.opts.signal?.cancelled) { + throw new ValidationError("Validation cancelled by signal", "TIMEOUT"); + } + } + + #checkTimeout(startMs: number, limits: ResolvedResourceLimits, scenarioId: string): void { + if (Date.now() - startMs > limits.timeoutMs) { + throw new ValidationTimeoutError(scenarioId, limits.timeoutMs); + } + } +} + +// ─── Minimizer ──────────────────────────────────────────────────────────────── + +import type { MinimizationResult } from "./types"; + +export interface MinimizerOptions { + /** Maximum number of scenario re-executions (default: 50). */ + maxTrials?: number; + /** Resource limits for each trial execution. */ + limits?: Partial; + signal?: ValidationCancellationSignal; +} + +/** + * Attempt to remove redundant calls from a scenario while preserving the outcome. + * + * Uses a greedy backward-elimination strategy: try removing each call from the + * end of the list first (since setup calls at the start are usually necessary). + */ +export async function minimizeScenario( + scenario: ValidationScenario, + adapter: EvmAdapter, + opts: MinimizerOptions = {}, +): Promise { + const maxTrials = opts.maxTrials ?? 50; + let trialsUsed = 0; + let budgetExceeded = false; + const removedIndices: number[] = []; + let current = { ...scenario, calls: [...scenario.calls] }; + const runner = new ValidationRunner(adapter, { + limits: opts.limits, + signal: opts.signal, + }); + + // First establish baseline outcome + const baseline = await runner.run(current); + trialsUsed++; + + if (!baseline.outcomeMatched) { + // Can't minimize if baseline doesn't match outcome + return { + originalCallCount: scenario.calls.length, + minimizedScenario: scenario, + minimizedCallCount: scenario.calls.length, + removedCallIndices: [], + trialsUsed, + budgetExceeded: false, + }; + } + + // Try removing each call (backward iteration for greedy) + for (let i = current.calls.length - 1; i >= 0; i--) { + if (opts.signal?.cancelled) break; + if (trialsUsed >= maxTrials) { + budgetExceeded = true; + break; + } + + const candidate = { + ...current, + calls: current.calls.filter((_, idx) => idx !== i), + }; + + if (candidate.calls.length === 0) continue; + + try { + const result = await runner.run(candidate); + trialsUsed++; + if (result.outcomeMatched) { + // Removal preserved outcome — keep it + removedIndices.push(i); + current = candidate; + // Adjust indices for next iteration + i = Math.min(i, current.calls.length - 1) + 1; + } + } catch { + trialsUsed++; + // Assume this removal broke something + } + } + + return { + originalCallCount: scenario.calls.length, + minimizedScenario: current, + minimizedCallCount: current.calls.length, + removedCallIndices: removedIndices.sort((a, b) => a - b), + trialsUsed, + budgetExceeded, + }; +} + +// ─── Sanitize scenario (strip secrets before persisting) ───────────────────── + +/** + * Return a copy of the scenario with private keys and fork URLs removed. + * @internal + */ +export function sanitizeScenario(scenario: ValidationScenario): ValidationScenario { + return { + ...scenario, + chain: { + ...scenario.chain, + forkUrl: scenario.chain.forkUrl ? "[redacted]" : undefined, + }, + accounts: scenario.accounts.map((a) => ({ + ...a, + privateKey: undefined, + })), + }; +} + +// ─── Batch runner ───────────────────────────────────────────────────────────── + +import type { ValidationReport } from "./types"; +import { AnvilAdapter } from "./anvil-adapter"; +import { HardhatAdapter } from "./hardhat-adapter"; + +export interface RunValidationOptions { + adapterType?: "anvil" | "hardhat"; + adapterBinaryPath?: string; + limits?: Partial; + signal?: ValidationCancellationSignal; + forkUrl?: string; + forkBlockNumber?: number; + chainId?: number; + verbosity?: 0 | 1 | 2; +} + +/** + * Run all scenarios in a plan and return a {@link ValidationReport}. + * + * Creates and manages the adapter lifecycle automatically. + */ +export async function runValidationPlan( + scenarios: ValidationScenario[], + opts: RunValidationOptions = {}, +): Promise { + const startMs = Date.now(); + const adapterType = opts.adapterType ?? "anvil"; + + // Build adapter for each scenario independently (process isolation) + const results: ValidationResult[] = []; + + for (const scenario of scenarios) { + if (opts.signal?.cancelled) break; + + let adapter: EvmAdapter; + if (adapterType === "hardhat") { + adapter = new HardhatAdapter({ + binaryPath: opts.adapterBinaryPath, + limits: opts.limits, + forkUrl: opts.forkUrl ?? scenario.chain.forkUrl, + forkBlockNumber: opts.forkBlockNumber ?? scenario.chain.forkBlockNumber, + chainId: opts.chainId ?? scenario.chain.chainId, + verbosity: opts.verbosity ?? 0, + }); + } else { + adapter = new AnvilAdapter({ + binaryPath: opts.adapterBinaryPath, + limits: opts.limits, + forkUrl: opts.forkUrl ?? scenario.chain.forkUrl, + forkBlockNumber: opts.forkBlockNumber ?? scenario.chain.forkBlockNumber, + chainId: opts.chainId ?? scenario.chain.chainId, + verbosity: opts.verbosity ?? 0, + }); + } + + try { + await adapter.start(opts.limits); + const runner = new ValidationRunner(adapter, { + limits: opts.limits, + signal: opts.signal, + }); + const result = await runner.run(scenario); + results.push(result); + } catch (err) { + // Infrastructure failure — record as errored + const errMsg = + err instanceof Error + ? sanitizeErrorMessage(err.message) + : "Unknown infrastructure error"; + results.push({ + schemaVersion: VALIDATION_SCHEMA_VERSION, + scenario: sanitizeScenario(scenario), + adapterType, + adapterVersion: "unknown", + snapshotId: "", + snapshotBlock: 0, + callResults: [], + outcomeMatched: false, + outcomeSummary: `Infrastructure error: ${errMsg}`, + storageAssertionResults: [], + balanceAssertionResults: [], + eventAssertionResults: [], + totalGasUsed: 0, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + durationMs: 0, + warnings: [], + error: errMsg, + }); + } finally { + await adapter.dispose().catch(() => {/* ignore */}); + } + } + + const passed = results.filter((r) => r.outcomeMatched && !r.error).length; + const errored = results.filter((r) => !!r.error).length; + const failed = results.length - passed - errored; + + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + timestamp: new Date().toISOString(), + total: results.length, + passed, + failed, + errored, + results, + adapterType, + totalDurationMs: Date.now() - startMs, + }; +} diff --git a/packages/core/src/validation/scaffold.ts b/packages/core/src/validation/scaffold.ts new file mode 100644 index 0000000..b005325 --- /dev/null +++ b/packages/core/src/validation/scaffold.ts @@ -0,0 +1,616 @@ +/** + * Finding-to-Scaffold Translator. + * + * Translates static {@link Finding} objects emitted by ChainProof's analysis + * pipeline into parameterized {@link ValidationScenario} scaffolds. + * + * @remarks + * This module does NOT claim automatic exploitability. Scenarios produced + * here are reproduction scaffolds — a starting point for a researcher to + * fill in contract bytecode and verify behavior. The `expectedOutcome` for + * all generated scenarios is `"exploit-succeeds"` to indicate "this is + * what we expect *if* the finding is exploitable"; it is the researcher's + * job to confirm or refute this by running the scenario. + * + * Supported finding IDs: + * - CP-107 / SWC-107: Reentrancy + * - CP-115 / SWC-115: tx.origin authentication + * - CP-101 / SWC-101: Integer overflow/underflow + * - CP-104 / SWC-104: Unchecked call return value + * - CP-122: Vault share-price inflation + * - CP-CB-CEI: Callback CEI violation + * - CP-CB-CROSSFN: Cross-function reentrancy via callback + * - CP-CB-SPOOF: Callback spoofing + * + * All other findings produce an {@link UnsupportedFinding} entry. + */ + +import * as path from "path"; +import * as crypto from "crypto"; +import type { Finding } from "../types"; +import type { + AccountSpec, + CallSpec, + ContractSpec, + UnsupportedFinding, + ValidationPlan, + ValidationScenario, +} from "./types"; +import { + VALIDATION_SCHEMA_VERSION, + CorruptBundleError, +} from "./types"; + +// ─── Supported finding IDs ──────────────────────────────────────────────────── + +const SUPPORTED_FINDING_IDS = new Set([ + "CP-107", + "SWC-107", + "CP-107-X", + "CP-115", + "SWC-115", + "CP-101", + "SWC-101", + "CP-104", + "SWC-104", + "CP-122", + "CP-CB-CEI", + "CP-CB-CROSSFN", + "CP-CB-SPOOF", + "CP-CB-BATCH", + "CP-CB-READONLY", +]); + +// ─── Default scenario accounts ─────────────────────────────────────────────── + +const SCAFFOLD_ACCOUNTS: AccountSpec[] = [ + { + address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + balance: "100000000000000000000", // 100 ETH + label: "deployer", + }, + { + address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + balance: "100000000000000000000", + label: "attacker", + }, + { + address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + balance: "100000000000000000000", + label: "victim", + }, +]; + +// ─── Scaffold generators ────────────────────────────────────────────────────── + +function makeScenarioId(finding: Finding, suffix?: string): string { + const base = [ + "scenario", + finding.id.replace(/[^A-Za-z0-9]/g, "-"), + path.basename(finding.file, ".sol"), + finding.line, + ] + .join("-") + .toLowerCase(); + const hash = crypto.createHash("sha1") + .update(finding.file + ":" + finding.line + ":" + finding.id) + .digest("hex") + .slice(0, 8); + return suffix ? `${base}-${suffix}-${hash}` : `${base}-${hash}`; +} + +function scaffoldReentrancy(finding: Finding): ValidationScenario { + const contracts: ContractSpec[] = [ + { + name: "VulnerableContract", + bytecode: "0x", // Researcher must supply compiled bytecode + abi: "[]", + deployer: "deployer", + }, + { + name: "AttackerContract", + bytecode: "0x", // Researcher must supply attacker contract bytecode + abi: "[]", + deployer: "attacker", + }, + ]; + + const calls: CallSpec[] = [ + { + to: "VulnerableContract", + signature: "deposit()", + value: "1000000000000000000", + from: "victim", + description: "Victim deposits 1 ETH into the vulnerable contract", + }, + { + to: "AttackerContract", + signature: "attack()", + from: "attacker", + description: "Attacker triggers the reentrancy exploit", + }, + ]; + + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + id: makeScenarioId(finding), + title: `Reentrancy reproduction scaffold: ${path.basename(finding.file)} L${finding.line}`, + description: + `Scaffold for finding ${finding.id}. ` + + `Supply compiled bytecode for VulnerableContract and AttackerContract, ` + + `then adjust call sequence to match the actual vulnerable function. ` + + `This scenario is NOT claimed to be automatically exploitable.`, + findingId: finding.id, + findingFile: finding.file, + findingLine: finding.line, + chain: { chainId: 31337 }, + accounts: SCAFFOLD_ACCOUNTS, + contracts, + calls, + expectedOutcome: "exploit-succeeds", + outcomeDescription: + "AttackerContract should drain VulnerableContract ETH via reentrancy. " + + "Validate by checking attacker balance increased and vault balance decreased.", + balanceAssertions: [ + { + account: "AttackerContract", + op: "gt", + value: "1000000000000000000", + description: "Attacker's contract gained ETH from the reentrancy", + }, + ], + tags: ["reentrancy", "CP-107", "SWC-107"], + createdAt: new Date().toISOString(), + }; +} + +function scaffoldTxOrigin(finding: Finding): ValidationScenario { + const calls: CallSpec[] = [ + { + to: "VulnerableContract", + signature: "privilegedAction()", + from: "attacker", + description: + "Attacker calls the function protected only by tx.origin. " + + "In a real exploit, attacker tricks the owner into calling attacker's contract, " + + "which then calls this function — bypassing the tx.origin check.", + }, + ]; + + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + id: makeScenarioId(finding), + title: `tx.origin auth bypass scaffold: ${path.basename(finding.file)} L${finding.line}`, + description: + `Scaffold for finding ${finding.id} (tx.origin authentication bypass). ` + + `Real exploitation requires a phishing vector: owner calls attacker's ` + + `contract, which calls back into VulnerableContract. ` + + `This scenario demonstrates the direct call path for analysis purposes.`, + findingId: finding.id, + findingFile: finding.file, + findingLine: finding.line, + chain: { chainId: 31337 }, + accounts: SCAFFOLD_ACCOUNTS, + contracts: [ + { + name: "VulnerableContract", + bytecode: "0x", + abi: "[]", + deployer: "deployer", + }, + ], + calls, + expectedOutcome: "exploit-succeeds", + tags: ["tx-origin", "CP-115", "SWC-115"], + createdAt: new Date().toISOString(), + }; +} + +function scaffoldIntegerOverflow(finding: Finding): ValidationScenario { + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + id: makeScenarioId(finding), + title: `Integer overflow/underflow scaffold: ${path.basename(finding.file)} L${finding.line}`, + description: + `Scaffold for finding ${finding.id}. ` + + `Supply bytecode compiled with solc < 0.8.0 (no checked arithmetic). ` + + `The scenario attempts to trigger overflow by passing boundary values.`, + findingId: finding.id, + findingFile: finding.file, + findingLine: finding.line, + chain: { chainId: 31337 }, + accounts: SCAFFOLD_ACCOUNTS, + contracts: [ + { + name: "VulnerableContract", + bytecode: "0x", + abi: "[]", + deployer: "deployer", + }, + ], + calls: [ + { + to: "VulnerableContract", + signature: "transfer(address,uint256)", + args: ["0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"], + from: "attacker", + description: "Transfer uint256.MAX to trigger overflow", + }, + ], + expectedOutcome: "exploit-succeeds", + tags: ["overflow", "CP-101", "SWC-101"], + createdAt: new Date().toISOString(), + }; +} + +function scaffoldUncheckedReturn(finding: Finding): ValidationScenario { + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + id: makeScenarioId(finding), + title: `Unchecked call return value scaffold: ${path.basename(finding.file)} L${finding.line}`, + description: + `Scaffold for finding ${finding.id}. ` + + `Demonstrates that the contract continues execution even when the low-level ` + + `call fails (returns false). The calling contract must NOT revert on failure.`, + findingId: finding.id, + findingFile: finding.file, + findingLine: finding.line, + chain: { chainId: 31337 }, + accounts: SCAFFOLD_ACCOUNTS, + contracts: [ + { + name: "VulnerableContract", + bytecode: "0x", + abi: "[]", + deployer: "deployer", + }, + { + name: "AlwaysRevertingTarget", + // Minimal contract: PUSH1 0x00 DUP1 REVERT + bytecode: "0x600060006000600060006000fa", + abi: "[]", + deployer: "deployer", + }, + ], + calls: [ + { + to: "VulnerableContract", + signature: "sendEther(address)", + args: ["AlwaysRevertingTarget"], + from: "attacker", + description: "Trigger the unchecked send to a reverting contract", + expectRevert: false, + }, + ], + expectedOutcome: "exploit-succeeds", + tags: ["unchecked-return", "CP-104", "SWC-104"], + createdAt: new Date().toISOString(), + }; +} + +function scaffoldVaultInflation(finding: Finding): ValidationScenario { + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + id: makeScenarioId(finding), + title: `Vault share-price inflation scaffold: ${path.basename(finding.file)} L${finding.line}`, + description: + `Scaffold for finding ${finding.id} (ERC-4626-style share inflation). ` + + `The attacker mints a tiny share count, then donates to inflate the price-per-share, ` + + `forcing the next depositor's shares to round to zero.`, + findingId: finding.id, + findingFile: finding.file, + findingLine: finding.line, + chain: { chainId: 31337 }, + accounts: SCAFFOLD_ACCOUNTS, + contracts: [ + { + name: "VulnerableVault", + bytecode: "0x", + abi: "[]", + deployer: "deployer", + }, + { + name: "UnderlyingToken", + bytecode: "0x", + abi: "[]", + deployer: "deployer", + }, + ], + calls: [ + { + to: "UnderlyingToken", + signature: "approve(address,uint256)", + args: ["VulnerableVault", "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"], + from: "attacker", + description: "Attacker approves vault to spend unlimited tokens", + }, + { + to: "VulnerableVault", + signature: "deposit(uint256,address)", + args: [1, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"], + from: "attacker", + description: "Attacker deposits 1 wei to get first shares", + }, + { + to: "UnderlyingToken", + signature: "transfer(address,uint256)", + args: ["VulnerableVault", "1000000000000000000"], + from: "attacker", + description: "Attacker donates 1 ETH worth of tokens directly to inflate price-per-share", + }, + { + to: "VulnerableVault", + signature: "deposit(uint256,address)", + args: ["500000000000000000", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"], + from: "victim", + description: "Victim deposits 0.5 ETH worth — rounds to 0 shares", + }, + ], + expectedOutcome: "exploit-succeeds", + tags: ["vault-inflation", "CP-122", "erc4626"], + createdAt: new Date().toISOString(), + }; +} + +function scaffoldCallbackCEI(finding: Finding): ValidationScenario { + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + id: makeScenarioId(finding), + title: `Callback CEI violation scaffold: ${path.basename(finding.file)} L${finding.line}`, + description: + `Scaffold for finding ${finding.id}. ` + + `A callback (ERC-721/1155/777/3156) is fired before state is finalized, ` + + `allowing a malicious receiver to re-enter with stale state. ` + + `Supply MaliciousReceiver bytecode that re-enters during the callback.`, + findingId: finding.id, + findingFile: finding.file, + findingLine: finding.line, + chain: { chainId: 31337 }, + accounts: SCAFFOLD_ACCOUNTS, + contracts: [ + { + name: "VulnerableContract", + bytecode: "0x", + abi: "[]", + deployer: "deployer", + }, + { + name: "MaliciousReceiver", + bytecode: "0x", + abi: "[]", + deployer: "attacker", + }, + ], + calls: [ + { + to: "VulnerableContract", + signature: "safeMint(address,uint256)", + args: ["MaliciousReceiver", 1], + from: "attacker", + description: "Trigger mint → callback → re-entry exploit chain", + }, + ], + expectedOutcome: "exploit-succeeds", + tags: ["callback-reentrancy", "CP-CB-CEI"], + createdAt: new Date().toISOString(), + }; +} + +function scaffoldCallbackSpoof(finding: Finding): ValidationScenario { + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + id: makeScenarioId(finding), + title: `Callback spoofing scaffold: ${path.basename(finding.file)} L${finding.line}`, + description: + `Scaffold for finding ${finding.id}. ` + + `A receiver hook function lacks msg.sender validation, allowing anyone to ` + + `call it directly and trigger state changes as if a legitimate transfer occurred.`, + findingId: finding.id, + findingFile: finding.file, + findingLine: finding.line, + chain: { chainId: 31337 }, + accounts: SCAFFOLD_ACCOUNTS, + contracts: [ + { + name: "VulnerableContract", + bytecode: "0x", + abi: "[]", + deployer: "deployer", + }, + ], + calls: [ + { + to: "VulnerableContract", + signature: "onERC721Received(address,address,uint256,bytes)", + args: [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + 1, + "0x", + ], + from: "attacker", + description: "Attacker directly calls the unguarded hook to spoof a transfer", + }, + ], + expectedOutcome: "exploit-succeeds", + tags: ["callback-spoof", "CP-CB-SPOOF"], + createdAt: new Date().toISOString(), + }; +} + +// ─── Dispatcher ─────────────────────────────────────────────────────────────── + +function scaffoldFinding(finding: Finding): ValidationScenario | null { + const id = finding.id; + if (id === "CP-107" || id === "SWC-107" || id === "CP-107-X") { + return scaffoldReentrancy(finding); + } + if (id === "CP-115" || id === "SWC-115") { + return scaffoldTxOrigin(finding); + } + if (id === "CP-101" || id === "SWC-101") { + return scaffoldIntegerOverflow(finding); + } + if (id === "CP-104" || id === "SWC-104") { + return scaffoldUncheckedReturn(finding); + } + if (id === "CP-122") { + return scaffoldVaultInflation(finding); + } + if (id === "CP-CB-CEI" || id === "CP-CB-CROSSFN" || id === "CP-CB-READONLY") { + return scaffoldCallbackCEI(finding); + } + if (id === "CP-CB-SPOOF") { + return scaffoldCallbackSpoof(finding); + } + if (id === "CP-CB-BATCH") { + // Batch callback DoS — just note it's not executable without a real contract + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + id: makeScenarioId(finding), + title: `Unbounded batch callback DoS scaffold: ${path.basename(finding.file)} L${finding.line}`, + description: + `Scaffold for finding ${finding.id}. ` + + `Supply the contract bytecode and a very large array to demonstrate gas exhaustion.`, + findingId: finding.id, + findingFile: finding.file, + findingLine: finding.line, + chain: { chainId: 31337 }, + accounts: SCAFFOLD_ACCOUNTS, + contracts: [{ name: "VulnerableContract", bytecode: "0x", abi: "[]", deployer: "deployer" }], + calls: [ + { + to: "VulnerableContract", + signature: "batchMint(address[],uint256[])", + args: [ + Array.from({ length: 1000 }, () => "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"), + Array.from({ length: 1000 }, (_, i) => i + 1), + ], + from: "attacker", + description: "Send 1000-element array to trigger unbounded gas usage", + gasLimit: 30_000_000, + }, + ], + expectedOutcome: "exploit-reverts", + outcomeDescription: "Call should run out of gas or hit block gas limit", + tags: ["dos", "CP-CB-BATCH"], + createdAt: new Date().toISOString(), + }; + } + return null; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** Options for `planValidation`. */ +export interface PlanValidationOptions { + /** + * If true, generate one scenario per unique (findingId, file, line) triple. + * If false (default), deduplicate findings with the same ID and file. + */ + deduplicateByFile?: boolean; + /** + * Only include findings with severity at or above this level. + * Defaults to "low" (all except gas). + */ + minSeverity?: "critical" | "high" | "medium" | "low" | "info"; +} + +const SEVERITY_RANK: Record = { + critical: 5, high: 4, medium: 3, low: 2, info: 1, gas: 0, +}; + +/** + * Build a {@link ValidationPlan} from an array of static findings. + * + * This is the main entry point for the `chainproof validate plan` command. + */ +export function planValidation( + findings: Finding[], + opts: PlanValidationOptions = {}, +): ValidationPlan { + const minRank = SEVERITY_RANK[opts.minSeverity ?? "low"] ?? 2; + const eligible = findings.filter( + (f) => + (SEVERITY_RANK[f.severity] ?? 0) >= minRank && + f.severity !== "gas" && + !f.id.startsWith("GAS-"), + ); + + const seen = new Set(); + const scenarios: ValidationScenario[] = []; + const unsupported: UnsupportedFinding[] = []; + + for (const finding of eligible) { + const key = opts.deduplicateByFile + ? `${finding.id}|${finding.file}|${finding.line}` + : `${finding.id}|${finding.file}`; + + if (seen.has(key)) continue; + seen.add(key); + + if (!SUPPORTED_FINDING_IDS.has(finding.id)) { + // Check if it's a Slither finding with a known mapping + if (!finding.id.startsWith("CP-") && !finding.id.startsWith("SWC-")) { + unsupported.push({ + findingId: finding.id, + findingFile: finding.file, + findingLine: finding.line, + reason: `Finding ID "${finding.id}" is not in the supported scaffold set. ` + + `Supported IDs: ${[...SUPPORTED_FINDING_IDS].join(", ")}.`, + }); + continue; + } + } + + const scenario = scaffoldFinding(finding); + if (scenario) { + scenarios.push(scenario); + } else { + unsupported.push({ + findingId: finding.id, + findingFile: finding.file, + findingLine: finding.line, + reason: `No scaffold template for finding ID "${finding.id}".`, + }); + } + } + + return { + schemaVersion: VALIDATION_SCHEMA_VERSION, + scenarios, + unsupportedFindings: unsupported, + createdAt: new Date().toISOString(), + }; +} + +/** + * Serialize a {@link ValidationPlan} to JSON (deterministic key order). + */ +export function serializeValidationPlan(plan: ValidationPlan): string { + return JSON.stringify(plan, null, 2); +} + +/** + * Parse and validate a {@link ValidationPlan} from a JSON string. + * Throws {@link CorruptBundleError} on schema mismatch. + */ +export function parseValidationPlan(json: string, filePath = ""): ValidationPlan { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (err) { + throw new CorruptBundleError(filePath, "Invalid JSON"); + } + const obj = parsed as Record; + if (!obj || typeof obj !== "object") { + throw new CorruptBundleError(filePath, "Root must be an object"); + } + if (typeof obj["schemaVersion"] !== "string") { + throw new CorruptBundleError(filePath, "Missing schemaVersion"); + } + if (!Array.isArray(obj["scenarios"])) { + throw new CorruptBundleError(filePath, "Missing scenarios array"); + } + return obj as unknown as ValidationPlan; +} diff --git a/packages/core/src/validation/types.ts b/packages/core/src/validation/types.ts new file mode 100644 index 0000000..cb104eb --- /dev/null +++ b/packages/core/src/validation/types.ts @@ -0,0 +1,709 @@ +/** + * @packageDocumentation + * Core types for the fork-aware concrete validation and exploit reproduction harness. + * + * Scenarios describe the EVM state needed to reproduce a finding. Adapters + * encapsulate process-isolated EVM backends (Anvil, Hardhat Network). + * ValidationBundles are the portable, versioned output of a validation run. + * + * @remarks + * All types are versioned. When the schema changes in a breaking way, + * VALIDATION_SCHEMA_VERSION is bumped and migration helpers must be provided. + * Serialized bundles carry a `schemaVersion` field so offline replay tools + * can reject incompatible files without crashing. + * + * Security boundaries: adapters run external processes with bounded resources + * (time, memory, fds). No secrets, local paths beyond the project root, or + * provider credentials are persisted into bundles. Scenario sources are + * embedded as sanitized bytecode or constructor arguments, not file paths. + */ + +/** Schema version for serialized validation scenarios and bundles. */ +export const VALIDATION_SCHEMA_VERSION = "1.0.0"; + +// ─── Chain / block context ──────────────────────────────────────────────────── + +/** + * Identifies the chain and block context for a validation scenario. + * + * When `forkUrl` is provided the adapter forks at `forkBlockNumber` + * (or latest, if omitted) and replays the scenario against real state. + * When both are absent the adapter starts a fresh in-process devnet. + */ +export interface ChainContext { + /** EIP-155 chain id. Defaults to 31337 (Hardhat/Anvil devnet). */ + chainId?: number; + /** + * Remote JSON-RPC URL to fork from. + * MUST NOT be serialized into a bundle; replaced with `forkBlockNumber` + * and a redacted placeholder when bundles are written. + */ + forkUrl?: string; + /** + * Block number to pin the fork. Required for deterministic replay + * when `forkUrl` is set. If absent, the adapter fetches and pins latest. + */ + forkBlockNumber?: number; + /** + * Unix timestamp (seconds) to use for the first block. + * Deterministic replay requires this to be pinned. + */ + timestamp?: number; + /** Base fee per gas in wei for the first block. */ + baseFeePerGas?: string; +} + +// ─── Accounts ──────────────────────────────────────────────────────────────── + +/** A funded account with an optional private key for transaction signing. */ +export interface AccountSpec { + /** Hex address (0x-prefixed, EIP-55 or lowercase). */ + address: string; + /** Initial balance in wei (decimal or 0x-prefixed hex string). */ + balance?: string; + /** + * Private key for signing (0x-prefixed hex). + * Absent for read-only or externally owned accounts. + * MUST NOT be emitted in bundles; adapters accept it transiently. + */ + privateKey?: string; + /** Human-readable role label for report generation. */ + label?: string; +} + +// ─── Deployed contracts ─────────────────────────────────────────────────────── + +/** + * A contract to deploy or alias at a fixed address before the scenario runs. + */ +export interface ContractSpec { + /** Symbolic name used in call specs (e.g. "Vault"). */ + name: string; + /** + * Pre-computed deployment bytecode (0x-prefixed hex). + * Either `bytecode` or `address` must be provided. + */ + bytecode?: string; + /** ABI as a JSON string (array of ABI items). */ + abi?: string; + /** + * ABI-encoded constructor arguments (0x-prefixed hex, no "0x" function selector). + * Appended to `bytecode` at deploy time. + */ + constructorArgs?: string; + /** + * Pre-existing contract address. When set, no deployment happens; + * the symbolic name is simply aliased to this address. + * Requires `forkUrl` in the chain context. + */ + address?: string; + /** + * Storage slots to pre-set before any calls. + * Keys are 0x-prefixed 32-byte slot indices; values are 0x-prefixed 32-byte values. + */ + storageOverrides?: Record; + /** Deployer account label (must match an AccountSpec label or address). */ + deployer?: string; +} + +// ─── Transactions / calls ───────────────────────────────────────────────────── + +/** A single EVM call or state-mutating transaction in the scenario. */ +export interface CallSpec { + /** Target contract name (from ContractSpec.name) or raw hex address. */ + to: string; + /** + * ABI function signature, e.g. `"withdraw(uint256)"`. + * Used to encode calldata when `calldata` is absent. + */ + signature?: string; + /** ABI-decoded arguments as JSON-serializable values. */ + args?: unknown[]; + /** + * Raw calldata (0x-prefixed hex). Takes precedence over `signature`+`args`. + */ + calldata?: string; + /** Sender account label or address. Defaults to the first AccountSpec. */ + from?: string; + /** Value in wei (decimal or 0x-prefixed hex). */ + value?: string; + /** Gas limit override. */ + gasLimit?: number; + /** + * Whether to treat a revert as expected (scenario assertion). + * When true, the call is considered passing if and only if it reverts. + */ + expectRevert?: boolean; + /** Human-readable description for report generation. */ + description?: string; +} + +// ─── Storage / outcome assumptions ─────────────────────────────────────────── + +/** A storage slot expected to hold a particular value after the scenario. */ +export interface StorageAssertion { + /** Contract name or address. */ + contract: string; + /** Slot index (0x-prefixed 32-byte hex). */ + slot: string; + /** Expected value (0x-prefixed 32-byte hex). */ + expected: string; + /** Human-readable description. */ + description?: string; +} + +/** An account balance assertion after the scenario. */ +export interface BalanceAssertion { + /** Account label or address. */ + account: string; + /** Comparison operator. */ + op: "eq" | "gt" | "gte" | "lt" | "lte"; + /** Value in wei (decimal or 0x-prefixed hex). */ + value: string; + /** Human-readable description. */ + description?: string; +} + +/** A log/event emission assertion. */ +export interface EventAssertion { + /** Contract name or address that emitted the event. */ + contract: string; + /** Event signature, e.g. `"Transfer(address,address,uint256)"`. */ + eventSignature: string; + /** Whether the event must NOT have been emitted. */ + negate?: boolean; + /** Human-readable description. */ + description?: string; +} + +// ─── The Validation Scenario ────────────────────────────────────────────────── + +/** + * A fully self-contained description of a validation experiment. + * + * Scenarios are produced either by the scaffold translator (from static + * findings) or hand-authored by security researchers. They are versioned, + * deterministic by default, and reproducible offline. + * + * @example Reentrancy reproduction scaffold + * ```json + * { + * "schemaVersion": "1.0.0", + * "id": "scenario-CP-107-VulnerableVault-withdraw", + * "title": "Reentrancy in VulnerableVault.withdraw", + * "findingId": "CP-107", + * "findingFile": "contracts/VulnerableVault.sol", + * "findingLine": 42, + * "chain": { "chainId": 31337 }, + * "accounts": [ + * { "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "balance": "10000000000000000000", "label": "attacker" } + * ], + * "contracts": [ + * { "name": "Vault", "bytecode": "0x...", "abi": "[...]" } + * ], + * "calls": [ + * { "to": "Vault", "signature": "deposit()", "value": "1000000000000000000", "from": "attacker" }, + * { "to": "Vault", "signature": "withdraw(uint256)", "args": ["1000000000000000000"], "from": "attacker" } + * ], + * "expectedOutcome": "exploit-succeeds" + * } + * ``` + */ +export interface ValidationScenario { + /** Schema version for migration and compatibility checks. */ + schemaVersion: string; + /** + * Stable unique identifier. + * Convention: `scenario-{findingId}-{contractName}-{functionName}`. + */ + id: string; + /** Human-readable title. */ + title: string; + /** Short explanation of what this scenario tests. */ + description?: string; + /** + * Finding ID this scenario was generated from (e.g. "CP-107"). + * Absent for hand-authored scenarios. + */ + findingId?: string; + /** Source file the finding was detected in. */ + findingFile?: string; + /** 1-indexed line number of the finding. */ + findingLine?: number; + /** Chain context (devnet or fork). */ + chain: ChainContext; + /** Accounts to pre-fund. */ + accounts: AccountSpec[]; + /** Contracts to deploy or alias. */ + contracts: ContractSpec[]; + /** + * Ordered list of calls. They execute in sequence. + * A snapshot is taken before call[0] and is available for replay. + */ + calls: CallSpec[]; + /** + * Storage assertions checked after all calls complete. + */ + storageAssertions?: StorageAssertion[]; + /** Balance assertions checked after all calls complete. */ + balanceAssertions?: BalanceAssertion[]; + /** Event assertions checked after all calls complete. */ + eventAssertions?: EventAssertion[]; + /** + * What outcome this scenario is designed to demonstrate. + * + * - `exploit-succeeds` — the calls execute without reverting and demonstrate the vulnerability. + * - `exploit-reverts` — the calls revert, suggesting a guard is in place. + * - `secure-baseline` — the equivalent hardened variant; used for false-positive controls. + * - `custom` — researcher-defined outcome; `outcomeDescription` is mandatory. + */ + expectedOutcome: "exploit-succeeds" | "exploit-reverts" | "secure-baseline" | "custom"; + /** + * Required when `expectedOutcome` is `"custom"`. + */ + outcomeDescription?: string; + /** + * Resource limits for this scenario (overrides adapter defaults). + */ + limits?: ScenarioResourceLimits; + /** + * Tags for filtering and grouping (e.g. `["reentrancy", "erc20"]`). + */ + tags?: string[]; + /** ISO-8601 creation timestamp. */ + createdAt?: string; +} + +// ─── Resource limits ────────────────────────────────────────────────────────── + +/** Resource bounds for a single validation run. */ +export interface ScenarioResourceLimits { + /** Maximum wall-clock time in milliseconds for the entire scenario (default: 30_000). */ + timeoutMs?: number; + /** Maximum memory in bytes for the adapter process (default: 512 * 1024 * 1024). */ + maxMemoryBytes?: number; + /** Maximum number of calls in the scenario (default: 100). */ + maxCalls?: number; + /** Maximum gas per call (default: 30_000_000). */ + maxGasPerCall?: number; + /** Maximum number of log entries captured (default: 1000). */ + maxLogs?: number; +} + +/** Resolved resource limits with all defaults applied. */ +export interface ResolvedResourceLimits { + timeoutMs: number; + maxMemoryBytes: number; + maxCalls: number; + maxGasPerCall: number; + maxLogs: number; +} + +export const DEFAULT_RESOURCE_LIMITS: ResolvedResourceLimits = { + timeoutMs: 30_000, + maxMemoryBytes: 512 * 1024 * 1024, + maxCalls: 100, + maxGasPerCall: 30_000_000, + maxLogs: 1_000, +}; + +// ─── Execution trace / results ──────────────────────────────────────────────── + +/** A single emitted log entry from an EVM call. */ +export interface LogEntry { + /** Emitting contract address. */ + address: string; + /** Log topics (0x-prefixed 32-byte hex strings). */ + topics: string[]; + /** ABI-decoded event name, if available. */ + eventName?: string; + /** Log data (0x-prefixed hex). */ + data: string; +} + +/** Storage diff for a single contract from a single call. */ +export interface StorageDiff { + /** Contract address. */ + address: string; + /** Map from slot (0x-prefixed 32-byte hex) to { before, after } values. */ + slots: Record; +} + +/** Result of executing a single {@link CallSpec}. */ +export interface CallResult { + /** Zero-based index into the scenario's calls array. */ + callIndex: number; + /** Whether the call reverted. */ + reverted: boolean; + /** Revert reason (ABI-decoded if possible, raw hex otherwise). */ + revertReason?: string; + /** Return data (0x-prefixed hex). */ + returnData?: string; + /** Gas used by this call. */ + gasUsed: number; + /** Emitted logs. */ + logs: LogEntry[]; + /** Storage changes. */ + storageDiff: StorageDiff[]; + /** Call trace in a human-readable format (optional, adapter-dependent). */ + callTrace?: string; +} + +/** Outcome of a storage assertion check. */ +export interface StorageAssertionResult { + assertion: StorageAssertion; + actual: string; + passed: boolean; +} + +/** Outcome of a balance assertion check. */ +export interface BalanceAssertionResult { + assertion: BalanceAssertion; + actual: string; + passed: boolean; +} + +/** Outcome of an event assertion check. */ +export interface EventAssertionResult { + assertion: EventAssertion; + found: boolean; + passed: boolean; +} + +/** + * The complete result of executing a {@link ValidationScenario}. + * + * Portable and serializable. Contains enough information to replay or + * minimize the scenario offline without a live network. + */ +export interface ValidationResult { + /** Schema version for compatibility checks. */ + schemaVersion: string; + /** The scenario that was executed (without private keys or fork URLs). */ + scenario: ValidationScenario; + /** Which adapter backend was used. */ + adapterType: AdapterType; + /** Adapter version string (e.g. "anvil/0.2.0"). */ + adapterVersion: string; + /** + * The EVM snapshot ID recorded before the first call. + * Used by replay and minimize operations. + */ + snapshotId: string; + /** Block number at which the snapshot was taken. */ + snapshotBlock: number; + /** Results for each call in execution order. */ + callResults: CallResult[]; + /** + * Whether the scenario's `expectedOutcome` was met. + */ + outcomeMatched: boolean; + /** + * Detailed outcome description (what actually happened vs. what was expected). + */ + outcomeSummary: string; + /** Storage assertion results. */ + storageAssertionResults: StorageAssertionResult[]; + /** Balance assertion results. */ + balanceAssertionResults: BalanceAssertionResult[]; + /** Event assertion results. */ + eventAssertionResults: EventAssertionResult[]; + /** Total gas used across all calls. */ + totalGasUsed: number; + /** ISO-8601 timestamp when the run started. */ + startedAt: string; + /** ISO-8601 timestamp when the run completed. */ + completedAt: string; + /** Wall-clock duration in milliseconds. */ + durationMs: number; + /** + * Any non-fatal warnings generated during the run + * (e.g. fork block older than 256 blocks, unresolved ABI). + */ + warnings: string[]; + /** + * Error message if the run failed due to an infrastructure error + * (adapter crash, timeout, OOM) rather than a revert. + */ + error?: string; +} + +// ─── Adapter types ──────────────────────────────────────────────────────────── + +/** Which EVM backend this adapter wraps. */ +export type AdapterType = "anvil" | "hardhat"; + +/** Options for constructing an EVM adapter. */ +export interface AdapterOptions { + type: AdapterType; + /** Explicit binary path. Defaults to searching $PATH. */ + binaryPath?: string; + /** Override adapter-level resource limits (merged with per-scenario limits). */ + limits?: Partial; + /** JSON-RPC port to bind (0 = random ephemeral). */ + port?: number; + /** + * Verbosity level for the adapter process. + * 0 = silent, 1 = errors only, 2 = full (useful for debugging). + */ + verbosity?: 0 | 1 | 2; +} + +// ─── Snapshot / replay ──────────────────────────────────────────────────────── + +/** + * A named snapshot that can be restored to replay a scenario from + * a deterministic starting state. + */ +export interface SnapshotEntry { + /** Unique snapshot identifier (adapter-assigned). */ + snapshotId: string; + /** Scenario ID this snapshot belongs to. */ + scenarioId: string; + /** Block number at snapshot. */ + blockNumber: number; + /** ISO-8601 when the snapshot was taken. */ + takenAt: string; +} + +// ─── Minimizer ──────────────────────────────────────────────────────────────── + +/** + * The result of the scenario minimizer. + * + * The minimizer attempts to remove calls that are not needed to reproduce + * the finding, without changing the outcome. + */ +export interface MinimizationResult { + /** Original number of calls. */ + originalCallCount: number; + /** Minimized scenario with redundant calls removed. */ + minimizedScenario: ValidationScenario; + /** Minimized number of calls. */ + minimizedCallCount: number; + /** Indices of calls that were removed. */ + removedCallIndices: number[]; + /** Number of EVM executions used during minimization. */ + trialsUsed: number; + /** Whether minimization completed within its trial budget. */ + budgetExceeded: boolean; +} + +// ─── Plan output ───────────────────────────────────────────────────────────── + +/** The result of `chainproof validate plan`. */ +export interface ValidationPlan { + /** Schema version. */ + schemaVersion: string; + /** Scenarios generated from the provided findings. */ + scenarios: ValidationScenario[]; + /** + * Findings that could not be translated to a scenario + * (unsupported rule, missing bytecode, etc.). + */ + unsupportedFindings: UnsupportedFinding[]; + /** ISO-8601 creation timestamp. */ + createdAt: string; +} + +/** A finding that the scaffold translator could not handle. */ +export interface UnsupportedFinding { + findingId: string; + findingFile: string; + findingLine: number; + reason: string; +} + +// ─── Report ─────────────────────────────────────────────────────────────────── + +/** Aggregate validation report covering multiple scenario results. */ +export interface ValidationReport { + /** Schema version. */ + schemaVersion: string; + /** ISO-8601 timestamp. */ + timestamp: string; + /** Total number of scenarios executed. */ + total: number; + /** Scenarios where `outcomeMatched` is true. */ + passed: number; + /** Scenarios where `outcomeMatched` is false. */ + failed: number; + /** Scenarios that errored due to infrastructure failures. */ + errored: number; + /** Individual results. */ + results: ValidationResult[]; + /** Adapter type used. */ + adapterType: AdapterType; + /** Total wall-clock time in milliseconds. */ + totalDurationMs: number; +} + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +/** Base class for all validation-subsystem errors. */ +export class ValidationError extends Error { + constructor( + message: string, + /** Machine-readable error code. */ + public readonly code: ValidationErrorCode, + /** Optional additional context (sanitized, no secrets). */ + public readonly context?: Record, + ) { + super(message); + this.name = "ValidationError"; + } +} + +/** Thrown when a scenario exceeds its configured resource limits. */ +export class ValidationTimeoutError extends ValidationError { + constructor(scenarioId: string, limitMs: number) { + super( + `Scenario "${scenarioId}" exceeded time limit of ${limitMs}ms`, + "TIMEOUT", + { scenarioId, limitMs }, + ); + this.name = "ValidationTimeoutError"; + } +} + +/** Thrown when the EVM adapter process crashes or becomes unreachable. */ +export class AdapterCrashError extends ValidationError { + constructor(adapterType: AdapterType, detail: string) { + super( + `EVM adapter "${adapterType}" crashed or became unreachable: ${sanitizeErrorMessage(detail)}`, + "ADAPTER_CRASH", + { adapterType }, + ); + this.name = "AdapterCrashError"; + } +} + +/** Thrown when the fork RPC is unavailable or returns an unexpected response. */ +export class ForkUnavailableError extends ValidationError { + constructor(detail: string) { + super( + `Fork RPC unavailable: ${sanitizeErrorMessage(detail)}`, + "FORK_UNAVAILABLE", + ); + this.name = "ForkUnavailableError"; + } +} + +/** Thrown when a validation bundle file is corrupt or has an unsupported version. */ +export class CorruptBundleError extends ValidationError { + constructor(filePath: string, detail: string) { + super( + `Validation bundle at "${sanitizePath(filePath)}" is corrupt or unsupported: ${sanitizeErrorMessage(detail)}`, + "CORRUPT_BUNDLE", + ); + this.name = "CorruptBundleError"; + } +} + +/** Thrown when a scenario fails schema validation. */ +export class ScenarioValidationError extends ValidationError { + constructor(detail: string) { + super(`Scenario validation failed: ${detail}`, "SCENARIO_INVALID"); + this.name = "ScenarioValidationError"; + } +} + +export type ValidationErrorCode = + | "TIMEOUT" + | "ADAPTER_CRASH" + | "FORK_UNAVAILABLE" + | "CORRUPT_BUNDLE" + | "SCENARIO_INVALID" + | "UNSUPPORTED_FINDING" + | "ADAPTER_NOT_FOUND" + | "RPC_ERROR" + | "RESOURCE_EXCEEDED" + | "SNAPSHOT_NOT_FOUND" + | "DEPLOY_FAILED"; + +// ─── Cancellation ──────────────────────────────────────────────────────────── + +/** A cooperative cancellation signal for long-running validation runs. */ +export interface ValidationCancellationSignal { + /** Whether cancellation has been requested. */ + readonly cancelled: boolean; + /** Callback called when cancellation is requested. */ + onCancelled(callback: () => void): void; +} + +/** Creates a cancellation signal/controller pair. */ +export function createCancellationSignal(): { + signal: ValidationCancellationSignal; + cancel: () => void; +} { + let cancelled = false; + const callbacks: Array<() => void> = []; + const signal: ValidationCancellationSignal = { + get cancelled() { + return cancelled; + }, + onCancelled(cb) { + if (cancelled) { + cb(); + } else { + callbacks.push(cb); + } + }, + }; + return { + signal, + cancel() { + if (cancelled) return; + cancelled = true; + for (const cb of callbacks) { + try { + cb(); + } catch { + // ignore + } + } + }, + }; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Sanitize an error message to remove potential sensitive information + * (file paths, hostnames, API keys) before persisting in bundles or logs. + * @internal + */ +export function sanitizeErrorMessage(message: string): string { + return message + .replace(/https?:\/\/[^\s"']*/gi, "[redacted-url]") + .replace(/\/[^\s"']+/g, "[redacted-path]") + .replace(/0x[0-9a-f]{40,}/gi, "[redacted-hex]") + .slice(0, 500); +} + +/** + * Sanitize a file path for inclusion in error messages. + * @internal + */ +export function sanitizePath(filePath: string): string { + // Only show the basename to avoid leaking directory structure + return filePath.replace(/.*[\\/]/, ""); +} + +/** + * Resolve scenario resource limits, merging scenario-level overrides + * with global defaults. + * @internal + */ +export function resolveResourceLimits( + scenario: Partial, + adapter: Partial = {}, +): ResolvedResourceLimits { + return { + timeoutMs: scenario.timeoutMs ?? adapter.timeoutMs ?? DEFAULT_RESOURCE_LIMITS.timeoutMs, + maxMemoryBytes: scenario.maxMemoryBytes ?? adapter.maxMemoryBytes ?? DEFAULT_RESOURCE_LIMITS.maxMemoryBytes, + maxCalls: scenario.maxCalls ?? adapter.maxCalls ?? DEFAULT_RESOURCE_LIMITS.maxCalls, + maxGasPerCall: scenario.maxGasPerCall ?? adapter.maxGasPerCall ?? DEFAULT_RESOURCE_LIMITS.maxGasPerCall, + maxLogs: scenario.maxLogs ?? adapter.maxLogs ?? DEFAULT_RESOURCE_LIMITS.maxLogs, + }; +} diff --git a/packages/github-action/action.yml b/packages/github-action/action.yml index ce26264..3c47dd2 100644 --- a/packages/github-action/action.yml +++ b/packages/github-action/action.yml @@ -47,6 +47,21 @@ inputs: required: false default: "" + validate: + description: "Generate a ValidationPlan from scan findings and upload it as an artifact (true/false)" + required: false + default: "false" + + validate-adapter: + description: "EVM adapter to use when running validation scenarios: anvil|hardhat" + required: false + default: "anvil" + + validate-run: + description: "Execute the generated ValidationPlan against the EVM adapter (true/false). Requires the adapter to be installed on the runner." + required: false + default: "false" + outputs: critical-count: description: "Number of critical severity findings" @@ -60,6 +75,10 @@ outputs: description: "Number of resolved findings in diff mode" report-path: description: "Path to the generated audit report" + validate-plan-path: + description: "Path to the generated ValidationPlan JSON (when validate: true)" + validate-report-path: + description: "Path to the ValidationReport JSON (when validate-run: true)" runs: using: "node20" diff --git a/packages/github-action/src/action.ts b/packages/github-action/src/action.ts index ff3801b..cb784f3 100644 --- a/packages/github-action/src/action.ts +++ b/packages/github-action/src/action.ts @@ -11,6 +11,11 @@ import { generateMarkdownDiffReport, clearCache, isSlitherAvailable, + planValidation, + serializeValidationPlan, + runValidationPlan, + serializeValidationReport, + generateValidationMarkdown, } from "@chainproof/core"; import type { ScanConfig, ScanResult, ScanDiff } from "@chainproof/core"; @@ -267,6 +272,77 @@ async function run() { core.setOutput("report-path", mdPath); + // ── Validation plan (optional) ──────────────────────────────────────────── + const shouldValidate = core.getInput("validate") === "true"; + const shouldValidateRun = core.getInput("validate-run") === "true"; + const validateAdapter = (core.getInput("validate-adapter") || "anvil") as "anvil" | "hardhat"; + + if (shouldValidate || shouldValidateRun) { + core.info("[ChainProof] Building validation plan from scan findings..."); + + // Collect all findings from the scan result + const allFindings = result.files.flatMap((f) => f.findings); + const plan = planValidation(allFindings, { minSeverity: "low" }); + + core.info( + `[ChainProof] Validation plan: ${plan.scenarios.length} scenario(s), ` + + `${plan.unsupportedFindings.length} unsupported finding(s)`, + ); + + const planPath = path.join(reportDir, "validation-plan.json"); + fs.writeFileSync(planPath, serializeValidationPlan(plan), "utf-8"); + core.setOutput("validate-plan-path", planPath); + core.info(`[ChainProof] Validation plan written to ${planPath}`); + + // Optionally run the plan against a local EVM adapter + if (shouldValidateRun && plan.scenarios.length > 0) { + core.info( + `[ChainProof] Running ${plan.scenarios.length} validation scenario(s) with ${validateAdapter}...`, + ); + try { + const validationReport = await runValidationPlan(plan.scenarios, { + adapterType: validateAdapter, + limits: { timeoutMs: 60_000 }, + verbosity: 0, + }); + + const vReportPath = path.join(reportDir, "validation-report.json"); + const vMdPath = path.join(reportDir, "validation-report.md"); + fs.writeFileSync(vReportPath, serializeValidationReport(validationReport), "utf-8"); + fs.writeFileSync(vMdPath, generateValidationMarkdown(validationReport), "utf-8"); + core.setOutput("validate-report-path", vReportPath); + + core.info( + `[ChainProof] Validation complete: ${validationReport.passed} passed, ` + + `${validationReport.failed} failed, ${validationReport.errored} errored`, + ); + + // Annotate any scenarios that failed (exploit-succeeds confirmed) + for (const vResult of validationReport.results) { + if (vResult.outcomeMatched && vResult.scenario.expectedOutcome === "exploit-succeeds") { + core.warning( + `[ChainProof Validation] Exploit confirmed: ${vResult.scenario.title}` + + ` (${vResult.scenario.findingId ?? "unknown"} @ ` + + `${vResult.scenario.findingFile ?? ""}:${vResult.scenario.findingLine ?? "?"})`, + { + file: vResult.scenario.findingFile ?? undefined, + startLine: vResult.scenario.findingLine ?? undefined, + title: "Exploit Scenario Confirmed", + }, + ); + } + } + } catch (valErr) { + // Validation failures are advisory, not blocking + core.warning( + `[ChainProof] Validation run encountered an error: ${ + valErr instanceof Error ? valErr.message : String(valErr) + }`, + ); + } + } + } + // ── Post PR comment ──────────────────────────────────────────────────────── const token = process.env.GITHUB_TOKEN; diff --git a/packages/server/src/routes/validate.ts b/packages/server/src/routes/validate.ts new file mode 100644 index 0000000..df58812 --- /dev/null +++ b/packages/server/src/routes/validate.ts @@ -0,0 +1,239 @@ +/** + * POST /validate — Fork-aware concrete validation REST endpoint. + * + * Routes: + * POST /validate/plan — translate static findings into a ValidationPlan + * POST /validate/run — execute a ValidationPlan or single scenario + * POST /validate/report — re-format a saved ValidationReport + * + * Security boundaries: + * - forkUrl is accepted in requests but is never echoed back in responses. + * - Private keys in AccountSpec are stripped from all responses. + * - Each run request spawns a fresh isolated adapter process. + * - No filesystem reads; all data is passed inline in the request body. + */ + +import { Router, Request, Response } from "express"; +import { + planValidation, + runValidationPlan, + generateValidationMarkdown, + parseValidationReport, + serializeValidationReport, + createCancellationSignal, + CorruptBundleError, + ValidationError, + VALIDATION_SCHEMA_VERSION, +} from "@chainproof/core"; +import type { + Finding, + RunValidationOptions, + ValidationScenario, +} from "@chainproof/core"; + +const router = Router(); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function safeErrorMessage(err: unknown): string { + if (err instanceof ValidationError) return err.message; + if (err instanceof CorruptBundleError) return err.message; + if (err instanceof Error) { + return err.message.replace(/\/[^\s"']+/g, "[path]").slice(0, 500); + } + return "Unexpected validation error"; +} + +function parseAdapterType(value: unknown): "anvil" | "hardhat" { + if (value === "hardhat") return "hardhat"; + return "anvil"; +} + +// ─── POST /validate/plan ────────────────────────────────────────────────────── + +/** + * POST /validate/plan + * + * Translate an array of static Finding objects into a ValidationPlan. + * + * Request body: + * ```json + * { + * "findings": [...Finding[]], + * "options": { "minSeverity": "low", "deduplicateByFile": false } + * } + * ``` + * + * Response 200: `{ "plan": ValidationPlan }` + * Response 400: malformed request + */ +router.post("/plan", (req: Request, res: Response): void => { + try { + const body = req.body as { + findings?: unknown; + options?: { minSeverity?: string; deduplicateByFile?: boolean }; + }; + + if (!Array.isArray(body.findings)) { + res.status(400).json({ + error: 'Request body must contain "findings" as an array of Finding objects.', + }); + return; + } + + const findings = body.findings as Finding[]; + const opts = body.options ?? {}; + const minSeverity = (opts.minSeverity ?? "low") as + | "critical" | "high" | "medium" | "low" | "info"; + + const plan = planValidation(findings, { + minSeverity, + deduplicateByFile: opts.deduplicateByFile ?? false, + }); + + res.status(200).json({ plan }); + } catch (err) { + res.status(500).json({ error: safeErrorMessage(err) }); + } +}); + +// ─── POST /validate/run ─────────────────────────────────────────────────────── + +/** + * POST /validate/run + * + * Execute a ValidationPlan (or array of scenarios) against a local EVM adapter. + * The adapter process is spawned and killed after each scenario (process isolation). + * + * Request body: + * ```json + * { + * "plan": ValidationPlan, + * "adapterType": "anvil", + * "forkUrl": "https://...", + * "forkBlockNumber": 19000000, + * "chainId": 1, + * "timeoutMs": 30000, + * "format": "json" + * } + * ``` + * + * Response 200: `{ "report": ValidationReport }` or text/markdown + * Response 400: malformed request + * Response 500: adapter infrastructure failure + */ +router.post("/run", async (req: Request, res: Response): Promise => { + const body = req.body as { + plan?: unknown; + scenarios?: unknown; + adapterType?: unknown; + forkUrl?: string; + forkBlockNumber?: number; + chainId?: number; + timeoutMs?: number; + format?: string; + }; + + let scenarios: ValidationScenario[]; + + if (body.plan && typeof body.plan === "object") { + const planObj = body.plan as Record; + if (!Array.isArray(planObj["scenarios"])) { + res.status(400).json({ error: '"plan.scenarios" must be an array.' }); + return; + } + scenarios = planObj["scenarios"] as ValidationScenario[]; + } else if (Array.isArray(body.scenarios)) { + scenarios = body.scenarios as ValidationScenario[]; + } else { + res.status(400).json({ + error: 'Request body must contain "plan" (ValidationPlan) or "scenarios" (ValidationScenario[]).', + }); + return; + } + + if (scenarios.length === 0) { + res.status(200).json({ + report: { + schemaVersion: VALIDATION_SCHEMA_VERSION, + timestamp: new Date().toISOString(), + total: 0, passed: 0, failed: 0, errored: 0, + results: [], + adapterType: parseAdapterType(body.adapterType), + totalDurationMs: 0, + }, + }); + return; + } + + const { signal, cancel } = createCancellationSignal(); + req.on("close", () => cancel()); + + const runOpts: RunValidationOptions = { + adapterType: parseAdapterType(body.adapterType), + forkUrl: body.forkUrl, + forkBlockNumber: body.forkBlockNumber, + chainId: body.chainId, + limits: { timeoutMs: body.timeoutMs ?? 30_000 }, + signal, + verbosity: 0, + }; + + try { + const report = await runValidationPlan(scenarios, runOpts); + if (body.format === "markdown") { + res.status(200).type("text/markdown").send(generateValidationMarkdown(report)); + } else { + res.status(200).json({ report }); + } + } catch (err) { + res.status(500).json({ error: safeErrorMessage(err) }); + } +}); + +// ─── POST /validate/report ──────────────────────────────────────────────────── + +/** + * POST /validate/report + * + * Re-format a saved ValidationReport as JSON or Markdown. + * Accepts the report as a JSON body — does not read from the filesystem. + * + * Request body: + * ```json + * { "report": ValidationReport, "format": "markdown" } + * ``` + * + * Response 200: formatted report + * Response 400: corrupt or missing report + */ +router.post("/report", (req: Request, res: Response): void => { + try { + const body = req.body as { report?: unknown; format?: string }; + + if (!body.report || typeof body.report !== "object") { + res.status(400).json({ + error: 'Request body must contain "report" as a ValidationReport object.', + }); + return; + } + + let validated; + try { + validated = parseValidationReport(JSON.stringify(body.report), ""); + } catch (parseErr) { + res.status(400).json({ error: safeErrorMessage(parseErr) }); + return; + } + + if (body.format === "markdown") { + res.status(200).type("text/markdown").send(generateValidationMarkdown(validated)); + } else { + res.status(200).type("application/json").send(serializeValidationReport(validated)); + } + } catch (err) { + res.status(500).json({ error: safeErrorMessage(err) }); + } +}); + +export default router; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 7272d2d..8426490 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -6,6 +6,7 @@ import rateLimit from "express-rate-limit"; import healthRouter from "./routes/health"; import scanRouter from "./routes/scan"; import rulesRouter from "./routes/rules"; +import validateRouter from "./routes/validate"; // ─── Configuration (can be overridden by env vars or programmatic start) ────── @@ -70,6 +71,7 @@ export function createApp(opts: ServerOptions = {}): express.Application { app.use("/health", healthRouter); app.use("/scan", scanRouter); app.use("/rules", rulesRouter); + app.use("/validate", validateRouter); // ── 404 handler ────────────────────────────────────────────────────────── app.use((_req, res) => { @@ -97,6 +99,9 @@ export async function startServer(opts: ServerOptions = {}): Promise { app.listen(port, host, () => { console.log(`\n 🚀 ChainProof server running at http://${host}:${port}`); console.log(` POST http://${host}:${port}/scan`); + console.log(` POST http://${host}:${port}/validate/plan`); + console.log(` POST http://${host}:${port}/validate/run`); + console.log(` POST http://${host}:${port}/validate/report`); console.log(` GET http://${host}:${port}/health`); console.log(` GET http://${host}:${port}/rules`); if (opts.token) { diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index b049e52..08fd017 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -47,6 +47,10 @@ { "command": "chainproof.explainVulnerability", "title": "ChainProof: Explain Vulnerability with AI" + }, + { + "command": "chainproof.planValidation", + "title": "ChainProof: Plan Validation (Generate Reproduction Scaffolds)" } ], "configuration": { diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 42138f7..d83f535 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -10,6 +10,8 @@ import { clearCache, astCache, enhanceFindingsWithLLM, + planValidation, + serializeValidationPlan, } from "@chainproof/core"; import type { Finding, GasHint, ScanConfig, ASTCacheEntry } from "@chainproof/core"; @@ -126,6 +128,12 @@ export function activate(context: vscode.ExtensionContext) { await explainVulnerability(uri, finding); } ), + vscode.commands.registerCommand( + "chainproof.planValidation", + async (uri?: vscode.Uri) => { + await planValidationForFindings(uri); + } + ), diagnosticCollection, statusBarItem, outputChannel, @@ -946,6 +954,99 @@ function renderMarkdown(text: string): string { let _extensionContext: vscode.ExtensionContext | undefined; +// ─── Plan Validation ────────────────────────────────────────────────────────── + +/** + * Build a `ValidationPlan` from the static findings of the active (or given) + * Solidity file and write it to a `.chainproof-validation-plan.json` file in + * the workspace root. + * + * The plan can then be executed with: + * `chainproof validate run .chainproof-validation-plan.json` + * + * No adapter process is spawned here — this is a pure static planning step. + */ +async function planValidationForFindings(uri?: vscode.Uri): Promise { + // Resolve target file + const targetUri = + uri ?? + vscode.window.activeTextEditor?.document.uri; + + if (!targetUri || !targetUri.fsPath.endsWith(".sol")) { + vscode.window.showWarningMessage( + "ChainProof: Open a Solidity file first to plan validation.", + ); + return; + } + + const filePath = targetUri.fsPath; + + // Collect findings for this file from the cache populated by the last scan + const findings = lastScanFindings.get(filePath) ?? []; + if (findings.length === 0) { + vscode.window.showInformationMessage( + "ChainProof: No findings for this file. Run a scan first (ChainProof: Scan Current File).", + ); + return; + } + + // Build the validation plan + const plan = planValidation(findings, { minSeverity: "low" }); + + if (plan.scenarios.length === 0) { + const unsupported = plan.unsupportedFindings.length; + vscode.window.showInformationMessage( + `ChainProof: No validation scenarios could be generated from the ${findings.length} finding(s). ` + + `${unsupported} finding(s) use unsupported rule IDs.`, + ); + return; + } + + // Write the plan to the workspace root + const workspaceRoot = + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) { + vscode.window.showErrorMessage( + "ChainProof: No workspace folder open. Cannot write validation plan.", + ); + return; + } + + const outPath = path.join(workspaceRoot, ".chainproof-validation-plan.json"); + try { + fs.writeFileSync(outPath, serializeValidationPlan(plan), "utf-8"); + } catch (writeErr) { + const msg = writeErr instanceof Error ? writeErr.message : String(writeErr); + vscode.window.showErrorMessage( + `ChainProof: Failed to write validation plan: ${msg}`, + ); + return; + } + + outputChannel.appendLine( + `[ChainProof] Validation plan written: ${outPath}` + + ` (${plan.scenarios.length} scenario(s), ${plan.unsupportedFindings.length} unsupported)`, + ); + + const action = await vscode.window.showInformationMessage( + `ChainProof: Validation plan created — ${plan.scenarios.length} scenario(s) for ` + + `${path.basename(filePath)}.` + + `\n\nRun with: chainproof validate run .chainproof-validation-plan.json`, + "Open Plan", + "Copy CLI Command", + ); + + if (action === "Open Plan") { + const doc = await vscode.workspace.openTextDocument(outPath); + await vscode.window.showTextDocument(doc, { preview: true }); + } else if (action === "Copy CLI Command") { + await vscode.env.clipboard.writeText( + `chainproof validate run .chainproof-validation-plan.json --adapter anvil`, + ); + vscode.window.showInformationMessage("ChainProof: CLI command copied to clipboard."); + } +} + export function deactivate() { if (_extensionContext) { persistCache(_extensionContext);