From cf9c5ae79427c80ff893ed4813ff696a025c23bd Mon Sep 17 00:00:00 2001 From: Nathaniel Nanle Date: Sun, 30 Aug 2026 06:14:54 +0100 Subject: [PATCH] feat(dos): implement denial-of-service, gas-griefing, and unbounded-work analysis (#80) --- docs/dos-analysis.md | 131 +++++ .../contracts/dos/FailureIsolatedBatch.sol | 31 ++ .../contracts/dos/MassStorageDeletion.sol | 28 ++ .../contracts/dos/PaginatedDividendVault.sol | 38 ++ examples/contracts/dos/PullPaymentAuction.sol | 39 ++ examples/contracts/dos/PushPaymentAuction.sol | 24 + examples/contracts/dos/ReturnBombGriefing.sol | 15 + examples/contracts/dos/SafeChunkedQueue.sol | 35 ++ .../contracts/dos/UnboundedDividendVault.sol | 39 ++ examples/contracts/dos/UnboundedRecursion.sol | 14 + packages/cli/src/__tests__/dos.test.ts | 54 +++ packages/cli/src/__tests__/server_dos.test.ts | 112 +++++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/dos.ts | 289 +++++++++++ .../src/dos/__tests__/adversarial.test.ts | 67 +++ packages/core/src/dos/__tests__/api.test.ts | 79 +++ .../src/dos/__tests__/call-fanout.test.ts | 63 +++ .../core/src/dos/__tests__/config.test.ts | 59 +++ .../src/dos/__tests__/growth-analyzer.test.ts | 51 ++ .../src/dos/__tests__/loop-analyzer.test.ts | 95 ++++ .../dos/__tests__/mitigation-detector.test.ts | 54 +++ packages/core/src/dos/__tests__/rules.test.ts | 124 +++++ packages/core/src/dos/api.ts | 294 +++++++++++ packages/core/src/dos/call-fanout.ts | 230 +++++++++ packages/core/src/dos/config.ts | 188 +++++++ packages/core/src/dos/growth-analyzer.ts | 178 +++++++ packages/core/src/dos/index.ts | 14 + packages/core/src/dos/loop-analyzer.ts | 343 +++++++++++++ packages/core/src/dos/mitigation-detector.ts | 178 +++++++ packages/core/src/dos/rules.ts | 459 ++++++++++++++++++ packages/core/src/dos/serialize.ts | 184 +++++++ packages/core/src/dos/types.ts | 233 +++++++++ packages/core/src/index.ts | 3 + packages/core/src/scanner.ts | 4 + packages/server/openapi.yaml | 67 +++ packages/server/src/routes/dos.ts | 95 ++++ packages/server/src/server.ts | 2 + 37 files changed, 3915 insertions(+) create mode 100644 docs/dos-analysis.md create mode 100644 examples/contracts/dos/FailureIsolatedBatch.sol create mode 100644 examples/contracts/dos/MassStorageDeletion.sol create mode 100644 examples/contracts/dos/PaginatedDividendVault.sol create mode 100644 examples/contracts/dos/PullPaymentAuction.sol create mode 100644 examples/contracts/dos/PushPaymentAuction.sol create mode 100644 examples/contracts/dos/ReturnBombGriefing.sol create mode 100644 examples/contracts/dos/SafeChunkedQueue.sol create mode 100644 examples/contracts/dos/UnboundedDividendVault.sol create mode 100644 examples/contracts/dos/UnboundedRecursion.sol create mode 100644 packages/cli/src/__tests__/dos.test.ts create mode 100644 packages/cli/src/__tests__/server_dos.test.ts create mode 100644 packages/cli/src/commands/dos.ts create mode 100644 packages/core/src/dos/__tests__/adversarial.test.ts create mode 100644 packages/core/src/dos/__tests__/api.test.ts create mode 100644 packages/core/src/dos/__tests__/call-fanout.test.ts create mode 100644 packages/core/src/dos/__tests__/config.test.ts create mode 100644 packages/core/src/dos/__tests__/growth-analyzer.test.ts create mode 100644 packages/core/src/dos/__tests__/loop-analyzer.test.ts create mode 100644 packages/core/src/dos/__tests__/mitigation-detector.test.ts create mode 100644 packages/core/src/dos/__tests__/rules.test.ts create mode 100644 packages/core/src/dos/api.ts create mode 100644 packages/core/src/dos/call-fanout.ts create mode 100644 packages/core/src/dos/config.ts create mode 100644 packages/core/src/dos/growth-analyzer.ts create mode 100644 packages/core/src/dos/index.ts create mode 100644 packages/core/src/dos/loop-analyzer.ts create mode 100644 packages/core/src/dos/mitigation-detector.ts create mode 100644 packages/core/src/dos/rules.ts create mode 100644 packages/core/src/dos/serialize.ts create mode 100644 packages/core/src/dos/types.ts create mode 100644 packages/server/src/routes/dos.ts diff --git a/docs/dos-analysis.md b/docs/dos-analysis.md new file mode 100644 index 0000000..00de82c --- /dev/null +++ b/docs/dos-analysis.md @@ -0,0 +1,131 @@ +# Denial-of-Service, Gas-Griefing & Unbounded-Work Analysis + +ChainProof provides a deterministic, production-grade static analysis engine for detecting Denial-of-Service (DoS), gas-griefing vectors, and unbounded-work vulnerabilities in Solidity smart contracts. + +--- + +## 1. Overview & Threat Model + +Denial-of-Service vulnerabilities in Ethereum and EVM-compatible blockchains rarely involve brute-force traffic volume; instead, they exploit economic and execution constraints of the EVM: + +1. **Block Gas Limit deadlocks (30M gas ceiling):** When work complexity scales linearly or quadratically with dynamic storage arrays, the gas required to execute a transaction eventually exceeds the block gas limit, permanently freezing contract state transitions. +2. **Push-Payment griefing:** Sending Ether or tokens to untrusted recipient addresses inside loops or single execution paths allows a single malicious contract recipient to revert the entire transaction. +3. **Return Bombs & Quadratic Memory Expansion:** When contracts make high-level calls or low-level calls without capping returndata copying, a malicious recipient can return an arbitrarily large payload (e.g. megabytes of data), forcing exponential memory expansion gas costs that exhaust caller gas. +4. **Mass Storage Deletion:** Deleting storage elements (`delete`) inside unbounded loops costs full gas up front, while EIP-3529 limits refunds to at most 20% of the transaction gas limit. +5. **Insufficient Gas Forwarding (63/64th Rule):** EIP-150 forwards at most 63/64 of remaining gas to sub-calls. Without explicit gas stipends, relayers can grief transactions by providing barely enough gas for outer execution. + +--- + +## 2. Rule Catalog + +| Rule ID | Title | Default Severity | Category | SWC Reference | +|---|---|---|---|---| +| `CP-DOS-001` | Unbounded Loop Iteration Over Dynamic Storage Array | `High` | `denial_of_service` | SWC-128 | +| `CP-DOS-002` | Push-Payment Pattern with Unexpected Revert Risk | `High` | `denial_of_service` | SWC-113 | +| `CP-DOS-003` | External Call Fan-Out in Loop Iteration | `Medium` | `gas_griefing` | - | +| `CP-DOS-004` | Return Bomb / Unbounded Returndata Memory Expansion | `Medium` | `gas_griefing` | - | +| `CP-DOS-005` | Unbounded Storage Clearing / Mass Deletion | `Medium` | `unbounded_work` | - | +| `CP-DOS-006` | Insufficient Gas Forwarding / 63/64th Rule Griefing | `Medium` | `gas_griefing` | - | +| `CP-DOS-007` | Single-Transaction Block Gas Limit Deadlock | `High` | `denial_of_service` | - | +| `CP-DOS-008` | Unbounded Recursion Without Depth Guard | `High` | `denial_of_service` | SWC-128 | +| `CP-DOS-009` | Attacker-Controlled Array Growth / Storage Poisoning | `Medium` | `denial_of_service` | - | +| `CP-DOS-010` | Revert Propagation in Critical Batch Operation | `Low` | `gas_griefing` | - | + +--- + +## 3. Recognized Mitigation Patterns + +ChainProof's AST analyzer recognizes secure architecture patterns to eliminate false positives: + +### 1. Pagination Pattern (`CP-DOS-001` suppressed) +Contracts that pass `offset` and `limit` / `count` with explicit upper bounds: +```solidity +function distributePaginated(uint256 offset, uint256 limit) external { + require(limit <= MAX_BATCH_SIZE, "Exceeds max batch"); + uint256 end = offset + limit; + if (end > shareholders.length) end = shareholders.length; + for (uint256 i = offset; i < end; i++) { + // Safe bounded loop + } +} +``` + +### 2. Pull-Payment Pattern (`CP-DOS-002` suppressed) +Contracts that track pending balances internally and offer a dedicated `withdraw()` endpoint: +```solidity +mapping(address => uint256) public pendingWithdrawals; + +function creditReward(address user, uint256 amount) internal { + pendingWithdrawals[user] += amount; +} + +function withdraw() external { + uint256 amount = pendingWithdrawals[msg.sender]; + require(amount > 0); + pendingWithdrawals[msg.sender] = 0; + (bool ok, ) = msg.sender.call{value: amount}(""); + require(ok); +} +``` + +### 3. Failure Isolation with `try/catch` (`CP-DOS-003`, `CP-DOS-010` suppressed) +Batch executors that isolate individual transaction failures: +```solidity +for (uint256 i = 0; i < targets.length; i++) { + try IReceiver(targets[i]).processTask(taskIds[i]) { + emit TaskSucceeded(targets[i], taskIds[i]); + } catch (bytes memory reason) { + emit TaskFailed(targets[i], taskIds[i], reason); + } +} +``` + +### 4. Checkpointed State Machines (`CP-DOS-007` suppressed) +State machines that persist progress across multiple transactions: +```solidity +uint256 public nextIndex; + +function processBatch(uint256 count) external { + require(count <= CHUNK_SIZE); + uint256 total = queue.length; + uint256 processed = 0; + while (nextIndex < total && processed < count) { + processItem(queue[nextIndex]); + nextIndex++; + processed++; + } +} +``` + +--- + +## 4. CLI Reference + +### `chainproof dos inspect-loops ` +Inspects all loops in Solidity files, classifying bounds (`storage_array_bounded`, `parameter_bounded`, `constant_bounded`, `paginated`, `unbounded`) and operations. + +```bash +chainproof dos inspect-loops contracts/ --format table +``` + +### `chainproof dos fanout ` +Inspects all external calls and push payment vectors. + +```bash +chainproof dos fanout contracts/ --format json +``` + +### `chainproof dos audit ` +Performs a complete DoS and unbounded-work audit. + +```bash +chainproof dos audit contracts/ --fail-on high --format markdown --output dos-report.md +``` + +--- + +## 5. REST API Endpoints + +- `POST /dos/inspect-loops`: Inspects loops and bound classifications across posted Solidity sources. +- `POST /dos/fanout`: Inspects external call fanout and payment vectors. +- `POST /dos/audit`: Generates a structured `DosAuditReport`. diff --git a/examples/contracts/dos/FailureIsolatedBatch.sol b/examples/contracts/dos/FailureIsolatedBatch.sol new file mode 100644 index 0000000..5536083 --- /dev/null +++ b/examples/contracts/dos/FailureIsolatedBatch.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +interface IReceiver { + function processTask(uint256 taskId) external; +} + +/** + * @title FailureIsolatedBatch + * @notice Secure batch executor isolating individual task failures with try/catch. + */ +contract FailureIsolatedBatch { + uint256 public constant MAX_BATCH = 50; + + event TaskSucceeded(address indexed target, uint256 taskId); + event TaskFailed(address indexed target, uint256 taskId, bytes reason); + + function executeBatch(address[] calldata targets, uint256[] calldata taskIds) external { + require(targets.length == taskIds.length, "Mismatched lengths"); + require(targets.length <= MAX_BATCH, "Exceeds max batch"); + + for (uint256 i = 0; i < targets.length; i++) { + try IReceiver(targets[i]).processTask(taskIds[i]) { + emit TaskSucceeded(targets[i], taskIds[i]); + } catch (bytes memory reason) { + // Failure is isolated: does not revert the entire batch + emit TaskFailed(targets[i], taskIds[i], reason); + } + } + } +} diff --git a/examples/contracts/dos/MassStorageDeletion.sol b/examples/contracts/dos/MassStorageDeletion.sol new file mode 100644 index 0000000..afcc453 --- /dev/null +++ b/examples/contracts/dos/MassStorageDeletion.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/** + * @title MassStorageDeletion + * @notice Vulnerable contract attempting mass deletion in unbounded loop. + */ +contract MassStorageDeletion { + address public admin; + uint256[] public entries; + + constructor() { + admin = msg.sender; + } + + function addEntry(uint256 value) external { + entries.push(value); + } + + function clearAllEntries() external { + require(msg.sender == admin, "Not admin"); + + // Vulnerability: Deleting storage elements in unbounded loop (CP-DOS-005) + for (uint256 i = 0; i < entries.length; i++) { + delete entries[i]; + } + } +} diff --git a/examples/contracts/dos/PaginatedDividendVault.sol b/examples/contracts/dos/PaginatedDividendVault.sol new file mode 100644 index 0000000..8e1afbe --- /dev/null +++ b/examples/contracts/dos/PaginatedDividendVault.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/** + * @title PaginatedDividendVault + * @notice Secure dividend vault implementing bounded pagination. + */ +contract PaginatedDividendVault { + uint256 public constant MAX_BATCH_SIZE = 50; + address public owner; + address[] public shareholders; + mapping(address => uint256) public shares; + uint256 public totalShares; + + constructor() { + owner = msg.sender; + } + + function distributePaginated(uint256 offset, uint256 limit) external payable { + require(limit <= MAX_BATCH_SIZE, "Exceeds max batch"); + require(totalShares > 0, "No shares"); + + uint256 end = offset + limit; + if (end > shareholders.length) { + end = shareholders.length; + } + + for (uint256 i = offset; i < end; i++) { + address payable recipient = payable(shareholders[i]); + uint256 payout = (msg.value * shares[recipient]) / totalShares; + (bool ok, ) = recipient.call{value: payout}(""); + // Failure isolation + if (!ok) { + // Log or track failure instead of blocking + } + } + } +} diff --git a/examples/contracts/dos/PullPaymentAuction.sol b/examples/contracts/dos/PullPaymentAuction.sol new file mode 100644 index 0000000..568b9fc --- /dev/null +++ b/examples/contracts/dos/PullPaymentAuction.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/** + * @title PullPaymentAuction + * @notice Secure auction implementing pull-over-push payment pattern. + */ +contract PullPaymentAuction { + address public highestBidder; + uint256 public highestBid; + mapping(address => uint256) public pendingReturns; + + function bid() external payable { + require(msg.value > highestBid, "Bid too low"); + + if (highestBidder != address(0)) { + // Secure: Credit balance in internal ledger + pendingReturns[highestBidder] += highestBid; + } + + highestBidder = msg.sender; + highestBid = msg.value; + } + + function withdraw() external returns (bool) { + uint256 amount = pendingReturns[msg.sender]; + require(amount > 0, "No funds to withdraw"); + + pendingReturns[msg.sender] = 0; + + (bool success, ) = msg.sender.call{value: amount}(""); + if (!success) { + pendingReturns[msg.sender] = amount; + return false; + } + + return true; + } +} diff --git a/examples/contracts/dos/PushPaymentAuction.sol b/examples/contracts/dos/PushPaymentAuction.sol new file mode 100644 index 0000000..b15ebfb --- /dev/null +++ b/examples/contracts/dos/PushPaymentAuction.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/** + * @title PushPaymentAuction + * @notice Vulnerable auction performing direct push refund on outbid. + */ +contract PushPaymentAuction { + address payable public highestBidder; + uint256 public highestBid; + + function bid() external payable { + require(msg.value > highestBid, "Bid too low"); + + if (highestBidder != address(0)) { + // Vulnerability: Direct push payment refund (CP-DOS-002) + // If previous highest bidder is a malicious contract rejecting transfers, no one can outbid them! + highestBidder.transfer(highestBid); + } + + highestBidder = payable(msg.sender); + highestBid = msg.value; + } +} diff --git a/examples/contracts/dos/ReturnBombGriefing.sol b/examples/contracts/dos/ReturnBombGriefing.sol new file mode 100644 index 0000000..b825d0b --- /dev/null +++ b/examples/contracts/dos/ReturnBombGriefing.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/** + * @title ReturnBombGriefing + * @notice Vulnerable relayer calling arbitrary targets without limiting returndata copying. + */ +contract ReturnBombGriefing { + function executeRelay(address target, bytes calldata data) external returns (bytes memory) { + // Vulnerability: Low-level call copying unbounded returndata (CP-DOS-004) + (bool success, bytes memory returnData) = target.call(data); + require(success, "Call failed"); + return returnData; + } +} diff --git a/examples/contracts/dos/SafeChunkedQueue.sol b/examples/contracts/dos/SafeChunkedQueue.sol new file mode 100644 index 0000000..a3119b8 --- /dev/null +++ b/examples/contracts/dos/SafeChunkedQueue.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/** + * @title SafeChunkedQueue + * @notice Secure checkpointed queue allowing partial progress across transactions. + */ +contract SafeChunkedQueue { + uint256 public constant CHUNK_SIZE = 20; + address[] public queue; + uint256 public nextIndex; + + function enqueue(address user) external { + queue.push(user); + } + + function processQueue(uint256 count) external { + require(count <= CHUNK_SIZE, "Count exceeds chunk size"); + + uint256 total = queue.length; + uint256 processed = 0; + + while (nextIndex < total && processed < count) { + address user = queue[nextIndex]; + nextIndex++; + processed++; + + // Process individual item safely + (bool ok, ) = user.call(""); + if (!ok) { + // Log and continue + } + } + } +} diff --git a/examples/contracts/dos/UnboundedDividendVault.sol b/examples/contracts/dos/UnboundedDividendVault.sol new file mode 100644 index 0000000..db7e4a4 --- /dev/null +++ b/examples/contracts/dos/UnboundedDividendVault.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/** + * @title UnboundedDividendVault + * @notice Vulnerable contract containing unbounded loop iteration, push payments, and array growth. + */ +contract UnboundedDividendVault { + address public owner; + address[] public shareholders; + mapping(address => uint256) public shares; + uint256 public totalShares; + + constructor() { + owner = msg.sender; + } + + function registerShareholder(address user, uint256 shareAmount) external { + // Vulnerability: Unrestricted array growth without limits or access control (CP-DOS-009) + shareholders.push(user); + shares[user] += shareAmount; + totalShares += shareAmount; + } + + function distributeDividends() external payable { + require(msg.value > 0, "No dividends"); + require(totalShares > 0, "No shares"); + + // Vulnerability: Unbounded loop over dynamic storage array (CP-DOS-001) + for (uint256 i = 0; i < shareholders.length; i++) { + address payable recipient = payable(shareholders[i]); + uint256 payout = (msg.value * shares[recipient]) / totalShares; + + // Vulnerability: Push-Payment pattern inside loop (CP-DOS-002) + // If one recipient reverts, entire distribution bricks! + recipient.transfer(payout); + } + } +} diff --git a/examples/contracts/dos/UnboundedRecursion.sol b/examples/contracts/dos/UnboundedRecursion.sol new file mode 100644 index 0000000..e19293b --- /dev/null +++ b/examples/contracts/dos/UnboundedRecursion.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/** + * @title UnboundedRecursion + * @notice Vulnerable contract with recursive function lacking depth guard. + */ +contract UnboundedRecursion { + function computeRecursive(uint256 value) external returns (uint256) { + if (value == 0) return 0; + // Vulnerability: Recursive call without explicit stack depth guard (CP-DOS-008) + return value + computeRecursive(value - 1); + } +} diff --git a/packages/cli/src/__tests__/dos.test.ts b/packages/cli/src/__tests__/dos.test.ts new file mode 100644 index 0000000..9d31a74 --- /dev/null +++ b/packages/cli/src/__tests__/dos.test.ts @@ -0,0 +1,54 @@ +import * as fs from "fs"; +import * as path from "path"; +import { execSync } from "child_process"; + +describe("CLI chainproof dos commands", () => { + const cliPath = path.resolve(__dirname, "../../dist/cli.js"); + const fixturePath = path.resolve(__dirname, "../../../../examples/contracts/dos/UnboundedDividendVault.sol"); + const secureFixturePath = path.resolve(__dirname, "../../../../examples/contracts/dos/PullPaymentAuction.sol"); + + it("runs chainproof dos inspect-loops --format json", () => { + const cmd = `node ${cliPath} dos inspect-loops ${fixturePath} --format json`; + const output = execSync(cmd, { encoding: "utf-8" }); + const parsed = JSON.parse(output); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + expect(parsed[0].boundType).toBe("storage_array_bounded"); + }); + + it("runs chainproof dos fanout --format json", () => { + const cmd = `node ${cliPath} dos fanout ${fixturePath} --format json`; + const output = execSync(cmd, { encoding: "utf-8" }); + const parsed = JSON.parse(output); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + expect(parsed[0].isPushPayment).toBe(true); + }); + + it("runs chainproof dos audit --format json on secure contract with exit 0", () => { + const cmd = `node ${cliPath} dos audit ${secureFixturePath} --format json`; + const output = execSync(cmd, { encoding: "utf-8" }); + const parsed = JSON.parse(output); + + expect(parsed.schemaVersion).toBe("1.0.0"); + expect(parsed.summary.passed).toBe(true); + }); + + it("runs chainproof dos audit with --output file", () => { + const tmpOut = path.resolve(__dirname, "../../temp_dos_out.json"); + try { + const cmd = `node ${cliPath} dos audit ${fixturePath} --format json --output ${tmpOut} --fail-on none`; + execSync(cmd, { encoding: "utf-8" }); + + expect(fs.existsSync(tmpOut)).toBe(true); + const content = JSON.parse(fs.readFileSync(tmpOut, "utf-8")); + expect(content.summary.totalFiles).toBe(1); + } finally { + if (fs.existsSync(tmpOut)) { + fs.unlinkSync(tmpOut); + } + } + }); +}); diff --git a/packages/cli/src/__tests__/server_dos.test.ts b/packages/cli/src/__tests__/server_dos.test.ts new file mode 100644 index 0000000..9a7ec89 --- /dev/null +++ b/packages/cli/src/__tests__/server_dos.test.ts @@ -0,0 +1,112 @@ +import * as http from "http"; +import { createApp } from "@chainproof/server"; + +describe("Server /dos Routes", () => { + let server: http.Server; + let baseUrl: string; + + beforeAll((done) => { + const app = createApp(); + server = http.createServer(app); + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as any; + baseUrl = `http://127.0.0.1:${addr.port}`; + done(); + }); + }); + + afterAll((done) => { + server.close(done); + }); + + async function postJson(endpoint: string, body: any): Promise<{ status: number; data: any }> { + return new Promise((resolve, reject) => { + const postData = JSON.stringify(body); + const url = new URL(endpoint, baseUrl); + const req = http.request( + url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(postData), + }, + }, + (res) => { + let raw = ""; + res.on("data", (chunk) => { + raw += chunk; + }); + res.on("end", () => { + try { + const data = JSON.parse(raw); + resolve({ status: res.statusCode || 200, data }); + } catch (err) { + resolve({ status: res.statusCode || 200, data: raw }); + } + }); + }, + ); + req.on("error", reject); + req.write(postData); + req.end(); + }); + } + + it("POST /dos/inspect-loops inspects loop bounds", async () => { + const res = await postJson("/dos/inspect-loops", { + files: [ + { + file: "Vault.sol", + content: "pragma solidity 0.8.20;\ncontract Vault { address[] u; function f() public { for(uint i=0; i { + const res = await postJson("/dos/fanout", { + files: [ + { + file: "Vault.sol", + content: "pragma solidity 0.8.20;\ncontract Vault { address[] u; function f() public { for(uint i=0; i { + const res = await postJson("/dos/audit", { + files: [ + { + file: "Vault.sol", + content: "pragma solidity 0.8.20;\ncontract Vault { address[] u; function f() public { for(uint i=0; i { + const res = await postJson("/dos/audit", { + files: "invalid-not-array", + }); + + expect(res.status).toBe(400); + expect(res.data.error).toBeDefined(); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 705ca2c..1339203 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -31,6 +31,7 @@ import { registerInvariantsCommand } from "./commands/invariants"; import { registerStakingCommand } from "./commands/staking"; import { registerGovernanceCommand } from "./commands/governance"; import { registerBridgeCommand } from "./commands/bridge"; +import { registerDosCommand } from "./commands/dos"; // ─── ASCII Banner ───────────────────────────────────────────────────────────── @@ -633,5 +634,6 @@ registerInvariantsCommand(program, printBanner); registerStakingCommand(program); registerGovernanceCommand(program, printBanner); registerBridgeCommand(program, printBanner); +registerDosCommand(program, printBanner); program.parse(); diff --git a/packages/cli/src/commands/dos.ts b/packages/cli/src/commands/dos.ts new file mode 100644 index 0000000..5285789 --- /dev/null +++ b/packages/cli/src/commands/dos.ts @@ -0,0 +1,289 @@ +/** + * @packageDocumentation + * @chainproof/cli — Denial-of-Service, Gas-Griefing & Unbounded-Work Commands + */ + +import { Command } from "commander"; +import chalk from "chalk"; +import * as fs from "fs"; +import { + inspectDosLoops, + inspectDosCallFanOut, + auditDosSafety, + serializeDosAuditJSON, + generateDosMarkdownReport, + generateDosTableReport, + generateDosLoopsMarkdown, + generateDosFanoutMarkdown, + loadDosConfigFile, + stableStringify, + DosConfigError, +} from "@chainproof/core"; +import type { + DosAnalysisOptions, + DosAnalysisLimits, + DosRuleId, +} from "@chainproof/core"; + +type OutputFormat = "table" | "json" | "markdown"; +type FailSeverity = "none" | "info" | "low" | "medium" | "high" | "critical"; + +const SEVERITY_RANK: Record = { + none: 99, + info: 1, + low: 2, + medium: 3, + high: 4, + critical: 5, +}; + +function positiveInteger(value: string): number { + if (!/^\d+$/.test(value)) { + throw new DosConfigError("Limit option must be a positive integer."); + } + const result = Number(value); + if (!Number.isSafeInteger(result) || result <= 0) { + throw new DosConfigError("Limit option must be a safe positive integer."); + } + return result; +} + +function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +function sanitize(message: string): string { + return message.replace(/[\r\n]+/g, " ").slice(0, 500); +} + +function writeReport(file: string, output: string): void { + try { + fs.writeFileSync(file, output, "utf-8"); + } catch (err) { + throw new DosConfigError(`Report file could not be written to ${file}`); + } +} + +export function registerDosCommand(program: Command, printBanner: () => void): void { + const dos = program + .command("dos") + .description("Solidity Denial-of-Service, gas-griefing, and unbounded-work analysis"); + + // ─── dos inspect-loops ────────────────────────────────────────────────────── + dos + .command("inspect-loops ") + .description("Inspect loop bounds, termination conditions, and storage array dependencies") + .option("--format ", "Output format: table|json|markdown", "table") + .option("--output ", "Write inspection report to file") + .option("--config ", "Load DoS configuration file") + .action((targets: string[], opts: { format: OutputFormat; output?: string; config?: string }) => { + if (opts.format === "table") printBanner(); + try { + const config = opts.config ? loadDosConfigFile(opts.config) : undefined; + const loops = inspectDosLoops(targets, { config }); + + let outputStr: string; + if (opts.format === "json") { + outputStr = stableStringify(loops); + } else if (opts.format === "markdown") { + outputStr = generateDosLoopsMarkdown(loops); + } else { + const lines: string[] = []; + lines.push(chalk.bold("\n Solidity Loop Bounds & Complexity Inspection\n")); + lines.push(chalk.gray(` Total Loops Inspected: ${loops.length}\n`)); + + for (const l of loops) { + const boundColor = + l.boundType === "storage_array_bounded" || l.boundType === "unbounded" + ? chalk.red + : l.isCapped + ? chalk.green + : chalk.yellow; + + lines.push( + ` ${chalk.cyan.bold(l.associatedContract)}::${chalk.white(l.associatedFunction)} (line ${l.line}) [${boundColor(l.boundType)}]`, + ); + lines.push( + chalk.gray( + ` Condition : ${l.conditionExpression}\n` + + ` Capped : ${l.isCapped ? chalk.green("YES") : chalk.red("NO")}\n` + + ` Ext Calls : ${l.hasExternalCalls ? chalk.red(`${l.externalCallsCount} calls`) : chalk.green("0")}\n` + + ` Deletions : ${l.hasStorageDeletions ? chalk.red("YES (Mass Deletion)") : chalk.green("No")}\n`, + ), + ); + } + outputStr = lines.join("\n"); + } + + if (opts.output) { + writeReport(opts.output, outputStr); + if (opts.format === "table") { + console.log(chalk.green(`\n ✅ Loop inspection output written to ${opts.output}`)); + } + } else { + console.log(outputStr); + } + + process.exit(0); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n DoS loop inspection error: ${sanitize(msg)}`)); + process.exit(2); + } + }); + + // ─── dos fanout ───────────────────────────────────────────────────────────── + dos + .command("fanout ") + .description("Inspect external call fan-out, push-payment patterns, and gas forwarding") + .option("--format ", "Output format: table|json|markdown", "table") + .option("--output ", "Write fanout report to file") + .option("--config ", "Load DoS configuration file") + .action((targets: string[], opts: { format: OutputFormat; output?: string; config?: string }) => { + if (opts.format === "table") printBanner(); + try { + const config = opts.config ? loadDosConfigFile(opts.config) : undefined; + const calls = inspectDosCallFanOut(targets, { config }); + + let outputStr: string; + if (opts.format === "json") { + outputStr = stableStringify(calls); + } else if (opts.format === "markdown") { + outputStr = generateDosFanoutMarkdown(calls); + } else { + const lines: string[] = []; + lines.push(chalk.bold("\n External Call Fan-Out & Payment Inspection\n")); + lines.push(chalk.gray(` Total External Calls Inspected: ${calls.length}\n`)); + + for (const c of calls) { + const riskColor = c.isInsideLoop || c.isPushPayment ? chalk.red : chalk.green; + lines.push( + ` ${chalk.cyan.bold(c.associatedContract)}::${chalk.white(c.associatedFunction)} (line ${c.line}) -> ${chalk.yellow(c.targetExpression)} [${riskColor(c.callType)}]`, + ); + lines.push( + chalk.gray( + ` Inside Loop : ${c.isInsideLoop ? chalk.red("YES (Fan-Out Risk)") : chalk.green("No")}\n` + + ` Push Payment : ${c.isPushPayment ? chalk.red("YES (Revert Risk)") : chalk.green("No")}\n` + + ` Try/Catch : ${c.isWrappedInTryCatch ? chalk.green("YES (Isolated)") : chalk.yellow("No")}\n` + + ` Gas Stipend : ${c.hasGasLimit ? chalk.green(c.gasLimitExpression) : chalk.red("Full Gas (63/64)")}\n`, + ), + ); + } + outputStr = lines.join("\n"); + } + + if (opts.output) { + writeReport(opts.output, outputStr); + if (opts.format === "table") { + console.log(chalk.green(`\n ✅ Fanout inspection output written to ${opts.output}`)); + } + } else { + console.log(outputStr); + } + + process.exit(0); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n DoS fanout inspection error: ${sanitize(msg)}`)); + process.exit(2); + } + }); + + // ─── dos audit ────────────────────────────────────────────────────────────── + dos + .command("audit ") + .description("Run a full Denial-of-Service, gas-griefing, and unbounded-work audit") + .option("--format ", "Output format: table|json|markdown", "table") + .option("--output ", "Write audit report to file") + .option("--config ", "Load DoS configuration file") + .option("--include-rule ", "Only run rule (repeatable)", collect, []) + .option("--exclude-rule ", "Skip rule (repeatable)", collect, []) + .option("--max-source-bytes ", "Maximum bytes per source file", positiveInteger) + .option("--max-files ", "Maximum number of source files", positiveInteger) + .option("--max-contracts ", "Maximum contracts to evaluate", positiveInteger) + .option("--max-findings ", "Maximum findings in report", positiveInteger) + .option( + "--fail-on ", + "Exit 1 if findings at or above severity exist: none|info|low|medium|high|critical", + "high", + ) + .action( + async ( + targets: string[], + opts: { + format: OutputFormat; + output?: string; + config?: string; + includeRule: string[]; + excludeRule: string[]; + maxSourceBytes?: number; + maxFiles?: number; + maxContracts?: number; + maxFindings?: number; + failOn: FailSeverity; + }, + ) => { + if (opts.format === "table") printBanner(); + try { + const configured = opts.config ? loadDosConfigFile(opts.config) : undefined; + + const limits: Partial = { + ...configured?.limits, + ...(opts.maxSourceBytes ? { maxSourceBytes: opts.maxSourceBytes } : {}), + ...(opts.maxFiles ? { maxFiles: opts.maxFiles } : {}), + ...(opts.maxContracts ? { maxContracts: opts.maxContracts } : {}), + ...(opts.maxFindings ? { maxFindings: opts.maxFindings } : {}), + }; + + const includeRules = opts.includeRule.length + ? (opts.includeRule as DosRuleId[]) + : configured?.includeRules; + const excludeRules = opts.excludeRule.length + ? (opts.excludeRule as DosRuleId[]) + : configured?.excludeRules; + + const options: DosAnalysisOptions = { + config: configured, + limits, + includeRules, + excludeRules, + }; + + const report = await auditDosSafety(targets, options); + + let outputStr: string; + if (opts.format === "json") { + outputStr = serializeDosAuditJSON(report); + } else if (opts.format === "markdown") { + outputStr = generateDosMarkdownReport(report); + } else { + outputStr = generateDosTableReport(report); + } + + if (opts.output) { + writeReport(opts.output, outputStr); + if (opts.format === "table") { + console.log(chalk.green(`\n ✅ DoS report written to ${opts.output}`)); + } + } else { + console.log(outputStr); + } + + const minRank = SEVERITY_RANK[opts.failOn]; + const hasFailingFindings = + opts.failOn !== "none" && + report.findings.some( + (f) => (SEVERITY_RANK[f.severity as FailSeverity] || 0) >= minRank, + ); + + const exitCode = hasFailingFindings ? 1 : 0; + + process.exit(exitCode); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n DoS audit error: ${sanitize(msg)}`)); + process.exit(2); + } + }, + ); +} diff --git a/packages/core/src/dos/__tests__/adversarial.test.ts b/packages/core/src/dos/__tests__/adversarial.test.ts new file mode 100644 index 0000000..b0112bf --- /dev/null +++ b/packages/core/src/dos/__tests__/adversarial.test.ts @@ -0,0 +1,67 @@ +import { auditDosSafety, inspectDosLoops } from "../api"; +import { DosConfigError } from "../config"; + +describe("DoS Adversarial and Edge Case Handling", () => { + it("handles malformed Solidity syntax gracefully without crashing", async () => { + const malformed = "contract { broken syntax ;;; for (;;;) "; + const report = await auditDosSafety([ + { + file: "Broken.sol", + content: malformed, + }, + ]); + + expect(report.schemaVersion).toBe("1.0.0"); + expect(report.summary.totalFiles).toBe(1); + expect(report.summary.passed).toBe(true); + }); + + it("enforces maxSourceBytes limit", async () => { + const largeContent = "contract Big {}\n".repeat(5000); + await expect( + auditDosSafety( + [ + { + file: "Big.sol", + content: largeContent, + }, + ], + { + limits: { maxSourceBytes: 100 }, + }, + ), + ).rejects.toThrow(DosConfigError); + }); + + it("enforces maxFiles limit", async () => { + const files = [ + { file: "A.sol", content: "contract A {}" }, + { file: "B.sol", content: "contract B {}" }, + ]; + await expect( + auditDosSafety(files, { + limits: { maxFiles: 1 }, + }), + ).rejects.toThrow(DosConfigError); + }); + + it("handles deeply nested loops without stack overflow", () => { + let nested = "pragma solidity 0.8.20; contract Deep { function run() public { "; + for (let i = 0; i < 20; i++) { + nested += `for (uint256 i${i} = 0; i${i} < 10; i${i}++) { `; + } + for (let i = 0; i < 20; i++) { + nested += "} "; + } + nested += "} }"; + + const loops = inspectDosLoops([ + { + file: "Deep.sol", + content: nested, + }, + ]); + + expect(loops.length).toBe(20); + }); +}); diff --git a/packages/core/src/dos/__tests__/api.test.ts b/packages/core/src/dos/__tests__/api.test.ts new file mode 100644 index 0000000..a9b59f3 --- /dev/null +++ b/packages/core/src/dos/__tests__/api.test.ts @@ -0,0 +1,79 @@ +import { auditDosSafety, inspectDosLoops, inspectDosCallFanOut, DosAnalysisCancelledError } from "../api"; + +describe("DoS Public High-Level APIs", () => { + const sampleSource = ` + pragma solidity 0.8.20; + contract Vault { + address[] public users; + function payAll() public { + for (uint256 i = 0; i < users.length; i++) { + payable(users[i]).transfer(1 ether); + } + } + } + `; + + it("audits Solidity sources and returns structured audit report", async () => { + const report = await auditDosSafety([ + { + file: "Vault.sol", + content: sampleSource, + }, + ]); + + expect(report.schemaVersion).toBe("1.0.0"); + expect(report.summary.totalFiles).toBe(1); + expect(report.summary.totalContracts).toBe(1); + expect(report.summary.totalLoopsAnalyzed).toBe(1); + expect(report.summary.unboundedLoopsFound).toBe(1); + expect(report.summary.pushPaymentsFound).toBe(1); + expect(report.summary.passed).toBe(false); + expect(report.findings.length).toBeGreaterThan(0); + }); + + it("inspects loop bounds across sources", () => { + const loops = inspectDosLoops([ + { + file: "Vault.sol", + content: sampleSource, + }, + ]); + + expect(loops.length).toBe(1); + expect(loops[0].associatedContract).toBe("Vault"); + expect(loops[0].associatedFunction).toBe("payAll"); + }); + + it("inspects call fanouts across sources", () => { + const calls = inspectDosCallFanOut([ + { + file: "Vault.sol", + content: sampleSource, + }, + ]); + + expect(calls.length).toBe(1); + expect(calls[0].isPushPayment).toBe(true); + expect(calls[0].isInsideLoop).toBe(true); + }); + + it("aborts audit when cancellation signal is triggered", async () => { + let cancelled = false; + const signal = { + isCancelled: () => cancelled, + }; + + cancelled = true; + await expect( + auditDosSafety( + [ + { + file: "Vault.sol", + content: sampleSource, + }, + ], + { signal }, + ), + ).rejects.toThrow(DosAnalysisCancelledError); + }); +}); diff --git a/packages/core/src/dos/__tests__/call-fanout.test.ts b/packages/core/src/dos/__tests__/call-fanout.test.ts new file mode 100644 index 0000000..278ec53 --- /dev/null +++ b/packages/core/src/dos/__tests__/call-fanout.test.ts @@ -0,0 +1,63 @@ +import { parseSolidity } from "../../ast/parser"; +import { extractCallFanOuts } from "../call-fanout"; + +describe("DoS Call Fan-Out Analyzer", () => { + it("detects value transfer inside loop as push payment", () => { + const source = ` + pragma solidity 0.8.20; + contract Fanout { + address[] public recipients; + function payAll() public { + for (uint256 i = 0; i < recipients.length; i++) { + payable(recipients[i]).transfer(100); + } + } + } + `; + const { ast } = parseSolidity(source, "Fanout.sol"); + const contract = ast!.children[1]; + const calls = extractCallFanOuts(contract, "Fanout", source, "Fanout.sol"); + + expect(calls.length).toBe(1); + expect(calls[0].callType).toBe("value_transfer"); + expect(calls[0].isInsideLoop).toBe(true); + expect(calls[0].isPushPayment).toBe(true); + }); + + it("detects low-level call without gas stipend", () => { + const source = ` + pragma solidity 0.8.20; + contract Relayer { + function forward(address target, bytes calldata data) public { + (bool ok, ) = target.call(data); + } + } + `; + const { ast } = parseSolidity(source, "Relayer.sol"); + const contract = ast!.children[1]; + const calls = extractCallFanOuts(contract, "Relayer", source, "Relayer.sol"); + + expect(calls.length).toBe(1); + expect(calls[0].callType).toBe("low_level_call"); + expect(calls[0].hasGasLimit).toBe(false); + expect(calls[0].isWrappedInTryCatch).toBe(false); + }); + + it("detects try/catch wrapped high-level call", () => { + const source = ` + pragma solidity 0.8.20; + interface IFoo { function bar() external; } + contract SafeCaller { + function callSafe(address target) public { + try IFoo(target).bar() {} catch {} + } + } + `; + const { ast } = parseSolidity(source, "SafeCaller.sol"); + const contract = ast!.children[2]; + const calls = extractCallFanOuts(contract, "SafeCaller", source, "SafeCaller.sol"); + + expect(calls.length).toBe(1); + expect(calls[0].isWrappedInTryCatch).toBe(true); + }); +}); diff --git a/packages/core/src/dos/__tests__/config.test.ts b/packages/core/src/dos/__tests__/config.test.ts new file mode 100644 index 0000000..016466a --- /dev/null +++ b/packages/core/src/dos/__tests__/config.test.ts @@ -0,0 +1,59 @@ +import { + validateDosConfig, + migrateDosConfig, + DosConfigError, + DEFAULT_DOS_LIMITS, +} from "../config"; + +describe("DoS Config Validation and Migration", () => { + it("validates default v1 config", () => { + const config = validateDosConfig({ version: 1 }); + expect(config.version).toBe(1); + expect(config.limits.maxFiles).toBe(DEFAULT_DOS_LIMITS.maxFiles); + }); + + it("migrates v0 config to v1", () => { + const v0 = { + version: 0, + maxFiles: 50, + includeRules: ["CP-DOS-001"], + }; + const migrated = migrateDosConfig(v0 as any); + expect(migrated.version).toBe(1); + expect(migrated.limits.maxFiles).toBe(50); + expect(migrated.includeRules).toContain("CP-DOS-001"); + }); + + it("throws error for non-object config", () => { + expect(() => validateDosConfig(null)).toThrow(DosConfigError); + expect(() => validateDosConfig("invalid")).toThrow(DosConfigError); + }); + + it("throws error for invalid rule ID", () => { + expect(() => + validateDosConfig({ + version: 1, + includeRules: ["INVALID-RULE"], + }), + ).toThrow(DosConfigError); + }); + + it("throws error when rule is in both include and exclude", () => { + expect(() => + validateDosConfig({ + version: 1, + includeRules: ["CP-DOS-001"], + excludeRules: ["CP-DOS-001"], + }), + ).toThrow(DosConfigError); + }); + + it("throws error for non-positive limits", () => { + expect(() => + validateDosConfig({ + version: 1, + limits: { maxFiles: -5 }, + }), + ).toThrow(DosConfigError); + }); +}); diff --git a/packages/core/src/dos/__tests__/growth-analyzer.test.ts b/packages/core/src/dos/__tests__/growth-analyzer.test.ts new file mode 100644 index 0000000..1918a8a --- /dev/null +++ b/packages/core/src/dos/__tests__/growth-analyzer.test.ts @@ -0,0 +1,51 @@ +import { parseSolidity } from "../../ast/parser"; +import { extractArrayGrowths } from "../growth-analyzer"; + +describe("DoS Storage Growth Analyzer", () => { + it("detects unrestricted array growth on iterated storage array", () => { + const source = ` + pragma solidity 0.8.20; + contract Queue { + address[] public items; + function addItem(address item) external { + items.push(item); + } + function processAll() external { + for (uint256 i = 0; i < items.length; i++) {} + } + } + `; + const { ast } = parseSolidity(source, "Queue.sol"); + const contract = ast!.children[1]; + const growths = extractArrayGrowths(contract, "Queue", source, "Queue.sol"); + + expect(growths.length).toBe(1); + expect(growths[0].arrayName).toBe("items"); + expect(growths[0].isPublicOrExternal).toBe(true); + expect(growths[0].hasAccessControl).toBe(false); + expect(growths[0].isIteratedInContract).toBe(true); + expect(growths[0].iteratingFunctions).toContain("processAll"); + }); + + it("recognizes access control and length caps", () => { + const source = ` + pragma solidity 0.8.20; + contract GuardedQueue { + address[] public items; + address public owner; + modifier onlyOwner() { require(msg.sender == owner); _; } + function addItem(address item) external onlyOwner { + require(items.length < 100, "full"); + items.push(item); + } + } + `; + const { ast } = parseSolidity(source, "GuardedQueue.sol"); + const contract = ast!.children[1]; + const growths = extractArrayGrowths(contract, "GuardedQueue", source, "GuardedQueue.sol"); + + expect(growths.length).toBe(1); + expect(growths[0].hasAccessControl).toBe(true); + expect(growths[0].hasLengthCap).toBe(true); + }); +}); diff --git a/packages/core/src/dos/__tests__/loop-analyzer.test.ts b/packages/core/src/dos/__tests__/loop-analyzer.test.ts new file mode 100644 index 0000000..e5fd101 --- /dev/null +++ b/packages/core/src/dos/__tests__/loop-analyzer.test.ts @@ -0,0 +1,95 @@ +import { parseSolidity } from "../../ast/parser"; +import { extractLoopBounds } from "../loop-analyzer"; + +describe("DoS Loop Analyzer", () => { + it("detects dynamic storage array loop bounds", () => { + const source = ` + pragma solidity 0.8.20; + contract Vault { + address[] public holders; + function run() public { + for (uint256 i = 0; i < holders.length; i++) { + // do something + } + } + } + `; + const { ast } = parseSolidity(source, "Vault.sol"); + const contract = ast!.children[1]; + const loops = extractLoopBounds(contract, "Vault", source, "Vault.sol"); + + expect(loops.length).toBe(1); + expect(loops[0].loopType).toBe("for"); + expect(loops[0].boundType).toBe("storage_array_bounded"); + expect(loops[0].targetVariable).toBe("holders"); + expect(loops[0].isCapped).toBe(false); + }); + + it("detects parameter-bounded loops with require upper-bound caps", () => { + const source = ` + pragma solidity 0.8.20; + contract Batcher { + function execute(uint256 count) public { + require(count <= 50, "too high"); + for (uint256 i = 0; i < count; i++) { + // do something + } + } + } + `; + const { ast } = parseSolidity(source, "Batcher.sol"); + const contract = ast!.children[1]; + const loops = extractLoopBounds(contract, "Batcher", source, "Batcher.sol"); + + expect(loops.length).toBe(1); + expect(loops[0].boundType).toBe("parameter_bounded"); + expect(loops[0].isCapped).toBe(true); + expect(loops[0].maxIterationsEstimate).toBe(50); + }); + + it("detects constant bounded loops", () => { + const source = ` + pragma solidity 0.8.20; + contract FixedLoop { + uint256 constant MAX = 10; + function run() public { + for (uint256 i = 0; i < 10; i++) {} + for (uint256 j = 0; j < MAX; j++) {} + } + } + `; + const { ast } = parseSolidity(source, "FixedLoop.sol"); + const contract = ast!.children[1]; + const loops = extractLoopBounds(contract, "FixedLoop", source, "FixedLoop.sol"); + + expect(loops.length).toBe(2); + expect(loops[0].boundType).toBe("constant_bounded"); + expect(loops[0].isCapped).toBe(true); + expect(loops[0].maxIterationsEstimate).toBe(10); + expect(loops[1].boundType).toBe("constant_bounded"); + expect(loops[1].isCapped).toBe(true); + }); + + it("detects operations inside loop bodies (calls, deletions, writes)", () => { + const source = ` + pragma solidity 0.8.20; + contract ComplexLoop { + address[] public users; + function clearAndPay() public { + for (uint256 i = 0; i < users.length; i++) { + delete users[i]; + payable(users[i]).transfer(1 ether); + } + } + } + `; + const { ast } = parseSolidity(source, "ComplexLoop.sol"); + const contract = ast!.children[1]; + const loops = extractLoopBounds(contract, "ComplexLoop", source, "ComplexLoop.sol"); + + expect(loops.length).toBe(1); + expect(loops[0].hasExternalCalls).toBe(true); + expect(loops[0].hasStorageDeletions).toBe(true); + expect(loops[0].hasStateWrites).toBe(true); + }); +}); diff --git a/packages/core/src/dos/__tests__/mitigation-detector.test.ts b/packages/core/src/dos/__tests__/mitigation-detector.test.ts new file mode 100644 index 0000000..33c5410 --- /dev/null +++ b/packages/core/src/dos/__tests__/mitigation-detector.test.ts @@ -0,0 +1,54 @@ +import { parseSolidity } from "../../ast/parser"; +import { detectMitigations } from "../mitigation-detector"; + +describe("DoS Mitigation Detector", () => { + it("recognizes pull payment pattern", () => { + const source = ` + pragma solidity 0.8.20; + contract PullVault { + mapping(address => uint256) public pendingWithdrawals; + function withdraw() external { + uint256 amt = pendingWithdrawals[msg.sender]; + pendingWithdrawals[msg.sender] = 0; + payable(msg.sender).transfer(amt); + } + } + `; + const { ast } = parseSolidity(source, "PullVault.sol"); + const contract = ast!.children[1]; + const mitigations = detectMitigations(contract, "PullVault", source, "PullVault.sol"); + + expect(mitigations.some((m) => m.type === "pull_payment")).toBe(true); + }); + + it("recognizes pagination pattern", () => { + const source = ` + pragma solidity 0.8.20; + contract Paginated { + function getBatch(uint256 offset, uint256 limit) external view {} + } + `; + const { ast } = parseSolidity(source, "Paginated.sol"); + const contract = ast!.children[1]; + const mitigations = detectMitigations(contract, "Paginated", source, "Paginated.sol"); + + expect(mitigations.some((m) => m.type === "pagination")).toBe(true); + }); + + it("recognizes failure isolation with try/catch", () => { + const source = ` + pragma solidity 0.8.20; + interface ITarget { function doWork() external; } + contract BatchIsolated { + function run(address target) external { + try ITarget(target).doWork() {} catch {} + } + } + `; + const { ast } = parseSolidity(source, "BatchIsolated.sol"); + const contract = ast!.children[2]; + const mitigations = detectMitigations(contract, "BatchIsolated", source, "BatchIsolated.sol"); + + expect(mitigations.some((m) => m.type === "failure_isolation")).toBe(true); + }); +}); diff --git a/packages/core/src/dos/__tests__/rules.test.ts b/packages/core/src/dos/__tests__/rules.test.ts new file mode 100644 index 0000000..97300dd --- /dev/null +++ b/packages/core/src/dos/__tests__/rules.test.ts @@ -0,0 +1,124 @@ +import { parseSolidity } from "../../ast/parser"; +import { detectDosVulnerabilities } from "../rules"; + +describe("DoS Rules (CP-DOS-001 to CP-DOS-010)", () => { + it("flags CP-DOS-001 on unbounded loop over dynamic storage array", () => { + const source = ` + pragma solidity 0.8.20; + contract Vault { + address[] public holders; + function run() public { + for (uint256 i = 0; i < holders.length; i++) {} + } + } + `; + const { ast } = parseSolidity(source, "Vault.sol"); + const findings = detectDosVulnerabilities(ast!, source, "Vault.sol"); + + expect(findings.some((f) => f.id === "CP-DOS-001")).toBe(true); + }); + + it("flags CP-DOS-002 on push payment inside loop", () => { + const source = ` + pragma solidity 0.8.20; + contract Dividend { + address[] public users; + function pay() public payable { + for (uint256 i = 0; i < users.length; i++) { + payable(users[i]).transfer(1 ether); + } + } + } + `; + const { ast } = parseSolidity(source, "Dividend.sol"); + const findings = detectDosVulnerabilities(ast!, source, "Dividend.sol"); + + expect(findings.some((f) => f.id === "CP-DOS-002")).toBe(true); + }); + + it("flags CP-DOS-004 on unchecked return data from low-level call", () => { + const source = ` + pragma solidity 0.8.20; + contract Relayer { + function callExt(address target, bytes calldata data) public { + (bool ok, ) = target.call(data); + require(ok); + } + } + `; + const { ast } = parseSolidity(source, "Relayer.sol"); + const findings = detectDosVulnerabilities(ast!, source, "Relayer.sol"); + + expect(findings.some((f) => f.id === "CP-DOS-004")).toBe(true); + }); + + it("flags CP-DOS-005 on mass storage deletion inside loop", () => { + const source = ` + pragma solidity 0.8.20; + contract ResetList { + uint256[] public list; + function clear() public { + for (uint256 i = 0; i < list.length; i++) { + delete list[i]; + } + } + } + `; + const { ast } = parseSolidity(source, "ResetList.sol"); + const findings = detectDosVulnerabilities(ast!, source, "ResetList.sol"); + + expect(findings.some((f) => f.id === "CP-DOS-005")).toBe(true); + }); + + it("flags CP-DOS-008 on unbounded recursion", () => { + const source = ` + pragma solidity 0.8.20; + contract Recursion { + function countdown(uint256 n) public returns (uint256) { + if (n == 0) return 0; + return countdown(n - 1); + } + } + `; + const { ast } = parseSolidity(source, "Recursion.sol"); + const findings = detectDosVulnerabilities(ast!, source, "Recursion.sol"); + + expect(findings.some((f) => f.id === "CP-DOS-008")).toBe(true); + }); + + it("flags CP-DOS-009 on unrestricted array growth", () => { + const source = ` + pragma solidity 0.8.20; + contract StorageAttack { + address[] public spam; + function pushEntry(address e) external { + spam.push(e); + } + function flush() external { + for (uint256 i = 0; i < spam.length; i++) {} + } + } + `; + const { ast } = parseSolidity(source, "StorageAttack.sol"); + const findings = detectDosVulnerabilities(ast!, source, "StorageAttack.sol"); + + expect(findings.some((f) => f.id === "CP-DOS-009")).toBe(true); + }); + + it("does not flag CP-DOS-001 when pagination is used", () => { + const source = ` + pragma solidity 0.8.20; + contract SafeVault { + address[] public holders; + function getBatch(uint256 offset, uint256 limit) external view { + uint256 end = offset + limit; + for (uint256 i = offset; i < end; i++) {} + } + } + `; + const { ast } = parseSolidity(source, "SafeVault.sol"); + const findings = detectDosVulnerabilities(ast!, source, "SafeVault.sol"); + + expect(findings.some((f) => f.id === "CP-DOS-001")).toBe(false); + }); +}); diff --git a/packages/core/src/dos/api.ts b/packages/core/src/dos/api.ts new file mode 100644 index 0000000..1706668 --- /dev/null +++ b/packages/core/src/dos/api.ts @@ -0,0 +1,294 @@ +/** + * @packageDocumentation + * @chainproof/core — Public High-Level DoS & Unbounded-Work Analysis API + */ + +import * as fs from "fs"; +import * as path from "path"; +import { parseSolidity } from "../ast/parser"; +import type { + DosAnalysisOptions, + DosAuditReport, + DosAuditSummary, + DosContractReport, + DosFileReport, + DosFinding, + DosSourceInput, + LoopBoundAnalysis, + CallFanOutAnalysis, + MitigationEvidence, +} from "./types"; +import { DOS_ANALYSIS_SCHEMA_VERSION } from "./types"; +import { DEFAULT_DOS_LIMITS, DosConfigError } from "./config"; +import { extractLoopBounds } from "./loop-analyzer"; +import { extractCallFanOuts } from "./call-fanout"; +import { extractArrayGrowths } from "./growth-analyzer"; +import { detectMitigations } from "./mitigation-detector"; +import { detectDosVulnerabilities } from "./rules"; + +export class DosAnalysisCancelledError extends Error { + constructor(message: string = "DoS and Unbounded-Work analysis was cancelled.") { + super(message); + this.name = "DosAnalysisCancelledError"; + } +} + +export function collectDosSolidityFiles( + targets: string[], + limits: { maxFiles: number; maxSourceBytes: number } = DEFAULT_DOS_LIMITS, +): DosSourceInput[] { + const result: DosSourceInput[] = []; + + function walk(currentPath: string): void { + if (result.length >= limits.maxFiles) { + throw new DosConfigError(`Maximum file limit of ${limits.maxFiles} exceeded during directory walk.`); + } + + const stat = fs.statSync(currentPath); + if (stat.isDirectory()) { + const entries = fs.readdirSync(currentPath); + for (const entry of entries) { + if (entry === "node_modules" || entry === ".git" || entry === "dist" || entry === "artifacts") { + continue; + } + walk(path.join(currentPath, entry)); + } + } else if (stat.isFile() && currentPath.endsWith(".sol")) { + if (stat.size > limits.maxSourceBytes) { + throw new DosConfigError( + `File "${currentPath}" size (${stat.size} bytes) exceeds maximum permitted limit (${limits.maxSourceBytes} bytes).`, + ); + } + const content = fs.readFileSync(currentPath, "utf-8"); + result.push({ file: currentPath, content }); + } + } + + for (const t of targets) { + if (fs.existsSync(t)) { + walk(t); + } else { + throw new DosConfigError(`Target path not found: ${t}`); + } + } + + return result; +} + +function resolveInputs( + targets: string[] | DosSourceInput[], + options?: DosAnalysisOptions, +): DosSourceInput[] { + const limits = { + maxFiles: options?.limits?.maxFiles ?? DEFAULT_DOS_LIMITS.maxFiles, + maxSourceBytes: options?.limits?.maxSourceBytes ?? DEFAULT_DOS_LIMITS.maxSourceBytes, + }; + + if (targets.length === 0) { + throw new DosConfigError("No targets provided for DoS analysis."); + } + + if (typeof targets[0] === "string") { + return collectDosSolidityFiles(targets as string[], limits); + } + + const inputs = targets as DosSourceInput[]; + if (inputs.length > limits.maxFiles) { + throw new DosConfigError(`Provided ${inputs.length} files exceeds maximum limit of ${limits.maxFiles}.`); + } + + for (const inp of inputs) { + if (inp.content && Buffer.byteLength(inp.content, "utf-8") > limits.maxSourceBytes) { + throw new DosConfigError(`File "${inp.file}" exceeds maximum permitted size.`); + } + } + + return inputs; +} + +export function inspectDosLoops( + targets: string[] | DosSourceInput[], + options?: DosAnalysisOptions, +): LoopBoundAnalysis[] { + const inputs = resolveInputs(targets, options); + const allLoops: LoopBoundAnalysis[] = []; + + for (const inp of inputs) { + if (options?.signal?.isCancelled()) { + throw new DosAnalysisCancelledError(); + } + + let ast = inp.ast; + if (!ast) { + const parsed = parseSolidity(inp.content, inp.file); + ast = parsed.ast; + } + if (!ast) continue; + + for (const child of ast.children || []) { + if (child.type === "ContractDefinition") { + const contractName = child.name || "Contract"; + const loops = extractLoopBounds(child, contractName, inp.content, inp.file); + allLoops.push(...loops); + } + } + } + + return allLoops; +} + +export function inspectDosCallFanOut( + targets: string[] | DosSourceInput[], + options?: DosAnalysisOptions, +): CallFanOutAnalysis[] { + const inputs = resolveInputs(targets, options); + const allCalls: CallFanOutAnalysis[] = []; + + for (const inp of inputs) { + if (options?.signal?.isCancelled()) { + throw new DosAnalysisCancelledError(); + } + + let ast = inp.ast; + if (!ast) { + const parsed = parseSolidity(inp.content, inp.file); + ast = parsed.ast; + } + if (!ast) continue; + + for (const child of ast.children || []) { + if (child.type === "ContractDefinition") { + const contractName = child.name || "Contract"; + const calls = extractCallFanOuts(child, contractName, inp.content, inp.file); + allCalls.push(...calls); + } + } + } + + return allCalls; +} + +export async function auditDosSafety( + targets: string[] | DosSourceInput[], + options?: DosAnalysisOptions, +): Promise { + const inputs = resolveInputs(targets, options); + const fileReports: DosFileReport[] = []; + const allFindings: DosFinding[] = []; + const allMitigations: MitigationEvidence[] = []; + + let totalContracts = 0; + let totalLoopsAnalyzed = 0; + let unboundedLoopsFound = 0; + let pushPaymentsFound = 0; + let returnBombRisksFound = 0; + let callFanOutsFound = 0; + let storageClearingFound = 0; + let arrayGrowthPointsFound = 0; + + for (const inp of inputs) { + if (options?.signal?.isCancelled()) { + throw new DosAnalysisCancelledError(); + } + + let ast = inp.ast; + if (!ast) { + const parsed = parseSolidity(inp.content, inp.file); + ast = parsed.ast; + } + + const contractReports: DosContractReport[] = []; + let fileFindings: DosFinding[] = []; + + if (ast) { + fileFindings = detectDosVulnerabilities(ast, inp.content, inp.file, options); + allFindings.push(...fileFindings); + + for (const child of ast.children || []) { + if (child.type === "ContractDefinition") { + totalContracts++; + const cName = child.name || "Contract"; + const loops = extractLoopBounds(child, cName, inp.content, inp.file); + const calls = extractCallFanOuts(child, cName, inp.content, inp.file); + const growths = extractArrayGrowths(child, cName, inp.content, inp.file); + const mitigations = detectMitigations(child, cName, inp.content, inp.file); + + totalLoopsAnalyzed += loops.length; + unboundedLoopsFound += loops.filter((l) => l.boundType === "storage_array_bounded" || l.boundType === "unbounded").length; + pushPaymentsFound += calls.filter((c) => c.isPushPayment && c.isInsideLoop).length; + returnBombRisksFound += calls.filter((c) => c.callType === "low_level_call" && !c.hasReturndataSizeCheck).length; + callFanOutsFound += calls.filter((c) => c.isInsideLoop).length; + storageClearingFound += loops.filter((l) => l.hasStorageDeletions).length; + arrayGrowthPointsFound += growths.length; + + allMitigations.push(...mitigations); + + const cFindings = fileFindings.filter((f) => f.file === inp.file); + + contractReports.push({ + contractName: cName, + file: inp.file, + totalLoops: loops.length, + unboundedLoops: loops.filter((l) => l.boundType === "storage_array_bounded" || l.boundType === "unbounded").length, + externalCallsInLoops: calls.filter((c) => c.isInsideLoop).length, + pushPaymentPatterns: calls.filter((c) => c.isPushPayment && c.isInsideLoop).length, + returnBombRisks: calls.filter((c) => c.callType === "low_level_call" && !c.hasReturndataSizeCheck).length, + growthEndpoints: growths.length, + loops, + callFanOuts: calls, + arrayGrowths: growths, + mitigations, + findings: cFindings, + }); + } + } + } + + fileReports.push({ + file: inp.file, + contracts: contractReports, + findings: fileFindings, + }); + } + + const severityCounts = { + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + gas: 0, + }; + + for (const f of allFindings) { + if (f.severity in severityCounts) { + severityCounts[f.severity as keyof typeof severityCounts]++; + } + } + + const passed = severityCounts.critical === 0 && severityCounts.high === 0; + + const summary: DosAuditSummary = { + totalFiles: inputs.length, + totalContracts, + totalLoopsAnalyzed, + unboundedLoopsFound, + pushPaymentsFound, + returnBombRisksFound, + callFanOutsFound, + storageClearingFound, + arrayGrowthPointsFound, + mitigationsRecognized: allMitigations.length, + findingsCount: severityCounts, + passed, + }; + + return { + schemaVersion: DOS_ANALYSIS_SCHEMA_VERSION, + createdAt: new Date().toISOString(), + summary, + files: fileReports, + findings: allFindings, + mitigations: allMitigations, + }; +} diff --git a/packages/core/src/dos/call-fanout.ts b/packages/core/src/dos/call-fanout.ts new file mode 100644 index 0000000..214974c --- /dev/null +++ b/packages/core/src/dos/call-fanout.ts @@ -0,0 +1,230 @@ +/** + * @packageDocumentation + * @chainproof/core — External Call Fan-Out, Push-Payment & Return-Bomb Analyzer + */ + +import { visit } from "../ast/parser"; +import type { CallFanOutAnalysis } from "./types"; + +function extractExpressionString(node: any): string { + if (!node) return ""; + if (node.type === "Identifier") return node.name || ""; + if (node.type === "NumberLiteral") return String(node.number || node.value || ""); + if (node.type === "MemberAccess") { + return `${extractExpressionString(node.expression)}.${node.memberName}`; + } + if (node.type === "IndexAccess") { + return `${extractExpressionString(node.base)}[${extractExpressionString(node.index)}]`; + } + if (node.type === "FunctionCall") { + const callee = extractExpressionString(node.expression); + const args = (node.arguments || []).map(extractExpressionString).join(", "); + return `${callee}(${args})`; + } + if (node.type === "NameValueExpression") { + return `${extractExpressionString(node.expression)}{${(node.arguments?.names || []).join(", ")}}`; + } + return ""; +} + +export function extractCallFanOuts( + contractNode: any, + contractName: string, + source: string, + _filePath: string, +): CallFanOutAnalysis[] { + if (!source.includes("call") && !source.includes("transfer") && !source.includes("send")) { + return []; + } + + const callAnalyses: CallFanOutAnalysis[] = []; + + visit(contractNode, { + FunctionDefinition: (fnNode: any) => { + const fnName = fnNode.name || (fnNode.isConstructor ? "constructor" : "fallback"); + + interface Range { + start: number; + end: number; + line: number; + } + + const loopRanges: Range[] = []; + const tryRanges: Range[] = []; + + visit(fnNode, { + ForStatement: (forNode: any) => { + const start = forNode.loc?.start?.line || 0; + const end = forNode.loc?.end?.line || start; + loopRanges.push({ start, end, line: start }); + }, + WhileStatement: (whileNode: any) => { + const start = whileNode.loc?.start?.line || 0; + const end = whileNode.loc?.end?.line || start; + loopRanges.push({ start, end, line: start }); + }, + DoWhileStatement: (doWhileNode: any) => { + const start = doWhileNode.loc?.start?.line || 0; + const end = doWhileNode.loc?.end?.line || start; + loopRanges.push({ start, end, line: start }); + }, + TryStatement: (tryNode: any) => { + const start = tryNode.loc?.start?.line || 0; + const end = tryNode.loc?.end?.line || start; + tryRanges.push({ start, end, line: start }); + }, + }); + + visit(fnNode, { + FunctionCall: (callNode: any) => { + const callLine = callNode.loc?.start?.line || 0; + const enclosingLoop = loopRanges.find( + (r) => callLine >= r.start && callLine <= r.end, + ); + const isWrappedInTryCatch = tryRanges.some( + (r) => callLine >= r.start && callLine <= r.end, + ); + + const analysis = inspectFunctionCall( + callNode, + !!enclosingLoop, + enclosingLoop?.line, + isWrappedInTryCatch, + fnName, + contractName, + ); + if (analysis) { + callAnalyses.push(analysis); + } + }, + }); + }, + }); + + return callAnalyses; +} + +function inspectFunctionCall( + callNode: any, + isInsideLoop: boolean, + loopLine: number | undefined, + isWrappedInTryCatch: boolean, + fnName: string, + contractName: string, +): CallFanOutAnalysis | null { + const expr = callNode.expression; + if (!expr) return null; + + const line = callNode.loc?.start?.line || 1; + const exprStr = extractExpressionString(expr); + + // 1. Check recipient.transfer(...) or recipient.send(...) + if (expr.type === "MemberAccess") { + const member = expr.memberName; + const targetExpr = extractExpressionString(expr.expression); + + if (member === "transfer" && callNode.arguments?.length === 1) { + return { + line, + callType: "value_transfer", + targetExpression: targetExpr, + valueExpression: extractExpressionString(callNode.arguments[0]), + isInsideLoop, + loopLine, + hasRevertCheck: true, // transfer automatically reverts on failure + isWrappedInTryCatch, + hasGasLimit: true, // transfer is capped at 2300 gas + gasLimitExpression: "2300", + hasReturndataSizeCheck: true, + isPushPayment: true, + associatedFunction: fnName, + associatedContract: contractName, + }; + } + + if (member === "send" && callNode.arguments?.length === 1) { + return { + line, + callType: "value_transfer", + targetExpression: targetExpr, + valueExpression: extractExpressionString(callNode.arguments[0]), + isInsideLoop, + loopLine, + hasRevertCheck: false, // send returns bool + isWrappedInTryCatch, + hasGasLimit: true, + gasLimitExpression: "2300", + hasReturndataSizeCheck: true, + isPushPayment: true, + associatedFunction: fnName, + associatedContract: contractName, + }; + } + } + + // 2. Check recipient.call{value: ...}("") or recipient.call(...) + if (expr.type === "NameValueExpression" || exprStr.includes(".call{") || exprStr.endsWith(".call")) { + let target = ""; + let valueExpr: string | undefined = undefined; + let gasLimit: string | undefined = undefined; + let hasGas = false; + + if (expr.type === "NameValueExpression") { + target = extractExpressionString(expr.expression); + if (expr.arguments) { + const names = expr.arguments.names || []; + const args = expr.arguments.arguments || []; + for (let i = 0; i < names.length; i++) { + if (names[i] === "value") { + valueExpr = extractExpressionString(args[i]); + } + if (names[i] === "gas") { + hasGas = true; + gasLimit = extractExpressionString(args[i]); + } + } + } + } else if (expr.type === "MemberAccess" && expr.memberName === "call") { + target = extractExpressionString(expr.expression); + } + + const isValueTransfer = !!valueExpr || exprStr.includes("value"); + + return { + line, + callType: isValueTransfer ? "value_transfer" : "low_level_call", + targetExpression: target || exprStr, + valueExpression: valueExpr, + isInsideLoop, + loopLine, + hasRevertCheck: false, + isWrappedInTryCatch, + hasGasLimit: hasGas, + gasLimitExpression: gasLimit, + hasReturndataSizeCheck: false, + isPushPayment: isValueTransfer, + associatedFunction: fnName, + associatedContract: contractName, + }; + } + + // 3. High-level external call + if (expr.type === "MemberAccess" && expr.expression?.type !== "Identifier" && expr.memberName !== "push") { + return { + line, + callType: "high_level", + targetExpression: extractExpressionString(expr.expression), + isInsideLoop, + loopLine, + hasRevertCheck: true, + isWrappedInTryCatch, + hasGasLimit: false, + hasReturndataSizeCheck: false, + isPushPayment: false, + associatedFunction: fnName, + associatedContract: contractName, + }; + } + + return null; +} diff --git a/packages/core/src/dos/config.ts b/packages/core/src/dos/config.ts new file mode 100644 index 0000000..8b85023 --- /dev/null +++ b/packages/core/src/dos/config.ts @@ -0,0 +1,188 @@ +/** + * @packageDocumentation + * @chainproof/core — DoS & Unbounded Work Configuration, Validation & Migration + */ + +import * as fs from "fs"; +import type { + DosConfigV0, + DosConfigV1, + ValidatedDosConfig, + DosAnalysisLimits, + DosRuleId, + DosSeverity, + DosConfidence, +} from "./types"; +import { DOS_CONFIG_SCHEMA_VERSION } from "./types"; + +export class DosConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "DosConfigError"; + } +} + +export const ALL_DOS_RULES: DosRuleId[] = [ + "CP-DOS-001", + "CP-DOS-002", + "CP-DOS-003", + "CP-DOS-004", + "CP-DOS-005", + "CP-DOS-006", + "CP-DOS-007", + "CP-DOS-008", + "CP-DOS-009", + "CP-DOS-010", +]; + +export const DEFAULT_DOS_LIMITS: DosAnalysisLimits = { + maxFiles: 200, + maxSourceBytes: 5 * 1024 * 1024, // 5MB + maxContracts: 100, + maxLoops: 500, + maxFindings: 1000, + timeoutMs: 30_000, +}; + +export function validateDosConfig(input: unknown): ValidatedDosConfig { + if (!input || typeof input !== "object") { + throw new DosConfigError("Configuration must be a non-null object."); + } + + const raw = input as Record; + const version = raw.version ?? DOS_CONFIG_SCHEMA_VERSION; + + if (version === 0) { + return migrateDosConfig(raw as DosConfigV0); + } + + if (version !== 1) { + throw new DosConfigError(`Unsupported DoS configuration schema version: ${version}`); + } + + const v1 = input as DosConfigV1; + + // Validate includeRules + const includeRules: DosRuleId[] = []; + if (v1.includeRules) { + if (!Array.isArray(v1.includeRules)) { + throw new DosConfigError("includeRules must be an array of rule IDs."); + } + for (const r of v1.includeRules) { + if (!ALL_DOS_RULES.includes(r as DosRuleId)) { + throw new DosConfigError(`Invalid rule ID in includeRules: ${r}`); + } + includeRules.push(r as DosRuleId); + } + } + + // Validate excludeRules + const excludeRules: DosRuleId[] = []; + if (v1.excludeRules) { + if (!Array.isArray(v1.excludeRules)) { + throw new DosConfigError("excludeRules must be an array of rule IDs."); + } + for (const r of v1.excludeRules) { + if (!ALL_DOS_RULES.includes(r as DosRuleId)) { + throw new DosConfigError(`Invalid rule ID in excludeRules: ${r}`); + } + if (includeRules.includes(r as DosRuleId)) { + throw new DosConfigError(`Rule ${r} cannot be in both includeRules and excludeRules.`); + } + excludeRules.push(r as DosRuleId); + } + } + + // Validate limits + const limits: DosAnalysisLimits = { ...DEFAULT_DOS_LIMITS }; + if (v1.limits) { + if (typeof v1.limits !== "object") { + throw new DosConfigError("limits must be an object."); + } + if (v1.limits.maxFiles !== undefined) { + if (typeof v1.limits.maxFiles !== "number" || v1.limits.maxFiles <= 0) { + throw new DosConfigError("limits.maxFiles must be a positive integer."); + } + limits.maxFiles = v1.limits.maxFiles; + } + if (v1.limits.maxSourceBytes !== undefined) { + if (typeof v1.limits.maxSourceBytes !== "number" || v1.limits.maxSourceBytes <= 0) { + throw new DosConfigError("limits.maxSourceBytes must be a positive integer."); + } + limits.maxSourceBytes = v1.limits.maxSourceBytes; + } + if (v1.limits.maxContracts !== undefined) { + if (typeof v1.limits.maxContracts !== "number" || v1.limits.maxContracts <= 0) { + throw new DosConfigError("limits.maxContracts must be a positive integer."); + } + limits.maxContracts = v1.limits.maxContracts; + } + if (v1.limits.maxLoops !== undefined) { + if (typeof v1.limits.maxLoops !== "number" || v1.limits.maxLoops <= 0) { + throw new DosConfigError("limits.maxLoops must be a positive integer."); + } + limits.maxLoops = v1.limits.maxLoops; + } + if (v1.limits.maxFindings !== undefined) { + if (typeof v1.limits.maxFindings !== "number" || v1.limits.maxFindings <= 0) { + throw new DosConfigError("limits.maxFindings must be a positive integer."); + } + limits.maxFindings = v1.limits.maxFindings; + } + if (v1.limits.timeoutMs !== undefined) { + if (typeof v1.limits.timeoutMs !== "number" || v1.limits.timeoutMs <= 0) { + throw new DosConfigError("limits.timeoutMs must be a positive integer."); + } + limits.timeoutMs = v1.limits.timeoutMs; + } + } + + const minSeverity: DosSeverity = v1.minSeverity || "info"; + const minConfidence: DosConfidence = v1.minConfidence || "low"; + + return { + version: 1, + includeRules, + excludeRules, + minSeverity, + minConfidence, + limits, + }; +} + +export function migrateDosConfig(v0: DosConfigV0): ValidatedDosConfig { + const includeRules: DosRuleId[] = (v0.includeRules || []) + .filter((r) => ALL_DOS_RULES.includes(r as DosRuleId)) as DosRuleId[]; + const excludeRules: DosRuleId[] = (v0.excludeRules || []) + .filter((r) => ALL_DOS_RULES.includes(r as DosRuleId)) as DosRuleId[]; + + const limits: DosAnalysisLimits = { + ...DEFAULT_DOS_LIMITS, + ...(v0.maxFiles ? { maxFiles: v0.maxFiles } : {}), + ...(v0.maxSourceSize ? { maxSourceBytes: v0.maxSourceSize } : {}), + }; + + return { + version: 1, + includeRules, + excludeRules, + minSeverity: "info", + minConfidence: "low", + limits, + }; +} + +export function loadDosConfigFile(configPath: string): ValidatedDosConfig { + if (!fs.existsSync(configPath)) { + throw new DosConfigError(`Configuration file not found: ${configPath}`); + } + + try { + const content = fs.readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(content); + return validateDosConfig(parsed); + } catch (err) { + if (err instanceof DosConfigError) throw err; + throw new DosConfigError(`Failed to load DoS config from ${configPath}: ${err instanceof Error ? err.message : String(err)}`); + } +} diff --git a/packages/core/src/dos/growth-analyzer.ts b/packages/core/src/dos/growth-analyzer.ts new file mode 100644 index 0000000..86e7a6f --- /dev/null +++ b/packages/core/src/dos/growth-analyzer.ts @@ -0,0 +1,178 @@ +/** + * @packageDocumentation + * @chainproof/core — Storage Growth & Array Poisoning Analyzer + */ + +import { visit } from "../ast/parser"; +import type { ArrayGrowthAnalysis } from "./types"; + +function extractExpressionString(node: any): string { + if (!node) return ""; + if (node.type === "Identifier") return node.name || ""; + if (node.type === "NumberLiteral") return String(node.number || node.value || ""); + if (node.type === "MemberAccess") { + return `${extractExpressionString(node.expression)}.${node.memberName}`; + } + if (node.type === "IndexAccess") { + return `${extractExpressionString(node.base)}[${extractExpressionString(node.index)}]`; + } + if (node.type === "BinaryOperation") { + return `${extractExpressionString(node.left)} ${node.operator} ${extractExpressionString(node.right)}`; + } + if (node.type === "UnaryOperation") { + return node.isPrefix + ? `${node.operator}${extractExpressionString(node.subExpression)}` + : `${extractExpressionString(node.subExpression)}${node.operator}`; + } + if (node.type === "FunctionCall") { + const callee = extractExpressionString(node.expression); + const args = (node.arguments || []).map(extractExpressionString).join(", "); + return `${callee}(${args})`; + } + return ""; +} + +export function extractArrayGrowths( + contractNode: any, + contractName: string, + source: string, + _filePath: string, +): ArrayGrowthAnalysis[] { + if (!source.includes(".push")) { + return []; + } + + const dynamicArrays = new Map(); // name -> type + const arrayIteratingFunctions = new Map>(); // arrayName -> set of function names iterating it + + // 1. Identify dynamic storage arrays + visit(contractNode, { + StateVariableDeclaration: (decl: any) => { + for (const v of decl.variables || []) { + if (v.name) { + const typeStr = v.typeName?.name || v.typeName?.namePath || ""; + if (v.typeName?.type === "ArrayTypeName" || typeStr.endsWith("[]")) { + dynamicArrays.set(v.name, typeStr || "dynamic[]"); + } + } + } + }, + }); + + // 2. Identify iterating functions and growth operations in a single pass + const rawGrowths: Array<{ + line: number; + arrayName: string; + isPublicOrExternal: boolean; + hasAccessControl: boolean; + hasRateLimitOrFee: boolean; + hasLengthCap: boolean; + associatedFunction: string; + associatedContract: string; + }> = []; + + visit(contractNode, { + FunctionDefinition: (fnNode: any) => { + const fnName = fnNode.name || (fnNode.isConstructor ? "constructor" : "fallback"); + const isPublicOrExternal = + fnNode.visibility === "public" || + fnNode.visibility === "external" || + !fnNode.visibility; + + let hasAccessControl = false; + let hasRateLimitOrFee = false; + let hasLengthCap = false; + + // Check modifiers + for (const mod of fnNode.modifiers || []) { + const modName = (mod.name || "").toLowerCase(); + if ( + modName.includes("only") || + modName.includes("auth") || + modName.includes("admin") || + modName.includes("governor") || + modName.includes("owner") + ) { + hasAccessControl = true; + } + } + + visit(fnNode, { + ForStatement: (forNode: any) => { + checkIteration(forNode.conditionExpression || forNode.condition, fnName, dynamicArrays, arrayIteratingFunctions); + }, + WhileStatement: (whileNode: any) => { + checkIteration(whileNode.condition, fnName, dynamicArrays, arrayIteratingFunctions); + }, + FunctionCall: (callNode: any) => { + const callee = extractExpressionString(callNode.expression); + if (callee === "require" && callNode.arguments?.length > 0) { + const condStr = extractExpressionString(callNode.arguments[0]); + if (condStr.includes("msg.sender") || condStr.includes("owner")) { + hasAccessControl = true; + } + if (condStr.includes("msg.value")) { + hasRateLimitOrFee = true; + } + if (condStr.includes(".length") && (condStr.includes("<") || condStr.includes("<="))) { + hasLengthCap = true; + } + } + if (callNode.expression?.type === "MemberAccess" && callNode.expression.memberName === "push") { + const arrayName = extractExpressionString(callNode.expression.expression); + if (dynamicArrays.has(arrayName)) { + rawGrowths.push({ + line: callNode.loc?.start?.line || 1, + arrayName, + isPublicOrExternal, + hasAccessControl, + hasRateLimitOrFee, + hasLengthCap, + associatedFunction: fnName, + associatedContract: contractName, + }); + } + } + }, + }); + }, + }); + + const growths: ArrayGrowthAnalysis[] = rawGrowths.map((g) => { + const iteratingFns = Array.from(arrayIteratingFunctions.get(g.arrayName) || []); + return { + line: g.line, + arrayName: g.arrayName, + arrayType: dynamicArrays.get(g.arrayName) || "dynamic[]", + pushExpression: `${g.arrayName}.push(...)`, + isPublicOrExternal: g.isPublicOrExternal, + hasAccessControl: g.hasAccessControl, + hasRateLimitOrFee: g.hasRateLimitOrFee, + hasLengthCap: g.hasLengthCap, + associatedFunction: g.associatedFunction, + associatedContract: g.associatedContract, + isIteratedInContract: iteratingFns.length > 0, + iteratingFunctions: iteratingFns, + }; + }); + + return growths; +} + +function checkIteration( + condNode: any, + fnName: string, + dynamicArrays: Map, + iteratingMap: Map>, +): void { + if (!condNode) return; + const condStr = extractExpressionString(condNode); + for (const arrName of dynamicArrays.keys()) { + if (condStr.includes(`${arrName}.length`)) { + if (!iteratingMap.has(arrName)) { + iteratingMap.set(arrName, new Set()); + } + iteratingMap.get(arrName)!.add(fnName); + } + } +} diff --git a/packages/core/src/dos/index.ts b/packages/core/src/dos/index.ts new file mode 100644 index 0000000..2fe086e --- /dev/null +++ b/packages/core/src/dos/index.ts @@ -0,0 +1,14 @@ +/** + * @packageDocumentation + * @chainproof/core — Denial-of-Service, Gas-Griefing & Unbounded-Work Analysis Module + */ + +export * from "./types"; +export * from "./loop-analyzer"; +export * from "./call-fanout"; +export * from "./growth-analyzer"; +export * from "./mitigation-detector"; +export * from "./rules"; +export * from "./config"; +export * from "./serialize"; +export * from "./api"; diff --git a/packages/core/src/dos/loop-analyzer.ts b/packages/core/src/dos/loop-analyzer.ts new file mode 100644 index 0000000..21af80a --- /dev/null +++ b/packages/core/src/dos/loop-analyzer.ts @@ -0,0 +1,343 @@ +/** + * @packageDocumentation + * @chainproof/core — AST Loop Bounds & Work Complexity Analyzer + */ + +import { visit } from "../ast/parser"; +import type { LoopBoundAnalysis, LoopBoundType } from "./types"; + +interface VariableScope { + stateVariables: Set; + storageArrays: Set; + constantVariables: Map; + functionParameters: Set; + parameterCaps: Map; +} + +function extractExpressionString(node: any): string { + if (!node) return ""; + if (node.type === "Identifier") return node.name || ""; + if (node.type === "NumberLiteral") return String(node.number || node.value || ""); + if (node.type === "MemberAccess") { + return `${extractExpressionString(node.expression)}.${node.memberName}`; + } + if (node.type === "IndexAccess") { + return `${extractExpressionString(node.base)}[${extractExpressionString(node.index)}]`; + } + if (node.type === "BinaryOperation") { + return `${extractExpressionString(node.left)} ${node.operator} ${extractExpressionString(node.right)}`; + } + if (node.type === "UnaryOperation") { + return node.isPrefix + ? `${node.operator}${extractExpressionString(node.subExpression)}` + : `${extractExpressionString(node.subExpression)}${node.operator}`; + } + if (node.type === "FunctionCall") { + const callee = extractExpressionString(node.expression); + const args = (node.arguments || []).map(extractExpressionString).join(", "); + return `${callee}(${args})`; + } + return ""; +} + +function analyzeContractScope(contractNode: any): VariableScope { + const stateVariables = new Set(); + const storageArrays = new Set(); + const constantVariables = new Map(); + + visit(contractNode, { + StateVariableDeclaration: (decl: any) => { + for (const v of decl.variables || []) { + if (v.name) { + stateVariables.add(v.name); + const typeStr = v.typeName?.name || v.typeName?.namePath || ""; + if (v.typeName?.type === "ArrayTypeName" || typeStr.endsWith("[]")) { + storageArrays.add(v.name); + } + if (v.isDeclaredConst || v.isImmutable) { + if (v.expression?.type === "NumberLiteral") { + const num = Number(v.expression.number || v.expression.value); + if (Number.isFinite(num)) { + constantVariables.set(v.name, num); + } + } + } + } + } + }, + }); + + return { + stateVariables, + storageArrays, + constantVariables, + functionParameters: new Set(), + parameterCaps: new Map(), + }; +} + +export function extractLoopBounds( + contractNode: any, + contractName: string, + source: string, + _filePath: string, +): LoopBoundAnalysis[] { + if (!source.includes("for") && !source.includes("while") && !source.includes("do")) { + return []; + } + + const baseScope = analyzeContractScope(contractNode); + const loopAnalyses: LoopBoundAnalysis[] = []; + + visit(contractNode, { + FunctionDefinition: (fnNode: any) => { + const fnName = fnNode.name || (fnNode.isConstructor ? "constructor" : "fallback"); + const fnScope: VariableScope = { + stateVariables: new Set(baseScope.stateVariables), + storageArrays: new Set(baseScope.storageArrays), + constantVariables: new Map(baseScope.constantVariables), + functionParameters: new Set(), + parameterCaps: new Map(), + }; + + // Collect parameters + const rawParams = Array.isArray(fnNode.parameters) + ? fnNode.parameters + : fnNode.parameters?.parameters || []; + for (const p of rawParams) { + if (p.name) fnScope.functionParameters.add(p.name); + } + + // Single pass over function AST for parameter caps and loops + visit(fnNode, { + FunctionCall: (callNode: any) => { + const callee = extractExpressionString(callNode.expression); + if (callee === "require" && callNode.arguments && callNode.arguments.length > 0) { + const cond = callNode.arguments[0]; + if (cond.type === "BinaryOperation" && (cond.operator === "<=" || cond.operator === "<")) { + const left = extractExpressionString(cond.left); + const right = cond.right; + if (fnScope.functionParameters.has(left) && right.type === "NumberLiteral") { + const cap = Number(right.number || right.value); + if (Number.isFinite(cap)) { + fnScope.parameterCaps.set(left, cap); + } + } + } + } + }, + ForStatement: (forNode: any) => { + const analysis = analyzeLoop( + forNode, + "for", + fnScope, + fnName, + contractName, + source, + ); + loopAnalyses.push(analysis); + }, + WhileStatement: (whileNode: any) => { + const analysis = analyzeLoop( + whileNode, + "while", + fnScope, + fnName, + contractName, + source, + ); + loopAnalyses.push(analysis); + }, + DoWhileStatement: (doWhileNode: any) => { + const analysis = analyzeLoop( + doWhileNode, + "do-while", + fnScope, + fnName, + contractName, + source, + ); + loopAnalyses.push(analysis); + }, + }); + }, + }); + + return loopAnalyses; +} + +function analyzeLoop( + loopNode: any, + loopType: "for" | "while" | "do-while", + scope: VariableScope, + fnName: string, + contractName: string, + _source: string, +): LoopBoundAnalysis { + const line = loopNode.loc?.start?.line || 1; + const cond = loopNode.conditionExpression || loopNode.condition; + const conditionExpr = extractExpressionString(cond); + + let boundType: LoopBoundType = "unknown"; + let boundExpression: string | undefined = undefined; + let targetVariable: string | undefined = undefined; + let isCapped = false; + let maxIterationsEstimate: number | undefined = undefined; + let uncertaintyReason: string | undefined = undefined; + + // Classify bound + if (cond) { + if (cond.type === "BinaryOperation" && (cond.operator === "<" || cond.operator === "<=" || cond.operator === "!=")) { + const rightStr = extractExpressionString(cond.right); + boundExpression = rightStr; + + // Check if right side is a storage array .length (e.g. holders.length) + if (rightStr.endsWith(".length")) { + const arrayBase = rightStr.slice(0, -7); + targetVariable = arrayBase; + if (scope.storageArrays.has(arrayBase) || scope.stateVariables.has(arrayBase)) { + boundType = "storage_array_bounded"; + isCapped = false; + uncertaintyReason = `Loop bound derives from dynamic storage array '${arrayBase}.length' which can grow arbitrarily.`; + } else if (scope.functionParameters.has(arrayBase)) { + boundType = "parameter_bounded"; + const cap = scope.parameterCaps.get(arrayBase); + if (cap !== undefined) { + isCapped = true; + maxIterationsEstimate = cap; + } else { + isCapped = false; + uncertaintyReason = `Calldata array '${arrayBase}' length is not explicitly bounded by a require statement.`; + } + } else { + boundType = "storage_array_bounded"; + } + } else if (scope.constantVariables.has(rightStr)) { + boundType = "constant_bounded"; + isCapped = true; + maxIterationsEstimate = scope.constantVariables.get(rightStr); + } else if (cond.right.type === "NumberLiteral") { + boundType = "constant_bounded"; + isCapped = true; + maxIterationsEstimate = Number(cond.right.number || cond.right.value); + } else if (scope.functionParameters.has(rightStr)) { + boundType = "parameter_bounded"; + targetVariable = rightStr; + const cap = scope.parameterCaps.get(rightStr); + if (cap !== undefined) { + isCapped = true; + maxIterationsEstimate = cap; + } else { + isCapped = false; + uncertaintyReason = `Loop parameter '${rightStr}' lacks explicit upper bound assertion.`; + } + } else if (scope.stateVariables.has(rightStr)) { + boundType = "state_variable_bounded"; + targetVariable = rightStr; + isCapped = false; + uncertaintyReason = `State variable '${rightStr}' can be manipulated across transactions.`; + } else if (conditionExpr.includes("offset") && conditionExpr.includes("limit")) { + boundType = "paginated"; + isCapped = true; + } + } else if (cond.type === "BooleanLiteral" && cond.value === true) { + boundType = "unbounded"; + isCapped = false; + uncertaintyReason = "Infinite loop condition (while true)."; + } + } + + // Analyze operations inside loop body + let hasExternalCalls = false; + let externalCallsCount = 0; + let hasStateWrites = false; + let hasStorageDeletions = false; + let hasReturndataCopying = false; + let hasEventEmissions = false; + let hasBreakOrReturn = false; + + const loopBody = loopNode.body || loopNode.loopExpression || loopNode; + + visit(loopBody, { + FunctionCall: (callNode: any) => { + const callee = extractExpressionString(callNode.expression); + if ( + callee.endsWith(".call") || + callee.endsWith(".delegatecall") || + callee.endsWith(".staticcall") || + callee.endsWith(".transfer") || + callee.endsWith(".send") || + (callNode.expression?.type === "MemberAccess" && callNode.expression.expression?.type === "FunctionCall") + ) { + hasExternalCalls = true; + externalCallsCount++; + } else if (callNode.expression?.type === "MemberAccess") { + const member = callNode.expression.memberName; + if (member === "push" || member === "pop") { + hasStateWrites = true; + } + } + }, + BinaryOperation: (binNode: any) => { + if ( + binNode.operator === "=" || + binNode.operator === "+=" || + binNode.operator === "-=" || + binNode.operator === "*=" + ) { + hasStateWrites = true; + } + }, + UnaryOperation: (unNode: any) => { + if (unNode.operator === "delete") { + hasStorageDeletions = true; + hasStateWrites = true; + } else if (unNode.operator === "++" || unNode.operator === "--") { + hasStateWrites = true; + } + }, + EmitStatement: () => { + hasEventEmissions = true; + }, + BreakStatement: () => { + hasBreakOrReturn = true; + }, + ReturnStatement: () => { + hasBreakOrReturn = true; + }, + InlineAssemblyStatement: (asmNode: any) => { + const asmStr = JSON.stringify(asmNode); + if (asmStr.includes("returndatacopy") || asmStr.includes("returndatasize")) { + hasReturndataCopying = true; + } + if (asmStr.includes("call") || asmStr.includes("delegatecall") || asmStr.includes("staticcall")) { + hasExternalCalls = true; + externalCallsCount++; + } + if (asmStr.includes("sstore")) { + hasStateWrites = true; + } + }, + }); + + return { + loopType, + line, + conditionExpression: conditionExpr, + boundType, + boundExpression, + targetVariable, + isCapped, + maxIterationsEstimate, + uncertaintyReason, + hasExternalCalls, + externalCallsCount, + hasStateWrites, + hasStorageDeletions, + hasReturndataCopying, + hasEventEmissions, + hasBreakOrReturn, + associatedFunction: fnName, + associatedContract: contractName, + }; +} diff --git a/packages/core/src/dos/mitigation-detector.ts b/packages/core/src/dos/mitigation-detector.ts new file mode 100644 index 0000000..7dc600a --- /dev/null +++ b/packages/core/src/dos/mitigation-detector.ts @@ -0,0 +1,178 @@ +/** + * @packageDocumentation + * @chainproof/core — DoS & Gas-Griefing Mitigation Pattern Recognizer + */ + +import { visit } from "../ast/parser"; +import type { MitigationEvidence } from "./types"; + +export function detectMitigations( + contractNode: any, + contractName: string, + source: string, + _filePath: string, +): MitigationEvidence[] { + if ( + !source.includes("withdraw") && + !source.includes("claim") && + !source.includes("offset") && + !source.includes("cursor") && + !source.includes("try") && + !source.includes("require") + ) { + return []; + } + + const mitigations: MitigationEvidence[] = []; + + let hasPendingBalancesMapping = false; + let hasWithdrawFunction = false; + let withdrawFnLine = 1; + + // 1. Check for Pull Payment Pattern + visit(contractNode, { + StateVariableDeclaration: (decl: any) => { + for (const v of decl.variables || []) { + const name = v.name?.toLowerCase() || ""; + if ( + name.includes("pending") || + name.includes("withdrawal") || + name.includes("credit") || + name.includes("claimable") + ) { + if (v.typeName?.type === "Mapping") { + hasPendingBalancesMapping = true; + } + } + } + }, + FunctionDefinition: (fnNode: any) => { + const fnName = fnNode.name?.toLowerCase() || ""; + if ( + fnName === "withdraw" || + fnName === "claim" || + fnName === "claimreward" || + fnName === "claimrewards" || + fnName === "withdrawfunds" + ) { + hasWithdrawFunction = true; + withdrawFnLine = fnNode.loc?.start?.line || 1; + } + }, + }); + + if (hasPendingBalancesMapping && hasWithdrawFunction) { + mitigations.push({ + type: "pull_payment", + description: "Pull-over-push payment pattern recognized: separate withdrawal/claim mechanism with pending balance tracking.", + line: withdrawFnLine, + confidence: "high", + contract: contractName, + functionName: "withdraw", + }); + } + + // 2. Check per-function mitigations (Pagination, Capped Batches, Failure Isolation, Checkpoint State Machine) + visit(contractNode, { + FunctionDefinition: (fnNode: any) => { + const fnName = fnNode.name || (fnNode.isConstructor ? "constructor" : "fallback"); + const line = fnNode.loc?.start?.line || 1; + + // Extract parameter names + const rawParams = Array.isArray(fnNode.parameters) + ? fnNode.parameters + : fnNode.parameters?.parameters || []; + const paramNames = rawParams.map((p: any) => p.name?.toLowerCase() || ""); + + // Pagination check (offset + limit) + const hasOffset = paramNames.some((n: string) => n.includes("offset") || n.includes("cursor") || n.includes("start")); + const hasLimit = paramNames.some((n: string) => n.includes("limit") || n.includes("count") || n.includes("pagesize") || n.includes("max")); + + if (hasOffset && hasLimit) { + mitigations.push({ + type: "pagination", + description: `Pagination pattern recognized in function '${fnName}': using offset and limit parameters to bound iteration.`, + line, + confidence: "high", + contract: contractName, + functionName: fnName, + }); + } + + // Check try/catch (failure isolation) inside function + let hasTryCatch = false; + visit(fnNode, { + TryStatement: () => { + hasTryCatch = true; + }, + }); + + if (hasTryCatch) { + mitigations.push({ + type: "failure_isolation", + description: `Failure isolation recognized in function '${fnName}': external calls are wrapped in try/catch to prevent revert propagation.`, + line, + confidence: "high", + contract: contractName, + functionName: fnName, + }); + } + + // Check batch caps (require(recipients.length <= MAX)) + let hasBatchCap = false; + visit(fnNode, { + FunctionCall: (callNode: any) => { + const callee = callNode.expression?.name || callNode.expression?.memberName; + if (callee === "require") { + const cond = JSON.stringify(callNode.arguments); + if (cond.includes(".length") && (cond.includes("<=") || cond.includes("<"))) { + hasBatchCap = true; + } + } + }, + }); + + if (hasBatchCap) { + mitigations.push({ + type: "capped_batch", + description: `Capped batch pattern recognized in function '${fnName}': array length is bounded with explicit limit validation.`, + line, + confidence: "high", + contract: contractName, + functionName: fnName, + }); + } + + // Check checkpoint state machine pattern (cursor / lastProcessedIndex update) + let hasStateIndexUpdate = false; + visit(fnNode, { + BinaryOperation: (binNode: any) => { + const leftStr = JSON.stringify(binNode.left); + if ( + leftStr.includes("lastProcessed") || + leftStr.includes("nextIndex") || + leftStr.includes("currentIndex") || + leftStr.includes("cursor") + ) { + if (binNode.operator === "=" || binNode.operator === "+=") { + hasStateIndexUpdate = true; + } + } + }, + }); + + if (hasStateIndexUpdate) { + mitigations.push({ + type: "checkpoint_state_machine", + description: `Checkpoint state-machine pattern recognized in function '${fnName}': processes items in chunks and persists progress across transactions.`, + line, + confidence: "medium", + contract: contractName, + functionName: fnName, + }); + } + }, + }); + + return mitigations; +} diff --git a/packages/core/src/dos/rules.ts b/packages/core/src/dos/rules.ts new file mode 100644 index 0000000..d09fedc --- /dev/null +++ b/packages/core/src/dos/rules.ts @@ -0,0 +1,459 @@ +/** + * @packageDocumentation + * @chainproof/core — DoS, Gas-Griefing & Unbounded-Work Rules (CP-DOS-001 to CP-DOS-010) + */ + +import type { ASTNode } from "../types"; +import { visit, getSnippet } from "../ast/parser"; +import type { + DosFinding, + DosRuleId, + DosAnalysisOptions, + LoopBoundAnalysis, + CallFanOutAnalysis, + ArrayGrowthAnalysis, + MitigationEvidence, +} from "./types"; +import { extractLoopBounds } from "./loop-analyzer"; +import { extractCallFanOuts } from "./call-fanout"; +import { extractArrayGrowths } from "./growth-analyzer"; +import { detectMitigations } from "./mitigation-detector"; + +export function shouldRunDosRule(ruleId: DosRuleId, options?: DosAnalysisOptions): boolean { + if (options?.includeRules && options.includeRules.length > 0) { + return options.includeRules.includes(ruleId); + } + if (options?.excludeRules && options.excludeRules.length > 0) { + return !options.excludeRules.includes(ruleId); + } + return true; +} + +export function detectDosVulnerabilities( + ast: ASTNode, + source: string, + filePath: string, + options?: DosAnalysisOptions, +): DosFinding[] { + const allFindings: DosFinding[] = []; + const contracts: any[] = []; + + if (ast.children && Array.isArray(ast.children)) { + for (const child of ast.children) { + if (child.type === "ContractDefinition") { + contracts.push(child); + } + } + } else { + visit(ast, { + ContractDefinition: (contractNode: any) => { + contracts.push(contractNode); + }, + }); + } + + for (const contractNode of contracts) { + const contractName = contractNode.name || "Contract"; + const loops = extractLoopBounds(contractNode, contractName, source, filePath); + const calls = extractCallFanOuts(contractNode, contractName, source, filePath); + const growths = extractArrayGrowths(contractNode, contractName, source, filePath); + const mitigations = detectMitigations(contractNode, contractName, source, filePath); + + // Check CP-DOS-001: Unbounded Loop Iteration + if (shouldRunDosRule("CP-DOS-001", options)) { + allFindings.push(...checkUnboundedLoops(loops, mitigations, source, filePath)); + } + + // Check CP-DOS-002: Push-Payment Pattern + if (shouldRunDosRule("CP-DOS-002", options)) { + allFindings.push(...checkPushPayments(calls, mitigations, source, filePath)); + } + + // Check CP-DOS-003: External Call Fan-Out in Loop + if (shouldRunDosRule("CP-DOS-003", options)) { + allFindings.push(...checkCallFanOut(calls, mitigations, source, filePath)); + } + + // Check CP-DOS-004: Return Bomb / Returndata Griefing + if (shouldRunDosRule("CP-DOS-004", options)) { + allFindings.push(...checkReturnBombs(calls, loops, source, filePath)); + } + + // Check CP-DOS-005: Unbounded Storage Clearing / Mass Deletion + if (shouldRunDosRule("CP-DOS-005", options)) { + allFindings.push(...checkMassStorageDeletion(loops, source, filePath)); + } + + // Check CP-DOS-006: Insufficient Gas Forwarding (63/64th Rule) + if (shouldRunDosRule("CP-DOS-006", options)) { + allFindings.push(...checkInsufficientGasForwarding(calls, source, filePath)); + } + + // Check CP-DOS-007: Single-Transaction Block Gas Limit Deadlock + if (shouldRunDosRule("CP-DOS-007", options)) { + allFindings.push(...checkBlockGasLimitDeadlock(loops, mitigations, source, filePath)); + } + + // Check CP-DOS-008: Unbounded Recursion + if (shouldRunDosRule("CP-DOS-008", options)) { + allFindings.push(...checkUnboundedRecursion(contractNode, contractName, source, filePath)); + } + + // Check CP-DOS-009: Array Poisoning / Unconstrained Growth + if (shouldRunDosRule("CP-DOS-009", options)) { + allFindings.push(...checkArrayPoisoning(growths, source, filePath)); + } + + // Check CP-DOS-010: Revert Propagation in Batch Operations + if (shouldRunDosRule("CP-DOS-010", options)) { + allFindings.push(...checkBatchRevertPropagation(calls, loops, mitigations, source, filePath)); + } + } + + return allFindings; +} + +// ─── Rule Implementations ───────────────────────────────────────────────────── + +function checkUnboundedLoops( + loops: LoopBoundAnalysis[], + mitigations: MitigationEvidence[], + source: string, + filePath: string, +): DosFinding[] { + const findings: DosFinding[] = []; + const hasPagination = mitigations.some((m) => m.type === "pagination"); + + for (const loop of loops) { + if (loop.boundType === "storage_array_bounded" || loop.boundType === "unbounded") { + if (!loop.isCapped && !hasPagination) { + findings.push({ + id: "CP-DOS-001", + dosRuleId: "CP-DOS-001", + swcId: "SWC-128", + title: "Unbounded Loop Iteration Over Dynamic Storage Array", + description: `Function '${loop.associatedFunction}' contains a loop bounded by '${loop.boundExpression || "unbounded condition"}'. If the storage collection grows large, transaction gas will exceed the block gas limit (30M gas), causing permanent denial of service.`, + recommendation: "Implement pagination (offset and limit parameters) or process items in capped batches to ensure execution stays within block gas limits.", + severity: "high", + confidence: "high", + category: "denial_of_service", + boundType: loop.boundType, + uncertainty: loop.uncertaintyReason, + file: filePath, + line: loop.line, + snippet: getSnippet(source, loop.line), + }); + } + } + } + + return findings; +} + +function checkPushPayments( + calls: CallFanOutAnalysis[], + mitigations: MitigationEvidence[], + source: string, + filePath: string, +): DosFinding[] { + const findings: DosFinding[] = []; + const hasPullPayment = mitigations.some((m) => m.type === "pull_payment"); + + for (const call of calls) { + if (call.isPushPayment && call.isInsideLoop) { + if (!call.isWrappedInTryCatch && !hasPullPayment) { + findings.push({ + id: "CP-DOS-002", + dosRuleId: "CP-DOS-002", + swcId: "SWC-113", + title: "Push-Payment Pattern with Unexpected Revert Risk", + description: `Function '${call.associatedFunction}' sends funds to '${call.targetExpression}' inside a loop. If any recipient is a contract that rejects payments (fallback without payable or intentional revert), the entire transaction reverts, preventing all honest users from receiving funds.`, + recommendation: "Adopt the Pull-Payment pattern: credit user balances in an internal mapping and provide a separate withdraw() function for individual claims.", + severity: "high", + confidence: "high", + category: "denial_of_service", + file: filePath, + line: call.line, + snippet: getSnippet(source, call.line), + }); + } + } + } + + return findings; +} + +function checkCallFanOut( + calls: CallFanOutAnalysis[], + mitigations: MitigationEvidence[], + source: string, + filePath: string, +): DosFinding[] { + const findings: DosFinding[] = []; + const hasFailureIsolation = mitigations.some((m) => m.type === "failure_isolation"); + + for (const call of calls) { + if (call.isInsideLoop && (call.callType === "high_level" || call.callType === "low_level_call")) { + if (!call.isWrappedInTryCatch && !hasFailureIsolation && !call.isPushPayment) { + findings.push({ + id: "CP-DOS-003", + dosRuleId: "CP-DOS-003", + title: "External Call Fan-Out in Loop Iteration", + description: `Function '${call.associatedFunction}' makes repeated external calls to '${call.targetExpression}' inside a loop. External calls in loops create quadratic gas overhead and allow external contracts to grief execution.`, + recommendation: "Avoid fan-out calls in loops. Batch calls using pull architectures, or isolate individual call failures using try/catch blocks.", + severity: "medium", + confidence: "medium", + category: "gas_griefing", + file: filePath, + line: call.line, + snippet: getSnippet(source, call.line), + }); + } + } + } + + return findings; +} + +function checkReturnBombs( + calls: CallFanOutAnalysis[], + loops: LoopBoundAnalysis[], + source: string, + filePath: string, +): DosFinding[] { + const findings: DosFinding[] = []; + + for (const call of calls) { + if (call.callType === "low_level_call" && !call.hasReturndataSizeCheck && !call.hasGasLimit) { + findings.push({ + id: "CP-DOS-004", + dosRuleId: "CP-DOS-004", + title: "Return Bomb / Unbounded Returndata Memory Expansion", + description: `External low-level call to '${call.targetExpression}' does not cap returndata copying or check returndatasize. A malicious contract can return a massive byte payload, forcing quadratic memory expansion gas costs that exhaust caller gas.`, + recommendation: "Use excessivelySafeCall or inline assembly to limit returndatacopy to the expected response size (e.g. max 32 bytes).", + severity: "medium", + confidence: "medium", + category: "gas_griefing", + file: filePath, + line: call.line, + snippet: getSnippet(source, call.line), + }); + } + } + + return findings; +} + +function checkMassStorageDeletion( + loops: LoopBoundAnalysis[], + source: string, + filePath: string, +): DosFinding[] { + const findings: DosFinding[] = []; + + for (const loop of loops) { + if (loop.hasStorageDeletions && (loop.boundType === "storage_array_bounded" || loop.boundType === "unbounded")) { + findings.push({ + id: "CP-DOS-005", + dosRuleId: "CP-DOS-005", + title: "Unbounded Storage Clearing / Mass Deletion", + description: `Function '${loop.associatedFunction}' executes storage deletions ('delete') inside an unbounded loop. Because EIP-3529 limits gas refunds to at most 20% of tx gas, mass storage clearing can exceed the block gas limit and permanently brick state resets.`, + recommendation: "Use incremental/paginated clearing or epoch/generation counters instead of deleting dynamic storage in a single transaction.", + severity: "medium", + confidence: "high", + category: "unbounded_work", + file: filePath, + line: loop.line, + snippet: getSnippet(source, loop.line), + }); + } + } + + return findings; +} + +function checkInsufficientGasForwarding( + calls: CallFanOutAnalysis[], + source: string, + filePath: string, +): DosFinding[] { + const findings: DosFinding[] = []; + + for (const call of calls) { + if (call.callType === "low_level_call" && !call.hasGasLimit && call.isPushPayment) { + findings.push({ + id: "CP-DOS-006", + dosRuleId: "CP-DOS-006", + title: "Insufficient Gas Forwarding / 63/64th Rule Griefing", + description: `External call to '${call.targetExpression}' forwards all remaining gas subject to the 63/64th rule. A relayer or attacker can provide barely enough gas for the outer transaction, causing the inner call to fail silently or revert while consuming gas.`, + recommendation: "Specify an explicit gas stipend or verify gasleft() >= REQUIRED_GAS before dispatching critical sub-calls.", + severity: "medium", + confidence: "medium", + category: "gas_griefing", + file: filePath, + line: call.line, + snippet: getSnippet(source, call.line), + }); + } + } + + return findings; +} + +function checkBlockGasLimitDeadlock( + loops: LoopBoundAnalysis[], + mitigations: MitigationEvidence[], + source: string, + filePath: string, +): DosFinding[] { + const findings: DosFinding[] = []; + const hasCheckpoint = mitigations.some((m) => m.type === "checkpoint_state_machine"); + + for (const loop of loops) { + if (loop.hasExternalCalls && loop.hasStateWrites && !loop.isCapped && !hasCheckpoint) { + const fnLower = loop.associatedFunction.toLowerCase(); + if ( + fnLower.includes("execute") || + fnLower.includes("settle") || + fnLower.includes("liquidate") || + fnLower.includes("distribute") || + fnLower.includes("finalize") + ) { + findings.push({ + id: "CP-DOS-007", + dosRuleId: "CP-DOS-007", + title: "Single-Transaction Block Gas Limit Deadlock", + description: `Critical lifecycle function '${loop.associatedFunction}' requires completing all state mutations and external calls in a single transaction. If the batch size grows, the transaction will perpetually exceed the block gas limit, freezing the protocol state machine.`, + recommendation: "Implement checkpointed multi-transaction execution allowing permissionless partial progress.", + severity: "high", + confidence: "high", + category: "denial_of_service", + file: filePath, + line: loop.line, + snippet: getSnippet(source, loop.line), + }); + } + } + } + + return findings; +} + +function checkUnboundedRecursion( + contractNode: any, + _contractName: string, + source: string, + filePath: string, +): DosFinding[] { + const findings: DosFinding[] = []; + + visit(contractNode, { + FunctionDefinition: (fnNode: any) => { + const fnName = fnNode.name; + if (!fnName) return; + + let hasRecursiveCall = false; + let hasDepthGuard = false; + let recursiveCallLine = fnNode.loc?.start?.line || 1; + + visit(fnNode, { + FunctionCall: (callNode: any) => { + const callee = callNode.expression?.name; + if (callee === fnName) { + hasRecursiveCall = true; + recursiveCallLine = callNode.loc?.start?.line || recursiveCallLine; + } + if (callNode.expression?.name === "require") { + const cond = JSON.stringify(callNode.arguments); + if (cond.includes("depth") || cond.includes("level")) { + hasDepthGuard = true; + } + } + }, + }); + + if (hasRecursiveCall && !hasDepthGuard) { + findings.push({ + id: "CP-DOS-008", + dosRuleId: "CP-DOS-008", + swcId: "SWC-128", + title: "Unbounded Recursion Without Depth Guard", + description: `Function '${fnName}' makes recursive calls to itself without an explicit recursion depth limit or stack guard. An attacker can trigger deep recursion to exhaust the 1024 EVM call stack depth limit or run out of gas.`, + recommendation: "Convert recursion into an iterative loop with bounded iterations, or enforce an explicit depth counter: require(depth < MAX_DEPTH).", + severity: "high", + confidence: "high", + category: "denial_of_service", + file: filePath, + line: recursiveCallLine, + snippet: getSnippet(source, recursiveCallLine), + }); + } + }, + }); + + return findings; +} + +function checkArrayPoisoning( + growths: ArrayGrowthAnalysis[], + source: string, + filePath: string, +): DosFinding[] { + const findings: DosFinding[] = []; + + for (const g of growths) { + if (g.isPublicOrExternal && !g.hasAccessControl && !g.hasLengthCap && !g.hasRateLimitOrFee) { + if (g.isIteratedInContract) { + findings.push({ + id: "CP-DOS-009", + dosRuleId: "CP-DOS-009", + title: "Attacker-Controlled Array Growth / Storage Poisoning", + description: `Unrestricted public function '${g.associatedFunction}' pushes elements to array '${g.arrayName}' without access control, length caps, or fees. An attacker can cheaply spam entries to bloat '${g.arrayName}', causing subsequent iterations in '${g.iteratingFunctions.join(", ")}' to exceed the block gas limit.`, + recommendation: "Add length caps (require(arr.length < MAX)), deposit fees, or permissioned access to prevent unbounded array expansion.", + severity: "medium", + confidence: "high", + category: "denial_of_service", + file: filePath, + line: g.line, + snippet: getSnippet(source, g.line), + }); + } + } + } + + return findings; +} + +function checkBatchRevertPropagation( + calls: CallFanOutAnalysis[], + loops: LoopBoundAnalysis[], + mitigations: MitigationEvidence[], + source: string, + filePath: string, +): DosFinding[] { + const findings: DosFinding[] = []; + const hasFailureIsolation = mitigations.some((m) => m.type === "failure_isolation"); + + for (const loop of loops) { + if (loop.hasExternalCalls && !hasFailureIsolation) { + const fnLower = loop.associatedFunction.toLowerCase(); + if (fnLower.includes("batch") || fnLower.includes("multicall") || fnLower.includes("processall")) { + findings.push({ + id: "CP-DOS-010", + dosRuleId: "CP-DOS-010", + title: "Revert Propagation in Critical Batch Operation", + description: `Batch function '${loop.associatedFunction}' executes external operations in a loop without try/catch error isolation. A single failing sub-transaction causes the entire batch to revert, enabling griefing against other batched users.`, + recommendation: "Wrap batch item execution in try/catch blocks and record failed item IDs in an event or mapping instead of reverting the whole transaction.", + severity: "low", + confidence: "medium", + category: "gas_griefing", + file: filePath, + line: loop.line, + snippet: getSnippet(source, loop.line), + }); + } + } + } + + return findings; +} diff --git a/packages/core/src/dos/serialize.ts b/packages/core/src/dos/serialize.ts new file mode 100644 index 0000000..dc6668f --- /dev/null +++ b/packages/core/src/dos/serialize.ts @@ -0,0 +1,184 @@ +/** + * @packageDocumentation + * @chainproof/core — Deterministic Serialization & Reports for DoS Analysis + */ + +import chalk from "chalk"; +import type { + DosAuditReport, + LoopBoundAnalysis, + CallFanOutAnalysis, +} from "./types"; + +export function stableStringify(obj: unknown, space: number = 2): string { + function sortKeys(value: unknown): unknown { + if (value === null || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map(sortKeys); + } + const sortedObj: Record = {}; + const keys = Object.keys(value as Record).sort(); + for (const key of keys) { + sortedObj[key] = sortKeys((value as Record)[key]); + } + return sortedObj; + } + + return JSON.stringify(sortKeys(obj), null, space); +} + +export function serializeDosAuditJSON(report: DosAuditReport): string { + return stableStringify(report, 2); +} + +export function generateDosMarkdownReport(report: DosAuditReport): string { + const lines: string[] = []; + + lines.push("# ChainProof Denial-of-Service, Gas-Griefing & Unbounded-Work Report"); + lines.push(""); + lines.push(`**Generated at:** ${report.createdAt} `); + lines.push(`**Schema Version:** \`${report.schemaVersion}\` `); + lines.push(`**Audit Result:** ${report.summary.passed ? "✅ **PASSED**" : "❌ **FAILED (DoS Hazards Detected)**"} `); + lines.push(""); + + lines.push("## Executive Summary"); + lines.push(""); + lines.push(`- **Files Analyzed:** ${report.summary.totalFiles}`); + lines.push(`- **Contracts Evaluated:** ${report.summary.totalContracts}`); + lines.push(`- **Loops Inspected:** ${report.summary.totalLoopsAnalyzed}`); + lines.push(`- **Unbounded Loops Found:** ${report.summary.unboundedLoopsFound}`); + lines.push(`- **Push-Payment Risks:** ${report.summary.pushPaymentsFound}`); + lines.push(`- **Return Bomb Risks:** ${report.summary.returnBombRisksFound}`); + lines.push(`- **Call Fan-Out Hazards:** ${report.summary.callFanOutsFound}`); + lines.push(`- **Mass Storage Deletions:** ${report.summary.storageClearingFound}`); + lines.push(`- **Array Poisoning Endpoints:** ${report.summary.arrayGrowthPointsFound}`); + lines.push(`- **Mitigations Recognized:** ${report.summary.mitigationsRecognized}`); + lines.push(""); + + lines.push("### Severity Breakdown"); + lines.push(""); + lines.push(`- 🔴 **Critical:** ${report.summary.findingsCount.critical}`); + lines.push(`- 🟠 **High:** ${report.summary.findingsCount.high}`); + lines.push(`- 🟡 **Medium:** ${report.summary.findingsCount.medium}`); + lines.push(`- 🔵 **Low:** ${report.summary.findingsCount.low}`); + lines.push(`- ⚪ **Info:** ${report.summary.findingsCount.info}`); + lines.push(""); + + if (report.mitigations.length > 0) { + lines.push("## Mitigations Recognized"); + lines.push(""); + lines.push("| Pattern | Contract | Function | Line | Description |"); + lines.push("| --- | --- | --- | --- | --- |"); + for (const m of report.mitigations) { + lines.push(`| \`${m.type}\` | \`${m.contract}\` | \`${m.functionName || "N/A"}\` | ${m.line} | ${m.description} |`); + } + lines.push(""); + } + + lines.push("## Findings Catalog"); + lines.push(""); + if (report.findings.length === 0) { + lines.push("✅ *No Denial-of-Service or Gas-Griefing vulnerabilities detected.*"); + } else { + for (const f of report.findings) { + const icon = + f.severity === "critical" || f.severity === "high" + ? "🚨" + : f.severity === "medium" + ? "⚠️" + : "ℹ️"; + + lines.push(`### ${icon} [${f.severity.toUpperCase()}] ${f.id} — ${f.title}`); + lines.push(""); + lines.push(`- **Location:** \`${f.file}:${f.line}\``); + lines.push(`- **Category:** \`${f.category}\``); + lines.push(`- **Confidence:** \`${f.confidence}\``); + if (f.boundType) { + lines.push(`- **Bound Type:** \`${f.boundType}\``); + } + lines.push(""); + lines.push(`**Description:** ${f.description}`); + lines.push(""); + lines.push(`**Recommendation:** ${f.recommendation}`); + lines.push(""); + if (f.snippet) { + lines.push("```solidity"); + lines.push(f.snippet); + lines.push("```"); + lines.push(""); + } + } + } + + return lines.join("\n"); +} + +export function generateDosTableReport(report: DosAuditReport): string { + const lines: string[] = []; + + lines.push(chalk.bold("\n ChainProof Denial-of-Service & Unbounded-Work Report\n")); + lines.push( + chalk.gray( + ` Files: ${report.summary.totalFiles} | Contracts: ${report.summary.totalContracts} | Loops: ${report.summary.totalLoopsAnalyzed}\n` + + ` Unbounded Loops: ${report.summary.unboundedLoopsFound} | Push Payments: ${report.summary.pushPaymentsFound} | Return Bombs: ${report.summary.returnBombRisksFound}\n` + + ` Mitigations Recognized: ${chalk.green(report.summary.mitigationsRecognized)}\n`, + ), + ); + + if (report.findings.length === 0) { + lines.push(chalk.green.bold(" ✅ PASS — No DoS or gas-griefing hazards detected.\n")); + } else { + lines.push(chalk.bold(" Findings Summary:")); + for (const f of report.findings) { + const color = + f.severity === "critical" || f.severity === "high" + ? chalk.red + : f.severity === "medium" + ? chalk.yellow + : chalk.blue; + lines.push(` ${color(`[${f.severity.toUpperCase()}]`)} ${chalk.cyan(f.id)} ${f.file}:${f.line} — ${f.title}`); + } + lines.push(""); + if (report.summary.passed) { + lines.push(chalk.green.bold(` ✅ PASS (No critical/high hazards) — ${report.findings.length} finding(s).\n`)); + } else { + lines.push(chalk.red.bold(` ❌ FAIL — ${report.findings.length} DoS hazard(s) detected.\n`)); + } + } + + return lines.join("\n"); +} + +export function generateDosLoopsMarkdown(loops: LoopBoundAnalysis[]): string { + const lines: string[] = []; + lines.push("# Loop Bounds and Complexity Inspection"); + lines.push(""); + lines.push("| Contract | Function | Line | Loop Type | Bound Type | Capped | Ext Calls | Writes | Deletions |"); + lines.push("| --- | --- | --- | --- | --- | --- | --- | --- | --- |"); + + for (const l of loops) { + lines.push( + `| \`${l.associatedContract}\` | \`${l.associatedFunction}\` | ${l.line} | \`${l.loopType}\` | \`${l.boundType}\` | ${l.isCapped ? "✅ Yes" : "❌ No"} | ${l.hasExternalCalls ? `⚠️ ${l.externalCallsCount}` : "0"} | ${l.hasStateWrites ? "Yes" : "No"} | ${l.hasStorageDeletions ? "⚠️ Yes" : "No"} |`, + ); + } + + return lines.join("\n"); +} + +export function generateDosFanoutMarkdown(calls: CallFanOutAnalysis[]): string { + const lines: string[] = []; + lines.push("# External Call Fan-Out & Payment Inspection"); + lines.push(""); + lines.push("| Contract | Function | Line | Call Type | In Loop | Push Payment | Try/Catch | Gas Limit |"); + lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |"); + + for (const c of calls) { + lines.push( + `| \`${c.associatedContract}\` | \`${c.associatedFunction}\` | ${c.line} | \`${c.callType}\` | ${c.isInsideLoop ? "⚠️ Yes" : "No"} | ${c.isPushPayment ? "⚠️ Yes" : "No"} | ${c.isWrappedInTryCatch ? "✅ Yes" : "No"} | ${c.hasGasLimit ? `✅ ${c.gasLimitExpression}` : "❌ Full (63/64)"} |`, + ); + } + + return lines.join("\n"); +} diff --git a/packages/core/src/dos/types.ts b/packages/core/src/dos/types.ts new file mode 100644 index 0000000..ba3e9e3 --- /dev/null +++ b/packages/core/src/dos/types.ts @@ -0,0 +1,233 @@ +/** + * @packageDocumentation + * @chainproof/core — Denial-of-Service, Gas-Griefing & Unbounded-Work Analysis Types + */ + +import type { ASTNode, Finding, Severity } from "../types"; + +export const DOS_ANALYSIS_SCHEMA_VERSION = "1.0.0"; +export const DOS_CONFIG_SCHEMA_VERSION = 1; + +export type DosSeverity = Severity; +export type DosConfidence = "high" | "medium" | "low"; + +export type DosRuleId = + | "CP-DOS-001" // Unbounded Loop Iteration Over Dynamic Storage Array + | "CP-DOS-002" // Push-Payment Pattern with Unexpected Revert Risk + | "CP-DOS-003" // External Call Fan-Out in Loop Iteration + | "CP-DOS-004" // Return Bomb / Unbounded Returndata Memory Expansion + | "CP-DOS-005" // Unbounded Storage Clearing / Mass Deletion + | "CP-DOS-006" // Insufficient Gas Forwarding / 63/64th Rule Griefing + | "CP-DOS-007" // Single-Transaction Block Gas Limit Deadlock + | "CP-DOS-008" // Unbounded Recursion Without Depth Guard + | "CP-DOS-009" // Attacker-Controlled Array Growth / Storage Poisoning + | "CP-DOS-010"; // Revert Propagation in Critical Batch Operation + +export type LoopBoundType = + | "constant_bounded" + | "parameter_bounded" + | "storage_array_bounded" + | "state_variable_bounded" + | "paginated" + | "unbounded" + | "unknown"; + +export type MitigationType = + | "pagination" + | "pull_payment" + | "capped_batch" + | "failure_isolation" + | "gas_stipend_guard" + | "checkpoint_state_machine" + | "rate_limited_growth" + | "depth_guard"; + +export interface LoopBoundAnalysis { + loopType: "for" | "while" | "do-while"; + line: number; + conditionExpression: string; + boundType: LoopBoundType; + boundExpression?: string; + targetVariable?: string; + isCapped: boolean; + maxIterationsEstimate?: number; + uncertaintyReason?: string; + hasExternalCalls: boolean; + externalCallsCount: number; + hasStateWrites: boolean; + hasStorageDeletions: boolean; + hasReturndataCopying: boolean; + hasEventEmissions: boolean; + hasBreakOrReturn: boolean; + associatedFunction: string; + associatedContract: string; +} + +export interface CallFanOutAnalysis { + line: number; + callType: "value_transfer" | "high_level" | "low_level_call" | "delegatecall" | "staticcall"; + targetExpression: string; + valueExpression?: string; + isInsideLoop: boolean; + loopLine?: number; + hasRevertCheck: boolean; + isWrappedInTryCatch: boolean; + hasGasLimit: boolean; + gasLimitExpression?: string; + hasReturndataSizeCheck: boolean; + isPushPayment: boolean; + associatedFunction: string; + associatedContract: string; +} + +export interface ArrayGrowthAnalysis { + line: number; + arrayName: string; + arrayType: string; + pushExpression: string; + isPublicOrExternal: boolean; + hasAccessControl: boolean; + hasRateLimitOrFee: boolean; + hasLengthCap: boolean; + associatedFunction: string; + associatedContract: string; + isIteratedInContract: boolean; + iteratingFunctions: string[]; +} + +export interface MitigationEvidence { + type: MitigationType; + description: string; + line: number; + confidence: DosConfidence; + contract: string; + functionName?: string; +} + +export interface DosEvidencePath { + file: string; + line: number; + column?: number; + message: string; + snippet?: string; +} + +export interface DosFinding extends Finding { + dosRuleId: DosRuleId; + confidence: DosConfidence; + category: "denial_of_service" | "gas_griefing" | "unbounded_work"; + boundType?: LoopBoundType; + evidencePaths?: DosEvidencePath[]; + mitigationsApplied?: MitigationType[]; + uncertainty?: string; +} + +export interface DosContractReport { + contractName: string; + file: string; + totalLoops: number; + unboundedLoops: number; + externalCallsInLoops: number; + pushPaymentPatterns: number; + returnBombRisks: number; + growthEndpoints: number; + loops: LoopBoundAnalysis[]; + callFanOuts: CallFanOutAnalysis[]; + arrayGrowths: ArrayGrowthAnalysis[]; + mitigations: MitigationEvidence[]; + findings: DosFinding[]; +} + +export interface DosFileReport { + file: string; + contracts: DosContractReport[]; + findings: DosFinding[]; +} + +export interface DosAuditSummary { + totalFiles: number; + totalContracts: number; + totalLoopsAnalyzed: number; + unboundedLoopsFound: number; + pushPaymentsFound: number; + returnBombRisksFound: number; + callFanOutsFound: number; + storageClearingFound: number; + arrayGrowthPointsFound: number; + mitigationsRecognized: number; + findingsCount: { + critical: number; + high: number; + medium: number; + low: number; + info: number; + gas: number; + }; + passed: boolean; +} + +export interface DosAuditReport { + schemaVersion: string; + createdAt: string; + summary: DosAuditSummary; + files: DosFileReport[]; + findings: DosFinding[]; + mitigations: MitigationEvidence[]; +} + +export interface DosAnalysisLimits { + maxFiles: number; + maxSourceBytes: number; + maxContracts: number; + maxLoops: number; + maxFindings: number; + timeoutMs: number; +} + +export interface DosCancellationSignal { + isCancelled: () => boolean; +} + +export interface DosAnalysisOptions { + config?: ValidatedDosConfig; + limits?: Partial; + signal?: DosCancellationSignal; + includeRules?: DosRuleId[]; + excludeRules?: DosRuleId[]; + minSeverity?: Severity; + minConfidence?: DosConfidence; +} + +export interface DosSourceInput { + file: string; + content: string; + ast?: ASTNode; +} + +export interface DosConfigV0 { + version: 0; + maxFiles?: number; + maxSourceSize?: number; + includeRules?: string[]; + excludeRules?: string[]; +} + +export interface DosConfigV1 { + version: 1; + includeRules?: DosRuleId[]; + excludeRules?: DosRuleId[]; + minSeverity?: Severity; + minConfidence?: DosConfidence; + limits?: Partial; +} + +export type DosConfigInput = DosConfigV0 | DosConfigV1; + +export interface ValidatedDosConfig { + version: 1; + includeRules: DosRuleId[]; + excludeRules: DosRuleId[]; + minSeverity: Severity; + minConfidence: DosConfidence; + limits: DosAnalysisLimits; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3d002a1..00c4e67 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -215,6 +215,9 @@ export * from "./governance"; // ─── Cross-chain bridge and message verification analysis ─────────────────── export * from "./bridge"; + +// ─── Denial-of-Service, Gas-Griefing & Unbounded-Work Analysis ───────────── +export * from "./dos"; export type { ParseSpecResult, MigrationResult, diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index 67bef5b..04061e8 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -31,6 +31,7 @@ import { detectCallbackReentrancy } from "./rules/callback-analysis"; import { detectStakingAccounting } from "./staking"; import { detectGovernanceSafety } from "./governance"; import { detectBridgeSafety } from "./bridge"; +import { detectDosVulnerabilities } from "./dos"; import { RuleOptions } from "./rules/rule-context"; import { detectGasIssues } from "./rules/gas-optimizer"; import { enhanceFindingsWithLLM } from "./llm/enhancer"; @@ -188,6 +189,9 @@ async function scanFile( // Bridge analysis runs once per physical file, similar to governance and staking. findings.push(...detectBridgeSafety(ast, source, filePath)); + // DoS and Unbounded Work analysis runs once per physical file. + findings.push(...detectDosVulnerabilities(ast, source, filePath)); + if (config.plugins) { for (const plugin of config.plugins) { for (const rule of plugin.rules) { diff --git a/packages/server/openapi.yaml b/packages/server/openapi.yaml index 07e839c..7eaed84 100644 --- a/packages/server/openapi.yaml +++ b/packages/server/openapi.yaml @@ -412,6 +412,70 @@ paths: schema: $ref: "#/components/schemas/Error" + /dos/inspect-loops: + post: + tags: [DoS] + summary: Inspect Solidity loops, termination conditions, and storage array bounds + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [files] + properties: + files: + type: array + items: + type: object + required: [content] + properties: + file: + type: string + content: + type: string + responses: + "200": + description: Loop analysis array + "400": + description: Invalid request body + + /dos/fanout: + post: + tags: [DoS] + summary: Inspect external call fan-out and payment patterns + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [files] + properties: + files: + type: array + responses: + "200": + description: Call fan-out analysis array + + /dos/audit: + post: + tags: [DoS] + summary: Run complete Denial-of-Service, gas-griefing, and unbounded-work audit + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [files] + properties: + files: + type: array + responses: + "200": + description: DoS audit report + tags: - name: System description: Health and liveness endpoints @@ -419,3 +483,6 @@ tags: description: Smart contract scanning endpoints - name: Rules description: Rule metadata endpoints + - name: DoS + description: Denial-of-Service, gas-griefing, and unbounded-work analysis endpoints + diff --git a/packages/server/src/routes/dos.ts b/packages/server/src/routes/dos.ts new file mode 100644 index 0000000..773bb7d --- /dev/null +++ b/packages/server/src/routes/dos.ts @@ -0,0 +1,95 @@ +/** + * @packageDocumentation + * @chainproof/server — Denial-of-Service & Unbounded Work Routes + */ + +import { Router, Request, Response } from "express"; +import { + auditDosSafety, + inspectDosLoops, + inspectDosCallFanOut, + DosConfigError, +} from "@chainproof/core"; +import type { DosSourceInput, DosAnalysisOptions } from "@chainproof/core"; + +const router = Router(); + +interface DosSourcePayload { + path?: string; + file?: string; + content: string; +} + +function normalizeSources(rawFiles: unknown): DosSourceInput[] { + if (!Array.isArray(rawFiles) || rawFiles.length === 0) { + throw new DosConfigError("Missing required field: files (array of { path/file, content })"); + } + + return rawFiles.map((f: DosSourcePayload, idx: number) => { + const file = f.file || f.path || `Source_${idx + 1}.sol`; + if (typeof f.content !== "string") { + throw new DosConfigError(`File "${file}" missing valid string content.`); + } + return { + file, + content: f.content, + }; + }); +} + +// ─── POST /dos/inspect-loops ────────────────────────────────────────────────── + +router.post("/inspect-loops", (req: Request, res: Response): void => { + try { + const sources = normalizeSources(req.body?.files); + const options: DosAnalysisOptions = { + config: req.body?.config, + }; + const loops = inspectDosLoops(sources, options); + res.json(loops); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status = err instanceof DosConfigError ? 400 : 500; + res.status(status).json({ error: message }); + } +}); + +// ─── POST /dos/fanout ───────────────────────────────────────────────────────── + +router.post("/fanout", (req: Request, res: Response): void => { + try { + const sources = normalizeSources(req.body?.files); + const options: DosAnalysisOptions = { + config: req.body?.config, + }; + const calls = inspectDosCallFanOut(sources, options); + res.json(calls); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status = err instanceof DosConfigError ? 400 : 500; + res.status(status).json({ error: message }); + } +}); + +// ─── POST /dos/audit ────────────────────────────────────────────────────────── + +router.post("/audit", async (req: Request, res: Response): Promise => { + try { + const sources = normalizeSources(req.body?.files); + const options: DosAnalysisOptions = { + includeRules: req.body?.includeRules, + excludeRules: req.body?.excludeRules, + minSeverity: req.body?.minSeverity, + minConfidence: req.body?.minConfidence, + config: req.body?.config, + }; + const report = await auditDosSafety(sources, options); + res.json(report); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status = err instanceof DosConfigError ? 400 : 500; + res.status(status).json({ error: message }); + } +}); + +export default router; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 7272d2d..6b1884e 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 dosRouter from "./routes/dos"; // ─── 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("/dos", dosRouter); // ── 404 handler ────────────────────────────────────────────────────────── app.use((_req, res) => {