diff --git a/docs/bridge-safety.md b/docs/bridge-safety.md new file mode 100644 index 0000000..6c1c3cf --- /dev/null +++ b/docs/bridge-safety.md @@ -0,0 +1,78 @@ +# Cross-Chain Bridge and Message Verification Safety + +ChainProof's bridge analysis engine (`CP-BRG-001` through `CP-BRG-016`) models cross-chain bridges and message verification contracts for structural security vulnerabilities. + +## Threat model + +The analyzer assumes: + +- Relayers, bridges, or transport layers may redeliver messages +- Validator signatures may be duplicated, unsorted, or include zero addresses +- Message payloads can be attacker-controlled +- Source chain reorgs and optimistic fraud are in scope + +The analyzer does **not** prove transport-layer authenticity, oracle correctness, or live-network finality. + +## Rules + +| Rule | Category | Description | +|------|----------|-------------| +| CP-BRG-001 | domain-separation | Missing source chain binding on inbound messages | +| CP-BRG-002 | domain-separation | Missing destination binding on outbound messages | +| CP-BRG-003 | replay-protection | Replayable messages without nonce/ID consumption | +| CP-BRG-004 | replay-protection | Weak nonce management | +| CP-BRG-005 | validator-governance | Unsafe validator/threshold updates | +| CP-BRG-006 | verification | Proof/signature verification bypass | +| CP-BRG-007 | verification | Duplicate validators in proof loop | +| CP-BRG-008 | verification | Unsorted validator set | +| CP-BRG-009 | verification | Zero-address validator not rejected | +| CP-BRG-010 | verification | Stale Merkle/state root acceptance | +| CP-BRG-011 | validator-governance | Unsafe quorum arithmetic | +| CP-BRG-012 | payload-execution | Unvalidated payload arbitrary execution | +| CP-BRG-013 | token-bridge | Mint without verified lock | +| CP-BRG-014 | token-bridge | Release without verified burn | +| CP-BRG-015 | finality | Missing finality/challenge window | +| CP-BRG-016 | operational-safety | Missing pause/rate-limit mitigations | + +## Usage + +### CLI + +```bash +chainproof bridge contracts/bridge/ --format markdown +chainproof bridge contracts/ --include-rule CP-BRG-003 --fail-on critical +``` + +### API + +```typescript +import { analyzeBridgeSource, analyzeBridgeFiles } from '@chainproof/core'; + +const report = analyzeBridgeSource(source, 'Bridge.sol'); +const files = analyzeBridgeFiles(['contracts/bridge/'], { includeModels: true }); +``` + +## Configuration + +Versioned configuration schema (`schemaVersion: 1`): + +```json +{ + "schemaVersion": 1, + "limits": { "maxFindings": 512 }, + "includeRules": ["CP-BRG-001", "CP-BRG-003"], + "excludeRules": ["CP-BRG-016"] +} +``` + +## Limitations + +- Static analysis only; no live-network monitoring +- Does not duplicate AI multi-contract analysis (#61) +- Framework adapters provide hints, not proofs of correctness + +## Troubleshooting + +- **No findings on obvious bridge**: Ensure the contract contains bridge signals (e.g. `receiveMessage`, `processedMessages`, `sourceChainId`) +- **False positives on trusted relayer paths**: Use `--exclude-rule` or document relayer authentication in code comments +- **Truncated output**: Increase `maxFindings` in configuration diff --git a/examples/contracts/bridge/SecureBurnReleaseBridge.sol b/examples/contracts/bridge/SecureBurnReleaseBridge.sol new file mode 100644 index 0000000..20ebf34 --- /dev/null +++ b/examples/contracts/bridge/SecureBurnReleaseBridge.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @notice Secure burn-release bridge with proof verification and finality window. +contract SecureBurnReleaseBridge { + mapping(bytes32 => bool) public processedMessages; + mapping(bytes32 => uint256) public messageReceivedAt; + uint256 public totalBurned; + uint256 public totalReleased; + bytes32 public merkleRoot; + uint256 public rootUpdatedAt; + uint256 public finalityWindow = 86400; + address public token; + + function receiveMessage(bytes32 messageId, bytes32 root, bytes calldata proof) external { + require(root == merkleRoot, "stale root"); + require(block.timestamp >= rootUpdatedAt, "root not ready"); + require(!processedMessages[messageId], "replay"); + verifyProof(proof); + processedMessages[messageId] = true; + messageReceivedAt[messageId] = block.timestamp; + } + + function releaseTokens(bytes32 messageId, address to, uint256 amount) external { + require(processedMessages[messageId], "not received"); + require(block.timestamp >= messageReceivedAt[messageId] + finalityWindow, "finality"); + require(amount <= totalBurned - totalReleased, "exceeds burn"); + (bool ok,) = token.call(abi.encodeWithSignature("transfer(address,uint256)", to, amount)); + require(ok); + totalReleased += amount; + } + + function burnTokens(uint256 amount) external { + totalBurned += amount; + } + + function verifyProof(bytes calldata proof) internal view { + require(proof.length > 0, "empty proof"); + require(block.timestamp >= rootUpdatedAt, "root timestamp"); + merkleRoot; + } + + function updateRoot(bytes32 newRoot) external { + merkleRoot = newRoot; + rootUpdatedAt = block.timestamp; + } +} diff --git a/examples/contracts/bridge/SecureLockMintBridge.sol b/examples/contracts/bridge/SecureLockMintBridge.sol new file mode 100644 index 0000000..972d3f8 --- /dev/null +++ b/examples/contracts/bridge/SecureLockMintBridge.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IMintable { + function mint(address to, uint256 amount) external; +} + +/// @notice Secure lock-mint bridge with domain binding, replay protection, and lock verification. +contract SecureLockMintBridge { + mapping(bytes32 => bool) public processedMessages; + mapping(uint256 => uint256) public inboundNonce; + uint256 public totalLocked; + uint256 public totalMinted; + IMintable public wrappedToken; + address public trustedRelayer; + uint256 public sourceChainId; + uint256 public validatorThreshold; + bool public paused; + + constructor(address token, address relayer, uint256 _sourceChain) { + wrappedToken = IMintable(token); + trustedRelayer = relayer; + sourceChainId = _sourceChain; + validatorThreshold = 3; + } + + modifier whenNotPaused() { + require(!paused, "paused"); + _; + } + + function lockTokens(address, uint256 amount) external { + totalLocked += amount; + } + + function mintTokens(address to, uint256 amount) external whenNotPaused { + require(amount <= totalLocked - totalMinted, "exceeds lock"); + wrappedToken.mint(to, amount); + totalMinted += amount; + } + + function receiveMessage( + bytes32 messageId, + uint256 originChain, + uint256 nonce, + address to, + uint256 amount + ) external whenNotPaused { + require(msg.sender == trustedRelayer, "untrusted"); + require(originChain == sourceChainId, "wrong source"); + require(!processedMessages[messageId], "replay"); + require(nonce == inboundNonce[originChain] + 1, "bad nonce"); + require(amount <= totalLocked - totalMinted, "exceeds lock"); + + processedMessages[messageId] = true; + inboundNonce[originChain] = nonce; + + mintTokens(to, amount); + } + + function pause() external { + paused = true; + } +} diff --git a/examples/contracts/bridge/VulnerableBurnReleaseBridge.sol b/examples/contracts/bridge/VulnerableBurnReleaseBridge.sol new file mode 100644 index 0000000..2710e5b --- /dev/null +++ b/examples/contracts/bridge/VulnerableBurnReleaseBridge.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @notice Vulnerable burn-release bridge without burn verification and with weak validator loop. +contract VulnerableBurnReleaseBridge { + mapping(bytes32 => bool) public executedMessages; + uint256 public totalBurned; + uint256 public totalReleased; + address public token; + + function releaseTokens(bytes32 messageId, address to, uint256 amount) external { + messageId; + (bool ok,) = token.call(abi.encodeWithSignature("transfer(address,uint256)", to, amount)); + require(ok); + totalReleased += amount; + } + + function verifyValidators(address[] calldata validators, bytes[] calldata sigs) external pure { + for (uint256 i = 0; i < sigs.length; i++) { + validators[i]; + } + } + + function sendMessage(uint256 destChainId, bytes calldata payload) external { + destChainId; + payload; + } +} diff --git a/examples/contracts/bridge/VulnerableLockMintBridge.sol b/examples/contracts/bridge/VulnerableLockMintBridge.sol new file mode 100644 index 0000000..3ec90a7 --- /dev/null +++ b/examples/contracts/bridge/VulnerableLockMintBridge.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IMintable { + function mint(address to, uint256 amount) external; +} + +/// @notice Vulnerable lock-mint bridge lacking replay protection, source binding, and lock verification. +contract VulnerableLockMintBridge { + mapping(bytes32 => bool) public processedMessages; + uint256 public totalMinted; + IMintable public wrappedToken; + address public relayer; + + constructor(address token, address _relayer) { + wrappedToken = IMintable(token); + relayer = _relayer; + } + + function receiveMessage(bytes32 messageId, address to, uint256 amount, bytes calldata) external { + require(msg.sender == relayer, "untrusted"); + wrappedToken.mint(to, amount); + totalMinted += amount; + } + + function executeMessage(bytes32, address target, bytes calldata data) external { + (bool ok,) = target.call(data); + require(ok); + } + + function updateThreshold(uint256 newThreshold) external { + // no bounds check, instant update + newThreshold; + } + + function verifySignatures(address[] calldata signers, bytes[] calldata) external pure returns (bool) { + uint256 count; + for (uint256 i = 0; i < signers.length; i++) { + count++; + } + return count >= 1; + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f1d07dc..705ca2c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -30,6 +30,7 @@ import { registerWatchCommand } from "./commands/watch"; import { registerInvariantsCommand } from "./commands/invariants"; import { registerStakingCommand } from "./commands/staking"; import { registerGovernanceCommand } from "./commands/governance"; +import { registerBridgeCommand } from "./commands/bridge"; // ─── ASCII Banner ───────────────────────────────────────────────────────────── @@ -631,5 +632,6 @@ registerWatchCommand(program, printBanner); registerInvariantsCommand(program, printBanner); registerStakingCommand(program); registerGovernanceCommand(program, printBanner); +registerBridgeCommand(program, printBanner); program.parse(); diff --git a/packages/cli/src/commands/bridge.ts b/packages/cli/src/commands/bridge.ts new file mode 100644 index 0000000..933040b --- /dev/null +++ b/packages/cli/src/commands/bridge.ts @@ -0,0 +1,186 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import * as fs from "fs"; +import { + analyzeBridgeFiles, + generateBridgeMarkdown, + BridgeAnalysisCancelledError, + BridgeConfigError, + loadBridgeConfigFile, + serializeBridgeReport, +} from "@chainproof/core"; +import type { + BridgeAnalysisLimits, + BridgeAnalysisOptions, + BridgeAnalysisReport, + BridgeRuleId, +} from "@chainproof/core"; + +type OutputFormat = "json" | "markdown"; +type FailSeverity = "none" | "info" | "low" | "medium" | "high" | "critical"; + +interface BridgeCliOptions { + format: OutputFormat; + output?: string; + config?: string; + includeModels?: boolean; + includeRule: string[]; + excludeRule: string[]; + maxSourceBytes?: number; + maxFiles?: number; + maxContracts?: number; + maxFunctions?: number; + maxOperations?: number; + maxFindings?: number; + failOn: FailSeverity; +} + +const RULE_PATTERN = /^CP-BRG-(?:00[1-9]|01[0-6])$/; +const SEVERITY_RANK: Record = { + none: 99, + info: 1, + low: 2, + medium: 3, + high: 4, + critical: 5, +}; + +export function registerBridgeCommand(program: Command, printBanner: () => void): void { + program + .command("bridge ") + .description("Analyze cross-chain bridge and message verification safety") + .option("--format ", "Output format: json|markdown", "markdown") + .option("--output ", "Write the report to a file") + .option("--config ", "Load a versioned bridge analysis configuration") + .option("--include-models", "Include the normalized bridge model in JSON output") + .option("--include-rule ", "Only run a rule (repeatable)", collect, []) + .option("--exclude-rule ", "Skip a rule (repeatable)", collect, []) + .option("--max-source-bytes ", "Maximum bytes per Solidity source", positiveInteger) + .option("--max-files ", "Maximum number of Solidity files", positiveInteger) + .option("--max-contracts ", "Maximum contracts per Solidity file", positiveInteger) + .option("--max-functions ", "Maximum functions per file and contract", positiveInteger) + .option("--max-operations ", "Maximum modeled operations per function", positiveInteger) + .option("--max-findings ", "Maximum findings in the report", positiveInteger) + .option( + "--fail-on ", + "Exit 1 when this severity or higher is present: none|info|low|medium|high|critical", + "high", + ) + .action((targets: string[], raw: BridgeCliOptions) => { + const json = raw.format === "json"; + if (!json && raw.format === "markdown") printBanner(); + try { + validateFormat(raw.format); + validateFailSeverity(raw.failOn); + const configured = raw.config ? loadBridgeConfigFile(raw.config) : undefined; + const includeRules = raw.includeRule.length + ? validateRules(raw.includeRule, "--include-rule") + : configured?.config.includeRules; + const excludeRules = raw.excludeRule.length + ? validateRules(raw.excludeRule, "--exclude-rule") + : configured?.config.excludeRules; + rejectOverlap(includeRules, excludeRules); + const limits: Partial = { + ...configured?.config.limits, + ...(raw.maxSourceBytes ? { maxSourceBytes: raw.maxSourceBytes } : {}), + ...(raw.maxFiles ? { maxFiles: raw.maxFiles } : {}), + ...(raw.maxContracts ? { maxContracts: raw.maxContracts } : {}), + ...(raw.maxFunctions ? { + maxFunctionsPerFile: raw.maxFunctions, + maxFunctionsPerContract: raw.maxFunctions, + } : {}), + ...(raw.maxOperations ? { maxOperationsPerFunction: raw.maxOperations } : {}), + ...(raw.maxFindings ? { maxFindings: raw.maxFindings } : {}), + }; + const options: BridgeAnalysisOptions = { + limits, + includeModels: raw.includeModels ?? configured?.config.includeModels ?? false, + ...(includeRules ? { includeRules } : {}), + ...(excludeRules ? { excludeRules } : {}), + }; + const report = analyzeBridgeFiles(targets, options); + const output = raw.format === "json" + ? serializeBridgeReport(report) + : generateBridgeMarkdown(report); + if (raw.output) { + writeReport(raw.output, output); + if (!json) console.log(chalk.green(`\n Bridge report written to ${raw.output}`)); + } else { + process.stdout.write(output); + } + process.exit(exitCode(report, raw.failOn)); + } catch (error) { + const message = error instanceof BridgeConfigError || + error instanceof BridgeAnalysisCancelledError || error instanceof Error + ? error.message + : "Bridge analysis failed"; + console.error(chalk.red(`Bridge analysis error: ${sanitize(message)}`)); + process.exit(2); + } + }); +} + +function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +function positiveInteger(value: string): number { + if (!/^\d+$/.test(value)) throw new BridgeConfigError("analysis limits must be positive integers"); + const result = Number(value); + if (!Number.isSafeInteger(result) || result <= 0) { + throw new BridgeConfigError("analysis limits must be positive safe integers"); + } + return result; +} + +function validateRules(values: string[], option: string): BridgeRuleId[] { + const result = new Set(); + for (const value of values) { + if (!RULE_PATTERN.test(value)) throw new BridgeConfigError(`${option} contains unknown rule ${value}`); + result.add(value as BridgeRuleId); + } + return [...result].sort(); +} + +function rejectOverlap(include: BridgeRuleId[] | undefined, exclude: BridgeRuleId[] | undefined): void { + if (!include || !exclude) return; + const overlap = include.filter((rule) => exclude.includes(rule)); + if (overlap.length) throw new BridgeConfigError(`included and excluded rules overlap: ${overlap.join(", ")}`); +} + +function validateFormat(value: string): asserts value is OutputFormat { + if (value !== "json" && value !== "markdown") { + throw new BridgeConfigError("--format must be json or markdown"); + } +} + +function validateFailSeverity(value: string): asserts value is FailSeverity { + if (!(value in SEVERITY_RANK)) { + throw new BridgeConfigError("--fail-on must be none, info, low, medium, high, or critical"); + } +} + +function exitCode(report: BridgeAnalysisReport, threshold: FailSeverity): number { + const rank = SEVERITY_RANK[threshold]; + return report.files.some((file) => file.findings.some((finding) => + severityRank(finding.severity) >= rank, + )) ? 1 : 0; +} + +function severityRank(severity: string): number { + return severity in SEVERITY_RANK ? SEVERITY_RANK[severity as FailSeverity] : 0; +} + +function sanitize(message: string): string { + return message.replace(/[\r\n]+/g, " ").slice(0, 500); +} + +function writeReport(file: string, output: string): void { + try { + fs.writeFileSync(file, output, "utf8"); + } catch (error) { + const code = (error as { code?: unknown } | null)?.code; + const safeCode = typeof code === "string" && /^[A-Z0-9_]+$/.test(code) ? code : "IO_ERROR"; + throw new BridgeConfigError(`report file could not be written (${safeCode})`); + } +} diff --git a/packages/core/src/bridge/__tests__/analyzer.test.ts b/packages/core/src/bridge/__tests__/analyzer.test.ts new file mode 100644 index 0000000..eee257b --- /dev/null +++ b/packages/core/src/bridge/__tests__/analyzer.test.ts @@ -0,0 +1,67 @@ +import * as fs from "fs"; +import * as path from "path"; +import { analyzeBridgeFiles, analyzeBridgeSource } from "../api"; +import type { BridgeRuleId } from "../types"; + +const FIXTURES = path.resolve(__dirname, "../../../../../examples/contracts/bridge"); + +function fixture(name: string) { + return analyzeBridgeFiles([path.join(FIXTURES, `${name}.sol`)], { includeModels: true }); +} + +function rules(name: string): BridgeRuleId[] { + return fixture(name).files.flatMap((file) => file.findings.map((finding) => finding.ruleId)); +} + +describe("bridge safety analyzer", () => { + it("detects replay, verification bypass, mint-without-lock, and payload execution risks", () => { + const ids = new Set(rules("VulnerableLockMintBridge")); + expect(ids.size).toBeGreaterThan(0); + for (const expected of [ + "CP-BRG-001", "CP-BRG-003", "CP-BRG-006", "CP-BRG-012", "CP-BRG-013", + ] satisfies BridgeRuleId[]) { + expect(ids).toContain(expected); + } + }); + + it("recognizes secure lock-mint bridge with domain binding and replay protection", () => { + const report = fixture("SecureLockMintBridge"); + expect(report.files[0].findings).toEqual([]); + const model = report.files[0].models?.find((item) => item.name === "SecureLockMintBridge"); + expect(model?.adapter).toBe("lock-mint-bridge"); + }); + + it("detects burn-release and validator loop weaknesses", () => { + const ids = new Set(rules("VulnerableBurnReleaseBridge")); + expect(ids.size).toBeGreaterThan(0); + }); + + it("recognizes secure burn-release bridge with finality window", () => { + const report = fixture("SecureBurnReleaseBridge"); + expect(report.files[0].findings).toEqual([]); + }); + + it("attaches evidence, assumptions, confidence, and precise locations", () => { + const finding = fixture("VulnerableLockMintBridge").files[0].findings[0]; + expect(finding.evidence.length).toBeGreaterThan(0); + expect(finding.assumptions.length).toBeGreaterThan(0); + expect(finding.location.line).toBeGreaterThan(0); + }); + + it("supports deterministic include/exclude selection", () => { + const file = path.join(FIXTURES, "VulnerableLockMintBridge.sol"); + const source = fs.readFileSync(file, "utf8"); + const all = analyzeBridgeSource(source, file).files[0].findings; + const filtered = analyzeBridgeSource(source, file, { includeRules: ["CP-BRG-003"] }).files[0].findings; + expect(filtered.every((f) => f.ruleId === "CP-BRG-003")).toBe(true); + expect(filtered.length).toBeLessThanOrEqual(all.length); + }); + + it("returns empty findings for unrelated contracts", () => { + const report = analyzeBridgeSource( + "pragma solidity ^0.8.20; contract Token { uint256 public x; }", + "Token.sol", + ); + expect(report.files[0].findings).toEqual([]); + }); +}); diff --git a/packages/core/src/bridge/adapters.ts b/packages/core/src/bridge/adapters.ts new file mode 100644 index 0000000..ec6a606 --- /dev/null +++ b/packages/core/src/bridge/adapters.ts @@ -0,0 +1,193 @@ +import type { + BridgeContractModel, + BridgeFrameworkAdapter, + BridgeFrameworkAdapterDefinition, + BridgeFrameworkMatch, +} from "./types"; + +export const BRIDGE_FRAMEWORK_ADAPTERS: readonly BridgeFrameworkAdapterDefinition[] = + Object.freeze([ + { + id: "lock-mint-bridge", + displayName: "Lock-and-mint token bridge", + requiredStateGroups: [["totalLocked", "lockedAmount"], ["totalMinted", "mintedAmount"]], + requiredFunctions: ["lockTokens", "mintTokens"], + mitigations: [ + "Lock and mint amounts are tracked independently", + "Minting is expected to follow verified lock events", + ], + limitations: [ + "The adapter does not prove lock verification precedes minting", + "Token decimal normalization and fee handling remain deployment-specific", + ], + }, + { + id: "burn-release-bridge", + displayName: "Burn-and-release token bridge", + requiredStateGroups: [["totalBurned", "burnedAmount"], ["totalReleased", "releasedAmount"]], + requiredFunctions: ["burnTokens", "releaseTokens"], + mitigations: [ + "Burn and release amounts are tracked independently", + "Release is expected to follow verified burn events", + ], + limitations: [ + "The adapter does not prove burn verification precedes release", + "Liquidity availability on the destination chain is not modeled", + ], + }, + { + id: "optimistic-bridge", + displayName: "Optimistic message bridge with challenge window", + requiredStateGroups: [["finalityWindow", "challengePeriod"], ["processedMessages", "executedMessages"]], + requiredFunctions: ["receiveMessage", "executeMessage"], + mitigations: [ + "A finality or challenge window is represented before execution", + "Processed message state prevents immediate replay", + ], + limitations: [ + "Fraud proof correctness and challenger incentives are not verified", + "Relayer liveness assumptions remain external", + ], + }, + { + id: "multisig-validator-bridge", + displayName: "Multisig validator threshold bridge", + requiredStateGroups: [["validators", "signers"], ["threshold", "validatorThreshold"]], + requiredFunctions: ["verifySignatures", "receiveMessage"], + mitigations: [ + "Validator set and threshold are independently represented", + "Signature verification is a distinct transition from execution", + ], + limitations: [ + "Validator uniqueness, sorting, and zero-address rejection are not proven", + "Threshold transition safety during validator rotation is not verified", + ], + }, + { + id: "merkle-proof-bridge", + displayName: "Merkle proof verified message bridge", + requiredStateGroups: [["merkleRoot", "stateRoot"], ["processedMessages", "messageId"]], + requiredFunctions: ["verifyProof", "receiveMessage"], + mitigations: [ + "Merkle or state root is stored and referenced during verification", + "Message consumption state prevents replay", + ], + limitations: [ + "Root update authorization and staleness checks are not proven", + "Proof construction correctness depends on off-chain indexing", + ], + }, + { + id: "layerzero-style", + displayName: "LayerZero-style endpoint messaging", + requiredStateGroups: [["endpointId", "chainId"], ["inboundNonce", "outboundNonce"]], + requiredFunctions: ["send", "lzReceive"], + mitigations: [ + "Separate inbound and outbound nonce tracking", + "Endpoint or chain ID provides domain separation", + ], + limitations: [ + "Trusted remote configuration and library upgrade paths are not verified", + "DVN/Oracle configuration remains deployment-specific", + ], + }, + { + id: "wormhole-style", + displayName: "Wormhole-style guardian verified messaging", + requiredStateGroups: [["guardians", "validators"], ["processedMessages", "consumedMessages"]], + requiredFunctions: ["publishMessage", "receiveMessage"], + mitigations: [ + "Guardian set is represented in state", + "Message consumption tracking prevents replay", + ], + limitations: [ + "Guardian set upgrade governance and VAA parsing are not verified", + "Finality assumptions for each connected chain remain external", + ], + }, + { + id: "axelar-style", + displayName: "Axelar-style gateway with proof verification", + requiredStateGroups: [["validators", "threshold"], ["processedMessages"]], + requiredFunctions: ["validateProof", "execute"], + mitigations: [ + "Proof validation is separated from execution", + "Validator threshold is independently stored", + ], + limitations: [ + "Key rotation and proof format evolution are not verified", + "Gas token routing and express execution paths need independent review", + ], + }, + ]); + +export function matchBridgeFramework( + model: Pick, +): BridgeFrameworkMatch { + const states = new Map(model.stateVariables.map((variable) => [normalize(variable.name), variable.name])); + const functions = new Map(model.transitions.map((transition) => [normalize(transition.name), transition.name])); + for (const adapter of BRIDGE_FRAMEWORK_ADAPTERS) { + const matchedState: string[] = []; + let complete = true; + for (const group of adapter.requiredStateGroups) { + const match = group + .map((name) => states.get(normalize(name))) + .find((value): value is string => value !== undefined); + if (!match) { + complete = false; + break; + } + matchedState.push(match); + } + if (!complete) continue; + const matchedFunctions: string[] = []; + for (const name of adapter.requiredFunctions) { + const match = functions.get(normalize(name)); + if (!match) { + complete = false; + break; + } + matchedFunctions.push(match); + } + if (!complete) continue; + return { + adapter: adapter.id, + matchedState: matchedState.sort(), + matchedFunctions: matchedFunctions.sort(), + }; + } + + const roles = new Set(model.transitions.map((transition) => transition.role)); + const stateRoles = new Set(model.stateVariables.map((variable) => variable.role)); + if (roles.has("lock-tokens") && roles.has("mint-tokens")) { + return { adapter: "lock-mint-bridge", matchedState: [], matchedFunctions: [] }; + } + if (roles.has("burn-tokens") && roles.has("release-tokens")) { + return { adapter: "burn-release-bridge", matchedState: [], matchedFunctions: [] }; + } + if (stateRoles.has("finality-window") && roles.has("execute-message")) { + return { adapter: "optimistic-bridge", matchedState: [], matchedFunctions: [] }; + } + if (stateRoles.has("validator-set") && roles.has("verify-signatures")) { + return { adapter: "multisig-validator-bridge", matchedState: [], matchedFunctions: [] }; + } + if (stateRoles.has("merkle-root") && roles.has("verify-proof")) { + return { adapter: "merkle-proof-bridge", matchedState: [], matchedFunctions: [] }; + } + if (roles.has("send-message") || roles.has("receive-message")) { + return { adapter: "generic-bridge", matchedState: [], matchedFunctions: [] }; + } + return { adapter: "none", matchedState: [], matchedFunctions: [] }; +} + +export function getBridgeFrameworkAdapter( + id: BridgeFrameworkAdapterDefinition["id"], +): BridgeFrameworkAdapterDefinition { + const adapter = BRIDGE_FRAMEWORK_ADAPTERS.find((candidate) => candidate.id === id); + if (!adapter) throw new Error(`Unknown bridge framework adapter: ${id}`); + return adapter; +} + +function normalize(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} diff --git a/packages/core/src/bridge/analyzer.ts b/packages/core/src/bridge/analyzer.ts new file mode 100644 index 0000000..9958149 --- /dev/null +++ b/packages/core/src/bridge/analyzer.ts @@ -0,0 +1,704 @@ +import { buildMessageFlows } from "./message-flow"; +import { hasMitigation } from "./mitigations"; +import { tracePayloadEffects } from "./payload-tracer"; +import { analyzeProofLoop, countSignatureRecoveries, hasUnsafeQuorumArithmetic } from "./proof-analysis"; +import type { + BridgeContractModel, + BridgeEvidence, + BridgeFinding, + BridgeOperation, + BridgeRuleId, + BridgeStateVariable, + BridgeTransition, + BridgeVariableRole, +} from "./types"; + +type Rule = (model: BridgeContractModel) => BridgeFinding[]; + +const RULE_ORDER: readonly BridgeRuleId[] = Array.from({ length: 16 }, (_, index) => + `CP-BRG-${String(index + 1).padStart(3, "0")}` as BridgeRuleId, +); + +const RULES: Record = { + "CP-BRG-001": detectMissingSourceBinding, + "CP-BRG-002": detectMissingDestinationBinding, + "CP-BRG-003": detectReplayableMessages, + "CP-BRG-004": detectNonceCollision, + "CP-BRG-005": detectWeakThresholdTransition, + "CP-BRG-006": detectVerificationBypass, + "CP-BRG-007": detectDuplicateValidators, + "CP-BRG-008": detectUnsortedValidatorSet, + "CP-BRG-009": detectZeroAddressValidator, + "CP-BRG-010": detectStaleRoot, + "CP-BRG-011": detectUnsafeQuorumArithmetic, + "CP-BRG-012": detectUnvalidatedPayloadExecution, + "CP-BRG-013": detectMintWithoutLock, + "CP-BRG-014": detectReleaseWithoutBurn, + "CP-BRG-015": detectMissingFinalityWindow, + "CP-BRG-016": detectMissingInboundMitigations, +}; + +export function analyzeBridgeModel( + model: BridgeContractModel, + options: { includeRules?: BridgeRuleId[]; excludeRules?: BridgeRuleId[] } = {}, +): BridgeFinding[] { + const include = options.includeRules ? new Set(options.includeRules) : null; + const exclude = new Set(options.excludeRules ?? []); + const findings: BridgeFinding[] = []; + for (const id of RULE_ORDER) { + if (include && !include.has(id)) continue; + if (exclude.has(id)) continue; + findings.push(...RULES[id](model)); + } + return findings.sort(compareFindings); +} + +function detectMissingSourceBinding(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const edge of buildMessageFlows(model)) { + if (edge.direction !== "inbound") continue; + if (edge.bindsSource || hasMitigation(edge.transition, "domain-binding")) continue; + const call = privilegedCall(edge.transition); + if (!call) continue; + findings.push(finding({ + ruleId: "CP-BRG-001", + title: `Inbound message in ${edge.transition.name} lacks source chain binding`, + description: + "A received cross-chain message reaches state-changing execution without binding authorization " + + "to a source chain ID, origin domain, or authenticated bridge endpoint. Relayers can replay " + + "messages from unintended source chains.", + recommendation: + "Include sourceChainId or origin in the message hash, verify it against a trusted remote mapping, " + + "and reject messages whose origin does not match the expected source chain.", + severity: "critical", + confidence: "high", + category: "domain-separation", + model, + transition: edge.transition, + evidence: [ + operationEvidence(call, "Inbound path reaches privileged execution"), + absenceEvidence(edge.transition, "No source-chain or origin binding was identified"), + ], + assumptions: ["Bridge accepts messages from multiple potential source chains"], + })); + } + return findings; +} + +function detectMissingDestinationBinding(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const edge of buildMessageFlows(model)) { + if (edge.direction !== "outbound") continue; + if (edge.bindsDestination) continue; + const call = outboundCall(edge.transition); + if (!call) continue; + findings.push(finding({ + ruleId: "CP-BRG-002", + title: `Outbound message in ${edge.transition.name} lacks destination binding`, + description: + "An outbound cross-chain message is dispatched without explicit destination chain ID or endpoint " + + "binding. Messages may be routed to unintended destinations or replayed on wrong chains.", + recommendation: + "Bind destinationChainId or endpointId in the message envelope, validate against an allowlist of " + + "trusted destination chains, and include destination in the signed message hash.", + severity: "high", + confidence: "high", + category: "domain-separation", + model, + transition: edge.transition, + evidence: [ + operationEvidence(call, "Outbound dispatch reaches external call"), + absenceEvidence(edge.transition, "No destination-chain or endpoint binding was identified"), + ], + assumptions: ["The bridge connects to multiple destination chains"], + })); + } + return findings; +} + +function detectReplayableMessages(model: BridgeContractModel): BridgeFinding[] { + const messageState = new Set(variables(model, ["message-id", "processed-messages", "replay-map", "nonce"]) + .map((item) => item.name)); + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["receive-message", "execute-message"])) { + const call = privilegedCall(transition); + if (!call) continue; + const write = firstWrite(transition, messageState); + if (write && write.order < call.order) continue; + if (hasMitigation(transition, "replay-map") || hasMitigation(transition, "nonce-consumption")) continue; + findings.push(finding({ + ruleId: "CP-BRG-003", + title: `Replayable cross-chain message in ${transition.name}`, + description: + "A received message reaches privileged execution without consuming a unique message ID or nonce " + + "before the call. Relayers or bridges can redeliver the same message to duplicate mints, releases, " + + "or arbitrary executions.", + recommendation: + "Maintain a processed-messages mapping or monotonic nonce, reject already-consumed IDs, and mark " + + "the message consumed before any external call or token mint/release.", + severity: "critical", + confidence: "high", + category: "replay-protection", + model, + transition, + evidence: [ + operationEvidence(call, "Message path reaches privileged execution"), + ...(write ? [] : [absenceEvidence(transition, "No pre-call message or nonce consumption write")]), + ], + assumptions: ["The transport can deliver duplicate messages"], + })); + } + return findings; +} + +function detectNonceCollision(model: BridgeContractModel): BridgeFinding[] { + const nonceVars = variables(model, ["nonce"]); + if (!nonceVars.length) return []; + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["send-message", "receive-message"])) { + const source = codeText(transition.source); + const reusesNonce = /nonces\[.*\]\s*==|require\s*\(\s*nonces|mapping.*nonce.*bool/i.test(source) && + !/nonces\[.*\]\s*=\s*true|nonces\[.*\]\+\+|incrementNonce/i.test(source); + const noIncrement = transition.role === "send-message" && + !/outboundNonce\+\+|nonces\[.*\]\s*=|incrementNonce/i.test(source); + if (!reusesNonce && !noIncrement) continue; + findings.push(finding({ + ruleId: "CP-BRG-004", + title: `Weak nonce management in ${transition.name}`, + description: + "Nonce state is read or checked without a visible increment or consumption pattern, enabling " + + "nonce collisions or predictable replay identifiers across concurrent message submissions.", + recommendation: + "Use separate inbound and outbound nonce counters, atomically increment on send, and reject " + + "messages whose nonce does not exactly match the expected next value.", + severity: "high", + confidence: "medium", + category: "replay-protection", + model, + transition, + evidence: [ + variableEvidence(nonceVars[0], "Nonce state is tracked"), + absenceEvidence(transition, "No atomic nonce increment or strict sequential check identified"), + ], + assumptions: ["Multiple messages can be submitted concurrently"], + })); + } + return findings; +} + +function detectWeakThresholdTransition(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["update-validator-set", "update-threshold"])) { + const source = codeText(transition.source); + const instantUpdate = !hasMitigation(transition, "two-phase-validator-update") && + !/pendingValidators|newThreshold|delay|timelock|schedule/i.test(source); + const noBoundsCheck = /threshold\s*=|setThreshold/i.test(source) && + !/threshold\s*<=\s*validators\.length|threshold\s*>\s*0|require\s*\(\s*threshold/i.test(source); + if (!instantUpdate && !noBoundsCheck) continue; + findings.push(finding({ + ruleId: "CP-BRG-005", + title: `Unsafe validator or threshold update in ${transition.name}`, + description: + "Validator set or threshold can be updated in a single transaction without a two-phase delay " + + "or without bounding the new threshold to [1, validators.length]. A compromised admin can " + + "instantly reduce quorum to one signer.", + recommendation: + "Implement two-phase validator updates with a timelock, require newThreshold > 0 and " + + "newThreshold <= validators.length, and prevent threshold changes during active validator rotation.", + severity: "critical", + confidence: "high", + category: "validator-governance", + model, + transition, + evidence: [ + absenceEvidence(transition, "Two-phase update or threshold bounds check not identified"), + ], + assumptions: ["Validator set updates are controlled by a privileged role"], + })); + } + return findings; +} + +function detectVerificationBypass(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["receive-message", "execute-message"])) { + const call = privilegedCall(transition); + if (!call) continue; + const source = codeText(transition.source); + const hasProof = hasMitigation(transition, "proof-verification") || hasMitigation(transition, "signature-verification"); + const proofBeforeCall = transition.operations.find((op) => + (op.kind === "call" || op.kind === "guard") && + /verifyProof|verifySignatures|checkSignatures|validateProof|processedMessages|require\s*\(\s*processed/i.test(op.expression) && + op.order < call.order, + ); + if (hasProof && proofBeforeCall) continue; + if (/require\s*\(\s*processedMessages|processedMessages\[|!processedMessages/i.test(source)) continue; + if (transition.role === "receive-message" && /trustedRelayer|onlyRelayer|msg\.sender\s*==.*relayer/i.test(source)) { + continue; + } + findings.push(finding({ + ruleId: "CP-BRG-006", + title: `Message verification bypass in ${transition.name}`, + description: + "A cross-chain message reaches token mint, release, or arbitrary execution without a visible " + + "proof or signature verification step before the privileged call. Forged messages can be accepted.", + recommendation: + "Verify Merkle proofs or validator signatures before any state change. Ensure verification " + + "dominates the privileged call in execution order and cannot be skipped by control flow.", + severity: "critical", + confidence: "high", + category: "verification", + model, + transition, + evidence: [ + operationEvidence(call, "Privileged execution without preceding verification"), + absenceEvidence(transition, "No proof or signature verification before privileged call"), + ], + assumptions: ["Message payloads originate from untrusted relayers or transport"], + })); + } + return findings; +} + +function detectDuplicateValidators(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["verify-signatures", "verify-proof", "receive-message"])) { + const analysis = analyzeProofLoop(transition); + if (!analysis.hasLoop || analysis.duplicateCheck) continue; + if (countSignatureRecoveries(transition) < 2) continue; + findings.push(finding({ + ruleId: "CP-BRG-007", + title: `Duplicate validator signatures accepted in ${transition.name}`, + description: + "Signature or proof verification iterates over validators without tracking seen signers. " + + "A single validator can submit multiple signatures to satisfy the threshold.", + recommendation: + "Track seen signers in a bitmap or mapping, reject duplicate recoveries, and require distinct " + + "validator addresses for each signature counted toward the threshold.", + severity: "critical", + confidence: "high", + category: "verification", + model, + transition, + evidence: [ + proofLoopEvidence(transition, "Signature loop lacks duplicate detection"), + ], + assumptions: ["Multiple signatures are collected in a single verification loop"], + })); + } + return findings; +} + +function detectUnsortedValidatorSet(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["verify-signatures", "verify-proof"])) { + const analysis = analyzeProofLoop(transition); + if (!analysis.hasLoop || analysis.sortingCheck) continue; + if (countSignatureRecoveries(transition) === 0) continue; + findings.push(finding({ + ruleId: "CP-BRG-008", + title: `Unsorted validator set in ${transition.name}`, + description: + "Signature verification does not enforce sorted validator ordering. Unsorted sets enable " + + "signature malleability and complicate duplicate detection across validator rotations.", + recommendation: + "Require signers to be sorted in ascending address order, verify each signer exceeds the " + + "previous, and reject out-of-order or duplicate entries.", + severity: "medium", + confidence: "medium", + category: "verification", + model, + transition, + evidence: [ + proofLoopEvidence(transition, "Validator loop lacks sorting requirement"), + ], + assumptions: ["Threshold verification accepts variable-length signature arrays"], + })); + } + return findings; +} + +function detectZeroAddressValidator(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["verify-signatures", "update-validator-set", "verify-proof"])) { + const analysis = analyzeProofLoop(transition); + const source = codeText(transition.source); + const checksZero = analysis.zeroAddressCheck || + /address\s*\(\s*0\s*\)|zeroAddress|!=\s*address\(0\)/i.test(source); + if (checksZero) continue; + if (transition.role === "update-validator-set" && + /require\s*\(\s*validator\s*!=|push\s*\(\s*validator\s*\)/i.test(source)) continue; + if (countSignatureRecoveries(transition) === 0 && transition.role !== "update-validator-set") continue; + findings.push(finding({ + ruleId: "CP-BRG-009", + title: `Zero-address validator not rejected in ${transition.name}`, + description: + "Validator set management or signature verification does not explicitly reject the zero address. " + + "Zero-address entries can reduce effective quorum or enable signature forgery edge cases.", + recommendation: + "Require validator != address(0) on add and during signature recovery. Reject ecrecover results " + + "that resolve to the zero address.", + severity: "high", + confidence: "medium", + category: "verification", + model, + transition, + evidence: [ + absenceEvidence(transition, "No zero-address rejection for validators or recovered signers"), + ], + assumptions: ["Validator addresses are supplied by external callers or signatures"], + })); + } + return findings; +} + +function detectStaleRoot(model: BridgeContractModel): BridgeFinding[] { + const roots = variables(model, ["merkle-root", "state-root"]); + if (!roots.length) return []; + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["verify-proof", "receive-message"])) { + const analysis = analyzeProofLoop(transition); + const source = codeText(transition.source); + if (analysis.staleRootCheck || /rootUpdatedAt|rootTimestamp|block\.timestamp\s*>=\s*root/i.test(source)) continue; + if (!/verifyProof|merkleRoot|stateRoot|processProof/i.test(source)) continue; + findings.push(finding({ + ruleId: "CP-BRG-010", + title: `Stale Merkle or state root accepted in ${transition.name}`, + description: + "Proof verification references a stored root without checking recency, update timestamp, or " + + "block height. Proofs against superseded roots can authorize outdated or forked state.", + recommendation: + "Track root update block/timestamp, reject proofs against roots older than a configured finality " + + "window, and require root updates to propagate before accepting new proofs.", + severity: "high", + confidence: "medium", + category: "verification", + model, + transition, + evidence: [ + variableEvidence(roots[0], "Merkle or state root stored on-chain"), + absenceEvidence(transition, "No root staleness or recency check identified"), + ], + assumptions: ["Roots can be updated while older proofs remain valid off-chain"], + })); + } + return findings; +} + +function detectUnsafeQuorumArithmetic(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["verify-signatures", "verify-proof", "receive-message"])) { + const badArithmetic = hasUnsafeQuorumArithmetic(transition); + const source = codeText(transition.source); + const zeroThreshold = /threshold\s*(?:==|<=)\s*0|signatures\.length\s*>=\s*0/i.test(source); + if (!badArithmetic && !zeroThreshold) continue; + findings.push(finding({ + ruleId: "CP-BRG-011", + title: `Unsafe quorum arithmetic in ${transition.name}`, + description: + "Validator threshold math truncates before multiplication or permits a zero threshold. " + + "Small validator sets and integer division can collapse the required signature count.", + recommendation: + "Use full-precision mulDiv for threshold calculations, require threshold > 0 and " + + "threshold <= validators.length, and document inclusive boundary behavior.", + severity: "high", + confidence: "high", + category: "validator-governance", + model, + transition, + evidence: [ + ...(badArithmetic ? [operationEvidence(badArithmetic, "Unsafe division in threshold math")] : []), + absenceEvidence(transition, "Threshold lower bound not enforced"), + ], + assumptions: ["Solidity integer truncation applies to threshold calculations"], + })); + } + return findings; +} + +function detectUnvalidatedPayloadExecution(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["receive-message", "execute-message"])) { + for (const trace of tracePayloadEffects(transition)) { + if (trace.validated || trace.effect !== "arbitrary-call") continue; + findings.push(finding({ + ruleId: "CP-BRG-012", + title: `Unvalidated message payload executes arbitrary call in ${transition.name}`, + description: + "Message calldata flows into a low-level call or delegatecall without verified proof, " + + "signature, or replay protection. Attackers can craft payloads for token theft, upgrades, " + + "or role grants.", + recommendation: + "Constrain executable payloads to an allowlist of selectors, validate message hash and " + + "replay state before call, and prefer typed execution over arbitrary .call(data).", + severity: "critical", + confidence: "high", + category: "payload-execution", + model, + transition, + evidence: [ + taintEvidence(trace.privilegedCall, "Message parameter reaches arbitrary execution"), + ...(trace.payloadSources.length ? + [absenceEvidence(transition, `Payload sources: ${trace.payloadSources.join(", ")}`)] : []), + ], + assumptions: ["Message payload bytes are attacker-controlled"], + })); + } + } + return findings; +} + +function detectMintWithoutLock(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + const mintRoles: BridgeTransition["role"][] = ["mint-tokens", "receive-message", "execute-message"]; + for (const transition of byRoles(model, mintRoles)) { + const mintCall = transition.operations.find((op) => + op.kind === "call" && /mint|_mint/i.test(op.expression), + ); + if (!mintCall) continue; + const source = codeText(transition.source); + const verifiesLock = /verifyProof|verifySignatures|totalLocked|lockedAmount|lockVerified|processedMessages|amount\s*<=.*totalLocked/i.test(source); + if (verifiesLock) continue; + findings.push(finding({ + ruleId: "CP-BRG-013", + title: `Token mint in ${transition.name} without verified lock`, + description: + "Wrapped or bridged tokens are minted without verifying a corresponding lock event on the " + + "source chain. Attackers can inflate wrapped supply without depositing collateral.", + recommendation: + "Mint only after verifying a proof of lock on the source chain, tracking cumulative locked vs " + + "minted amounts, and rejecting mint requests exceeding verified lock balance.", + severity: "critical", + confidence: "high", + category: "token-bridge", + model, + transition, + evidence: [ + operationEvidence(mintCall, "Mint call without visible lock verification"), + absenceEvidence(transition, "No lock proof or locked-amount check before mint"), + ], + assumptions: ["Minted tokens represent locked collateral on another chain"], + })); + } + return findings; +} + +function detectReleaseWithoutBurn(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["release-tokens"])) { + const releaseCall = transition.operations.find((op) => + op.kind === "call" && /transfer|release|withdraw|_transfer/i.test(op.expression), + ); + if (!releaseCall) continue; + const source = codeText(transition.source); + const verifiesBurn = /verifyProof|verifySignatures|totalBurned|burnedAmount|burnVerified|processedMessages/i.test(source); + const burnTransition = model.transitions.some((t) => t.role === "burn-tokens"); + if (verifiesBurn) continue; + if (!burnTransition && !variables(model, ["burn-amount"]).length) continue; + findings.push(finding({ + ruleId: "CP-BRG-014", + title: `Token release in ${transition.name} without verified burn`, + description: + "Locked or escrowed tokens are released without verifying a corresponding burn on the " + + "destination chain. Double-spending across chains becomes possible.", + recommendation: + "Release only after verifying a proof of burn on the destination chain, tracking cumulative " + + "burned vs released amounts, and rejecting releases exceeding verified burn balance.", + severity: "critical", + confidence: "high", + category: "token-bridge", + model, + transition, + evidence: [ + operationEvidence(releaseCall, "Release call without visible burn verification"), + absenceEvidence(transition, "No burn proof or burned-amount check before release"), + ], + assumptions: ["Released tokens correspond to burns on another chain"], + })); + } + return findings; +} + +function detectMissingFinalityWindow(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["execute-message", "receive-message"])) { + if (hasMitigation(transition, "delayed-finality")) continue; + const finalityVars = variables(model, ["finality-window"]); + const source = codeText(transition.source); + const hasFinalityCheck = finalityVars.some((v) => source.includes(v.name)) || + /challengePeriod|confirmationBlocks|finalityDelay|block\.number\s*>=\s*.*\+/i.test(source); + if (hasFinalityCheck) continue; + const call = privilegedCall(transition); + if (!call) continue; + const isOptimistic = model.adapter === "optimistic-bridge" || + model.transitions.some((t) => t.role === "relay-message"); + if (!isOptimistic && finalityVars.length === 0) continue; + findings.push(finding({ + ruleId: "CP-BRG-015", + title: `Missing finality window before execution in ${transition.name}`, + description: + "Cross-chain message execution proceeds immediately without a challenge period, confirmation " + + "block delay, or finality window. Reorgs or fraudulent messages can finalize before detection.", + recommendation: + "Introduce a configurable finality delay between message acceptance and execution. Allow " + + "challengers to dispute during the window and only finalize after the delay elapses.", + severity: "high", + confidence: "medium", + category: "finality", + model, + transition, + evidence: [ + operationEvidence(call, "Immediate execution without finality delay"), + absenceEvidence(transition, "No challenge period or confirmation block check"), + ], + assumptions: ["Source chain reorgs or optimistic fraud are in threat model"], + })); + } + return findings; +} + +function detectMissingInboundMitigations(model: BridgeContractModel): BridgeFinding[] { + const findings: BridgeFinding[] = []; + for (const transition of byRoles(model, ["receive-message", "execute-message"])) { + const call = privilegedCall(transition); + if (!call) continue; + const mitigations = [ + hasMitigation(transition, "pause-guard"), + hasMitigation(transition, "rate-limit"), + hasMitigation(transition, "replay-map"), + ]; + if (mitigations.filter(Boolean).length >= 2) continue; + const pauseVar = variables(model, ["bridge-paused"]); + const rateVar = variables(model, ["rate-limit"]); + if (pauseVar.length && rateVar.length) continue; + findings.push(finding({ + ruleId: "CP-BRG-016", + title: `High-risk inbound path lacks pause or rate-limit in ${transition.name}`, + description: + "An inbound message path that mints tokens or executes payloads lacks adequate operational " + + "mitigations such as pause controls or rate limiting. Incidents cannot be contained quickly.", + recommendation: + "Add whenNotPaused guards, configurable rate limits on inbound message volume, and an " + + "emergency pause controlled by a multisig or timelock.", + severity: "medium", + confidence: "medium", + category: "operational-safety", + model, + transition, + evidence: [ + operationEvidence(call, "High-risk inbound execution path"), + absenceEvidence(transition, "Insufficient pause and rate-limit mitigations"), + ], + assumptions: ["Bridge operators need incident response controls"], + })); + } + return findings; +} + +interface FindingInput { + ruleId: BridgeRuleId; + title: string; + description: string; + recommendation: string; + severity: BridgeFinding["severity"]; + confidence: BridgeFinding["confidence"]; + category: string; + model: BridgeContractModel; + transition?: BridgeTransition; + location?: BridgeFinding["location"]; + evidence: BridgeEvidence[]; + assumptions: string[]; +} + +function finding(input: FindingInput): BridgeFinding { + return { + ruleId: input.ruleId, + title: input.title, + description: input.description, + recommendation: input.recommendation, + severity: input.severity, + confidence: input.confidence, + category: input.category, + contract: input.model.name, + location: input.location ?? input.transition?.location ?? input.model.location, + evidence: input.evidence, + assumptions: input.assumptions, + }; +} + +function byRoles(model: BridgeContractModel, roles: BridgeTransition["role"][]): BridgeTransition[] { + const selected = new Set(roles); + return model.transitions.filter((transition) => selected.has(transition.role)); +} + +function variables(model: BridgeContractModel, roles: BridgeVariableRole[]): BridgeStateVariable[] { + const selected = new Set(roles); + return model.stateVariables.filter((variable) => selected.has(variable.role)); +} + +function privilegedCall(transition: BridgeTransition): BridgeOperation | undefined { + return transition.operations.find((operation) => + operation.kind === "call" && (/call|delegatecall|functioncall|execute|mint|release|upgradeto/i.test(operation.name) || + /\.call\s*\{|\.delegatecall\s*\(|\.mint\s*\(|upgradeto/i.test(operation.expression)), + ); +} + +function outboundCall(transition: BridgeTransition): BridgeOperation | undefined { + return transition.operations.find((operation) => + operation.kind === "call" && /send|dispatch|publish|emit/i.test(operation.name + operation.expression), + ); +} + +function firstWrite(transition: BridgeTransition, names: Set): BridgeOperation | undefined { + return transition.operations.find((operation) => + operation.kind === "write" && [...names].some((name) => + operation.name.split(",").includes(name) || operation.expression.includes(name), + ), + ); +} + +function operationEvidence(operation: BridgeOperation, description: string): BridgeEvidence { + return { + kind: operation.kind === "write" ? "state-write" : operation.kind === "arithmetic" ? + "arithmetic" : operation.kind === "guard" ? "branch" : "call", + description, + location: operation.location, + snippet: operation.expression, + }; +} + +function taintEvidence(operation: BridgeOperation, description: string): BridgeEvidence { + return { + kind: "taint-flow", + description: `${description}; sources: ${operation.parameterSources.join(", ")}`, + location: operation.location, + snippet: operation.expression, + }; +} + +function variableEvidence(variable: BridgeStateVariable, description: string): BridgeEvidence { + return { + kind: "state-read", + description, + location: variable.location, + snippet: `${variable.typeName} ${variable.name}`, + }; +} + +function absenceEvidence(transition: BridgeTransition, description: string): BridgeEvidence { + return { kind: "absence", description, location: transition.location }; +} + +function proofLoopEvidence(transition: BridgeTransition, description: string): BridgeEvidence { + return { kind: "proof-loop", description, location: transition.location, snippet: transition.source.slice(0, 200) }; +} + +function codeText(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n\r]*/g, " ").replace(/\s+/g, " "); +} + +function compareFindings(left: BridgeFinding, right: BridgeFinding): number { + return left.location.file.localeCompare(right.location.file) || left.location.line - right.location.line || + left.location.column - right.location.column || left.ruleId.localeCompare(right.ruleId) || + left.title.localeCompare(right.title); +} diff --git a/packages/core/src/bridge/api.ts b/packages/core/src/bridge/api.ts new file mode 100644 index 0000000..df72a5f --- /dev/null +++ b/packages/core/src/bridge/api.ts @@ -0,0 +1,267 @@ +import * as fs from "fs"; +import * as path from "path"; +import { analyzeBridgeModel } from "./analyzer"; +import { + BridgeAnalysisCancelledError, + resolveBridgeLimits, +} from "./config"; +import { buildBridgeModels } from "./model"; +import type { + BridgeAnalysisOptions, + BridgeAnalysisReport, + BridgeContractModel, + BridgeDiagnostic, + BridgeFileAnalysis, + BridgeFinding, + BridgeSourceInput, +} from "./types"; + +export const BRIDGE_ENGINE_VERSION = "0.1.0" as const; + +const SEVERITIES = ["critical", "high", "medium", "low", "info"] as const; + +/** Analyze a single in-memory Solidity source without filesystem or network access. */ +export function analyzeBridgeSource( + source: string, + file = ".sol", + options: BridgeAnalysisOptions = {}, +): BridgeAnalysisReport { + return analyzeBridgeSources([{ file, source }], options); +} + +/** Analyze an explicitly supplied, deterministic set of Solidity sources. */ +export function analyzeBridgeSources( + inputs: BridgeSourceInput[], + options: BridgeAnalysisOptions = {}, +): BridgeAnalysisReport { + const limits = resolveBridgeLimits(options.limits); + checkCancelled(options); + const ordered = [...inputs] + .map((input) => ({ file: input.file, source: input.source })) + .sort((left, right) => left.file.localeCompare(right.file)); + const files: BridgeFileAnalysis[] = []; + let contractCount = 0; + let findingsRemaining = limits.maxFindings; + let truncated = ordered.length > limits.maxFiles; + + for (const input of ordered.slice(0, limits.maxFiles)) { + checkCancelled(options); + const built = buildBridgeModels(input.source, input.file, limits, options.signal); + contractCount += built.models.length; + const findings: BridgeFinding[] = []; + for (const model of built.models) { + checkCancelled(options); + for (const finding of analyzeBridgeModel(model, options)) { + if (findingsRemaining === 0) { + truncated = true; + break; + } + findings.push({ + ...finding, + evidence: finding.evidence.slice(0, limits.maxEvidencePerFinding), + }); + findingsRemaining -= 1; + } + if (findingsRemaining === 0) break; + } + const diagnostics = [...built.diagnostics]; + if (findingsRemaining === 0) { + diagnostics.push(limitDiagnostic(input.file, limits.maxFindings)); + } + files.push({ + file: input.file, + findings: findings.sort(compareFindings), + diagnostics: diagnostics.sort(compareDiagnostics), + ...(options.includeModels ? { models: built.models.map(sortModel) } : {}), + }); + } + + if (ordered.length > limits.maxFiles) { + files.push({ + file: "", + findings: [], + diagnostics: [{ + code: "BRG_SOURCE_LIMIT", + severity: "warning", + message: `Only the first ${limits.maxFiles} Solidity files were analyzed`, + }], + }); + } + return report(files, truncated, contractCount); +} + +/** Recursively collect Solidity files while avoiding symlink traversal. */ +export function collectBridgeSolidityFiles(targets: string[]): string[] { + const result = new Set(); + const pending = [...targets].map((target) => path.resolve(target)).sort().reverse(); + while (pending.length) { + const candidate = pending.pop() as string; + let stat: fs.Stats; + try { + stat = fs.lstatSync(candidate); + } catch { + continue; + } + if (stat.isSymbolicLink()) continue; + if (stat.isFile()) { + if (candidate.endsWith(".sol")) result.add(candidate); + continue; + } + if (!stat.isDirectory()) continue; + let entries: string[]; + try { + entries = fs.readdirSync(candidate).sort(); + } catch { + continue; + } + for (let index = entries.length - 1; index >= 0; index -= 1) { + pending.push(path.join(candidate, entries[index])); + } + } + return [...result].sort(); +} + +/** Read and analyze Solidity files/directories with bounded IO and sanitized diagnostics. */ +export function analyzeBridgeFiles( + targets: string[], + options: BridgeAnalysisOptions = {}, +): BridgeAnalysisReport { + const limits = resolveBridgeLimits(options.limits); + checkCancelled(options); + const discovered = collectBridgeSolidityFiles(targets); + const inputs: BridgeSourceInput[] = []; + const unreadable: BridgeFileAnalysis[] = []; + for (const target of [...new Set(targets.map((item) => path.resolve(item)))].sort()) { + try { + fs.lstatSync(target); + } catch (error) { + unreadable.push(unreadableFile(target, error)); + } + } + for (const file of discovered.slice(0, limits.maxFiles)) { + checkCancelled(options); + try { + inputs.push({ file, source: fs.readFileSync(file, "utf8") }); + } catch (error) { + unreadable.push(unreadableFile(file, error)); + } + } + const analysis = analyzeBridgeSources(inputs, { ...options, limits }); + const files = [...analysis.files.filter((file) => file.file !== ""), ...unreadable] + .sort((left, right) => left.file.localeCompare(right.file)); + if (discovered.length > limits.maxFiles || analysis.files.some((file) => file.file === "")) { + files.push({ + file: "", + findings: [], + diagnostics: [{ + code: "BRG_SOURCE_LIMIT", + severity: "warning", + message: `Only the first ${limits.maxFiles} Solidity files were analyzed`, + }], + }); + } + return report( + files, + analysis.summary.truncated || discovered.length > limits.maxFiles, + analysis.summary.contracts, + ); +} + +function report( + files: BridgeFileAnalysis[], + truncated: boolean, + contractCount: number, +): BridgeAnalysisReport { + const summary = { + files: files.filter((file) => file.file !== "").length, + contracts: contractCount, + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + total: 0, + truncated, + }; + for (const file of files) { + for (const finding of file.findings) { + incrementSeverity(summary, finding.severity); + summary.total += 1; + } + if (file.diagnostics.some((diagnostic) => + diagnostic.code === "BRG_FINDING_LIMIT" || diagnostic.code.endsWith("_LIMIT"))) { + summary.truncated = true; + } + } + return { + schemaVersion: "1.0.0", + engineVersion: BRIDGE_ENGINE_VERSION, + files, + summary, + }; +} + +function sortModel(model: BridgeContractModel): BridgeContractModel { + return { + ...model, + stateVariables: [...model.stateVariables].sort((left, right) => + left.location.line - right.location.line || left.name.localeCompare(right.name)), + transitions: [...model.transitions].sort((left, right) => + left.location.line - right.location.line || left.name.localeCompare(right.name)), + privilegedCalls: [...model.privilegedCalls].sort((left, right) => left.order - right.order), + messageControlledCalls: [...model.messageControlledCalls].sort((left, right) => left.order - right.order), + }; +} + +function incrementSeverity( + summary: { critical: number; high: number; medium: number; low: number; info: number }, + severity: string, +): void { + if (severity === "critical" || severity === "high" || severity === "medium" || + severity === "low" || severity === "info") { + summary[severity] += 1; + } +} + +function limitDiagnostic(file: string, limit: number): BridgeDiagnostic { + return { + code: "BRG_FINDING_LIMIT", + severity: "warning", + message: `Finding output was limited to ${limit} records`, + location: { file, line: 1, column: 1 }, + }; +} + +function compareFindings(left: BridgeFinding, right: BridgeFinding): number { + return left.location.line - right.location.line || left.location.column - right.location.column || + left.ruleId.localeCompare(right.ruleId) || left.contract.localeCompare(right.contract); +} + +function compareDiagnostics(left: BridgeDiagnostic, right: BridgeDiagnostic): number { + return (left.location?.line ?? 0) - (right.location?.line ?? 0) || + left.code.localeCompare(right.code) || left.message.localeCompare(right.message); +} + +function checkCancelled(options: BridgeAnalysisOptions): void { + if (options.signal?.aborted) throw new BridgeAnalysisCancelledError(); +} + +function safeErrorCode(error: unknown): string { + const code = (error as { code?: unknown } | null)?.code; + return typeof code === "string" && /^[A-Z0-9_]+$/.test(code) ? code : "IO_ERROR"; +} + +function unreadableFile(file: string, error: unknown): BridgeFileAnalysis { + return { + file, + findings: [], + diagnostics: [{ + code: "BRG_FILE_UNREADABLE", + severity: "error", + message: `Solidity target could not be read (${safeErrorCode(error)})`, + location: { file, line: 1, column: 1 }, + }], + }; +} + +export const BRIDGE_SEVERITY_ORDER = SEVERITIES; diff --git a/packages/core/src/bridge/config.ts b/packages/core/src/bridge/config.ts new file mode 100644 index 0000000..05daaee --- /dev/null +++ b/packages/core/src/bridge/config.ts @@ -0,0 +1,216 @@ +import * as fs from "fs"; +import { + BRIDGE_CONFIG_SCHEMA_VERSION, + type BridgeAnalysisConfigInput, + type BridgeAnalysisConfigV1, + type BridgeAnalysisLimits, + type BridgeDiagnostic, + type BridgeRuleId, + type ValidatedBridgeConfig, +} from "./types"; + +export const DEFAULT_BRIDGE_LIMITS: Readonly = Object.freeze({ + maxSourceBytes: 2 * 1024 * 1024, + maxFiles: 256, + maxContracts: 128, + maxFunctionsPerFile: 512, + maxFunctionsPerContract: 512, + maxOperationsPerFunction: 2048, + maxFindings: 1024, + maxEvidencePerFinding: 12, +}); + +const RULE_IDS = new Set(Array.from({ length: 16 }, (_, index) => + `CP-BRG-${String(index + 1).padStart(3, "0")}`, +)); + +const LIMIT_KEYS: Array = [ + "maxSourceBytes", + "maxFiles", + "maxContracts", + "maxFunctionsPerFile", + "maxFunctionsPerContract", + "maxOperationsPerFunction", + "maxFindings", + "maxEvidencePerFinding", +]; + +export class BridgeConfigError extends Error { + readonly code = "BRG_CONFIG_INVALID"; + + constructor(message: string) { + super(message); + this.name = "BridgeConfigError"; + } +} + +export class BridgeAnalysisCancelledError extends Error { + readonly code = "BRG_CANCELLED"; + + constructor() { + super("Bridge safety analysis was cancelled"); + this.name = "BridgeAnalysisCancelledError"; + } +} + +export function resolveBridgeLimits( + input?: Partial, +): BridgeAnalysisLimits { + if (input !== undefined && !isRecord(input)) { + throw new BridgeConfigError("limits must be an object"); + } + const result: BridgeAnalysisLimits = { ...DEFAULT_BRIDGE_LIMITS }; + for (const key of LIMIT_KEYS) { + const value = input?.[key]; + if (value === undefined) continue; + if (!Number.isSafeInteger(value) || value <= 0) { + throw new BridgeConfigError(`${key} must be a positive safe integer`); + } + result[key] = value; + } + return result; +} + +export function migrateBridgeConfig( + input: BridgeAnalysisConfigInput, +): ValidatedBridgeConfig { + if (!isRecord(input)) throw new BridgeConfigError("configuration root must be an object"); + if (input.schemaVersion === BRIDGE_CONFIG_SCHEMA_VERSION) return validateV1(input); + if (input.schemaVersion !== undefined && input.schemaVersion !== 0) { + throw new BridgeConfigError( + `unsupported bridge configuration schemaVersion ${String(input.schemaVersion)}`, + ); + } + rejectUnknownKeys(input, [ + "schemaVersion", "version", "maxFileSize", "maxIssues", "detectors", "includeModels", + ], "configuration"); + + const limits: Partial = {}; + if (input.maxFileSize !== undefined) { + limits.maxSourceBytes = positiveInteger(input.maxFileSize, "maxFileSize"); + } + if (input.maxIssues !== undefined) { + limits.maxFindings = positiveInteger(input.maxIssues, "maxIssues"); + } + const includeRules = input.detectors === undefined + ? undefined + : validateRules(input.detectors, "detectors"); + const migrated = input.version === 0 || input.maxFileSize !== undefined || + input.maxIssues !== undefined || input.detectors !== undefined; + const diagnostics: BridgeDiagnostic[] = migrated ? [{ + code: "BRG_CONFIG_INVALID", + severity: "info", + message: "Migrated bridge configuration from legacy schema v0 to v1", + }] : []; + + const config: BridgeAnalysisConfigV1 = { + schemaVersion: BRIDGE_CONFIG_SCHEMA_VERSION, + ...(Object.keys(limits).length ? { limits } : {}), + ...(typeof input.includeModels === "boolean" ? { includeModels: input.includeModels } : {}), + ...(includeRules ? { includeRules } : {}), + }; + resolveBridgeLimits(config.limits); + return { config, diagnostics }; +} + +export function validateBridgeConfig( + input: BridgeAnalysisConfigInput, +): ValidatedBridgeConfig { + return migrateBridgeConfig(input); +} + +export function loadBridgeConfigFile(filePath: string): ValidatedBridgeConfig { + let content: string; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (error) { + throw new BridgeConfigError(`configuration file could not be read (${errorCode(error)})`); + } + try { + return validateBridgeConfig(JSON.parse(content) as BridgeAnalysisConfigInput); + } catch (error) { + if (error instanceof BridgeConfigError) throw error; + throw new BridgeConfigError("configuration file contains invalid JSON"); + } +} + +function validateV1(input: Record): ValidatedBridgeConfig { + rejectUnknownKeys(input, [ + "schemaVersion", "limits", "includeModels", "includeRules", "excludeRules", + ], "configuration"); + if (input.includeModels !== undefined && typeof input.includeModels !== "boolean") { + throw new BridgeConfigError("includeModels must be a boolean"); + } + const limits = input.limits === undefined ? undefined : validateLimits(input.limits); + const includeRules = input.includeRules === undefined + ? undefined + : validateRules(input.includeRules, "includeRules"); + const excludeRules = input.excludeRules === undefined + ? undefined + : validateRules(input.excludeRules, "excludeRules"); + if (includeRules && excludeRules) { + const overlap = includeRules.filter((rule) => excludeRules.includes(rule)); + if (overlap.length) { + throw new BridgeConfigError(`includeRules and excludeRules overlap: ${overlap.join(", ")}`); + } + } + return { + config: { + schemaVersion: BRIDGE_CONFIG_SCHEMA_VERSION, + ...(limits ? { limits } : {}), + ...(typeof input.includeModels === "boolean" ? { includeModels: input.includeModels } : {}), + ...(includeRules ? { includeRules } : {}), + ...(excludeRules ? { excludeRules } : {}), + }, + diagnostics: [], + }; +} + +function validateLimits(value: unknown): Partial { + if (!isRecord(value)) throw new BridgeConfigError("limits must be an object"); + rejectUnknownKeys(value, LIMIT_KEYS, "limits"); + const limits: Partial = {}; + for (const key of LIMIT_KEYS) { + if (value[key] !== undefined) limits[key] = positiveInteger(value[key], key); + } + resolveBridgeLimits(limits); + return limits; +} + +function validateRules(value: unknown, field: string): BridgeRuleId[] { + if (!Array.isArray(value)) throw new BridgeConfigError(`${field} must be an array`); + const result = new Set(); + for (const rule of value) { + if (typeof rule !== "string" || !RULE_IDS.has(rule)) { + throw new BridgeConfigError(`${field} contains unknown rule ${String(rule)}`); + } + result.add(rule as BridgeRuleId); + } + return [...result].sort(); +} + +function positiveInteger(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new BridgeConfigError(`${field} must be a positive safe integer`); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorCode(error: unknown): string { + const code = (error as { code?: unknown } | null)?.code; + return typeof code === "string" && /^[A-Z0-9_]+$/.test(code) ? code : "IO_ERROR"; +} + +function rejectUnknownKeys( + value: Record, + allowed: readonly (string | number | symbol)[], + field: string, +): void { + const permitted = new Set(allowed.map(String)); + const unknown = Object.keys(value).filter((key) => !permitted.has(key)).sort(); + if (unknown.length) throw new BridgeConfigError(`${field} contains unknown field ${unknown[0]}`); +} diff --git a/packages/core/src/bridge/index.ts b/packages/core/src/bridge/index.ts new file mode 100644 index 0000000..5ca61da --- /dev/null +++ b/packages/core/src/bridge/index.ts @@ -0,0 +1,57 @@ +export { + analyzeBridgeSource, + analyzeBridgeSources, + analyzeBridgeFiles, + collectBridgeSolidityFiles, + BRIDGE_ENGINE_VERSION, + BRIDGE_SEVERITY_ORDER, +} from "./api"; +export { analyzeBridgeModel } from "./analyzer"; +export { buildBridgeModels } from "./model"; +export { + BRIDGE_FRAMEWORK_ADAPTERS, + getBridgeFrameworkAdapter, + matchBridgeFramework, +} from "./adapters"; +export { + DEFAULT_BRIDGE_LIMITS, + BridgeAnalysisCancelledError, + BridgeConfigError, + loadBridgeConfigFile, + migrateBridgeConfig, + resolveBridgeLimits, + validateBridgeConfig, +} from "./config"; +export { generateBridgeMarkdown, serializeBridgeReport } from "./serialize"; +export { detectBridgeSafety } from "./rule"; +export { + BRIDGE_CONFIG_SCHEMA_VERSION, + BRIDGE_REPORT_SCHEMA_VERSION, +} from "./types"; +export type { + BridgeAnalysisConfigInput, + BridgeAnalysisConfigV0, + BridgeAnalysisConfigV1, + BridgeAnalysisLimits, + BridgeAnalysisOptions, + BridgeAnalysisReport, + BridgeCancellationSignal, + BridgeContractModel, + BridgeDiagnostic, + BridgeEvidence, + BridgeFileAnalysis, + BridgeFinding, + BridgeFrameworkAdapter, + BridgeFrameworkAdapterDefinition, + BridgeFrameworkMatch, + BridgeFunctionRole, + BridgeOperation, + BridgeRuleId, + BridgeSourceInput, + BridgeSourceLocation, + BridgeStateVariable, + BridgeTransition, + BridgeVariableRole, + ValidatedBridgeConfig, +} from "./types"; +export type { BuildBridgeModelsResult } from "./model"; diff --git a/packages/core/src/bridge/message-flow.ts b/packages/core/src/bridge/message-flow.ts new file mode 100644 index 0000000..825af39 --- /dev/null +++ b/packages/core/src/bridge/message-flow.ts @@ -0,0 +1,69 @@ +import type { BridgeContractModel, BridgeTransition } from "./types"; + +export type MessageDirection = "outbound" | "inbound" | "bidirectional"; + +export interface MessageFlowEdge { + direction: MessageDirection; + transition: BridgeTransition; + bindsSource: boolean; + bindsDestination: boolean; + consumesNonce: boolean; + consumesMessageId: boolean; +} + +/** Model outbound and inbound message flows across bridge transitions. */ +export function buildMessageFlows(model: BridgeContractModel): MessageFlowEdge[] { + const edges: MessageFlowEdge[] = []; + for (const transition of model.transitions) { + const direction = classifyDirection(transition.role); + if (direction === "bidirectional") continue; + edges.push({ + direction, + transition, + bindsSource: hasSourceBinding(transition), + bindsDestination: hasDestinationBinding(transition), + consumesNonce: hasNonceConsumption(transition), + consumesMessageId: hasMessageIdConsumption(transition), + }); + } + return edges.sort((left, right) => + left.transition.location.line - right.transition.location.line || + left.transition.name.localeCompare(right.transition.name), + ); +} + +function classifyDirection(role: BridgeTransition["role"]): MessageDirection { + if (["send-message", "lock-tokens", "burn-tokens"].includes(role)) return "outbound"; + if (["receive-message", "execute-message", "relay-message"].includes(role)) return "inbound"; + return "bidirectional"; +} + +function hasSourceBinding(transition: BridgeTransition): boolean { + const source = codeText(transition.source); + return /sourceChainId|originChain|fromChain|srcChain|origin\s*==|msg\.sender\s*==.*bridge/i.test(source); +} + +function hasDestinationBinding(transition: BridgeTransition): boolean { + const source = codeText(transition.source); + return /destChainId|destinationChain|toChain|dstChain|targetChain|endpointId/i.test(source); +} + +function hasNonceConsumption(transition: BridgeTransition): boolean { + const source = codeText(transition.source); + const nonceWrite = transition.operations.find((op) => + op.kind === "write" && /nonce/i.test(op.expression), + ); + return Boolean(nonceWrite) || /nonces\[|nonce\+\+|incrementNonce|inboundNonce/i.test(source); +} + +function hasMessageIdConsumption(transition: BridgeTransition): boolean { + const source = codeText(transition.source); + const idWrite = transition.operations.find((op) => + op.kind === "write" && /processed|consumed|executed|handled/i.test(op.expression), + ); + return Boolean(idWrite) || /processedMessages|consumedMessages|executedMessages/i.test(source); +} + +function codeText(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n\r]*/g, " ").replace(/\s+/g, " "); +} diff --git a/packages/core/src/bridge/mitigations.ts b/packages/core/src/bridge/mitigations.ts new file mode 100644 index 0000000..adbb5c7 --- /dev/null +++ b/packages/core/src/bridge/mitigations.ts @@ -0,0 +1,111 @@ +import type { BridgeTransition } from "./types"; + +/** Recognized cross-chain mitigations that suppress or downgrade findings. */ +export type BridgeMitigationKind = + | "pause-guard" + | "rate-limit" + | "replay-map" + | "two-phase-validator-update" + | "delayed-finality" + | "nonce-consumption" + | "domain-binding" + | "proof-verification" + | "signature-verification"; + +export interface BridgeMitigationEvidence { + kind: BridgeMitigationKind; + description: string; + expression: string; +} + +/** Detect structural mitigations present in a bridge transition's source and operations. */ +export function detectMitigations(transition: BridgeTransition): BridgeMitigationEvidence[] { + const source = codeText(transition.source); + const mitigations: BridgeMitigationEvidence[] = []; + + if (/whenNotPaused|!paused|!isPaused|require\s*\(\s*!.*paused/i.test(source) || + transition.modifiers.some((m) => /pause|whennotpaused/i.test(m))) { + mitigations.push({ + kind: "pause-guard", + description: "Transition is guarded by a pause check", + expression: "pause guard", + }); + } + + if (/rateLimit|messageLimit|dailyLimit|hourlyLimit|maxMessagesPer/i.test(source)) { + mitigations.push({ + kind: "rate-limit", + description: "Rate limiting is referenced in the transition", + expression: "rate limit", + }); + } + + if (/processedMessages|consumedMessages|handledMessages|executedMessages|replayMap|seenMessages/i.test(source)) { + mitigations.push({ + kind: "replay-map", + description: "Replay or processed-message map is referenced", + expression: "replay map", + }); + } + + if (/pendingValidators|newValidators|validatorEpoch|twoPhase|commitValidators/i.test(source)) { + mitigations.push({ + kind: "two-phase-validator-update", + description: "Two-phase validator set update pattern detected", + expression: "two-phase validator update", + }); + } + + if (/finalityWindow|challengePeriod|confirmationBlocks|block\.number\s*>=\s*.*\+|block\.timestamp\s*>=\s*.*\+/i.test(source)) { + mitigations.push({ + kind: "delayed-finality", + description: "Delayed finality or challenge window referenced", + expression: "delayed finality", + }); + } + + if (/nonce\+\+|nonces\[|inboundNonce|outboundNonce|incrementNonce/i.test(source)) { + mitigations.push({ + kind: "nonce-consumption", + description: "Nonce consumption or increment detected", + expression: "nonce consumption", + }); + } + + if (/sourceChainId|originChain|destinationChain|domainSeparator|endpointId|block\.chainid/i.test(source)) { + mitigations.push({ + kind: "domain-binding", + description: "Chain domain or source/destination binding referenced", + expression: "domain binding", + }); + } + + if (/verifyProof|verifyMerkleProof|MerkleProof|processProof|checkProof/i.test(source)) { + mitigations.push({ + kind: "proof-verification", + description: "Merkle or state proof verification referenced", + expression: "proof verification", + }); + } + + if (/verifySignatures|checkSignatures|ecrecover|recoverSigner|validateSignatures/i.test(source)) { + mitigations.push({ + kind: "signature-verification", + description: "Signature or validator verification referenced", + expression: "signature verification", + }); + } + + return mitigations; +} + +export function hasMitigation( + transition: BridgeTransition, + kind: BridgeMitigationKind, +): boolean { + return detectMitigations(transition).some((item) => item.kind === kind); +} + +function codeText(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n\r]*/g, " ").replace(/\s+/g, " "); +} diff --git a/packages/core/src/bridge/model.ts b/packages/core/src/bridge/model.ts new file mode 100644 index 0000000..29d719b --- /dev/null +++ b/packages/core/src/bridge/model.ts @@ -0,0 +1,523 @@ +import { parseSolidity } from "../ast/parser"; +import type { ASTNode } from "../types"; +import { matchBridgeFramework } from "./adapters"; +import { BridgeAnalysisCancelledError } from "./config"; +import { detectMitigations } from "./mitigations"; +import type { + BridgeAnalysisLimits, + BridgeCancellationSignal, + BridgeContractModel, + BridgeDiagnostic, + BridgeFunctionRole, + BridgeOperation, + BridgeSourceLocation, + BridgeStateVariable, + BridgeTransition, + BridgeVariableRole, +} from "./types"; + +interface NodeRecord { + type?: string; + name?: string; + namePath?: string; + memberName?: string; + operator?: string; + visibility?: string; + isConstructor?: boolean; + range?: [number, number]; + loc?: { + start?: { line?: number; column?: number }; + end?: { line?: number; column?: number }; + }; + subNodes?: ASTNode[]; + variables?: ASTNode[]; + parameters?: ASTNode[]; + modifiers?: ASTNode[]; + expression?: ASTNode; + left?: ASTNode; + condition?: ASTNode; + typeName?: ASTNode; + baseTypeName?: ASTNode; + keyType?: ASTNode; + valueType?: ASTNode; + [key: string]: unknown; +} + +export interface BuildBridgeModelsResult { + models: BridgeContractModel[]; + diagnostics: BridgeDiagnostic[]; +} + +export function buildBridgeModels( + source: string, + file: string, + limits: BridgeAnalysisLimits, + signal?: BridgeCancellationSignal, +): BuildBridgeModelsResult { + checkCancelled(signal); + if (Buffer.byteLength(source, "utf8") > limits.maxSourceBytes) { + return limited("BRG_SOURCE_LIMIT", `Source exceeds the ${limits.maxSourceBytes}-byte limit`, file); + } + const shape = preflightSourceShape(source); + if (shape.contracts > limits.maxContracts) { + return limited("BRG_CONTRACT_LIMIT", `Source declares more than ${limits.maxContracts} contracts`, file); + } + if (shape.functions > limits.maxFunctionsPerFile) { + return limited("BRG_FUNCTION_LIMIT", `Source declares more than ${limits.maxFunctionsPerFile} functions`, file); + } + + const parsed = parseSolidity(source, ""); + if (!parsed.ast) { + return parseFailure(file, sanitizeParseError(parsed.error)); + } + const tolerantErrors = (parsed.ast as { + errors?: Array<{ message?: string; line?: number; column?: number }>; + }).errors; + if (tolerantErrors?.length) { + const first = tolerantErrors[0]; + return { + models: [], + diagnostics: [{ + code: "BRG_PARSE_ERROR", + severity: "error", + message: `Solidity source could not be parsed: ${sanitizeParserMessage(first.message)}`, + location: { file, line: first.line ?? 1, column: (first.column ?? 0) + 1 }, + }], + }; + } + + const contracts = collectNodes(parsed.ast, "ContractDefinition", signal); + const diagnostics: BridgeDiagnostic[] = []; + if (contracts.length > limits.maxContracts) { + diagnostics.push({ + code: "BRG_CONTRACT_LIMIT", + severity: "warning", + message: `Only the first ${limits.maxContracts} contracts were analyzed`, + location: startLocation(file), + }); + } + const models: BridgeContractModel[] = []; + for (const contract of contracts.slice(0, limits.maxContracts)) { + checkCancelled(signal); + const built = buildContract(source, file, contract, limits); + diagnostics.push(...built.diagnostics); + if (isRelevant(built.model)) models.push(built.model); + } + return { models, diagnostics }; +} + +function buildContract( + source: string, + file: string, + contractNode: ASTNode, + limits: BridgeAnalysisLimits, +): { model: BridgeContractModel; diagnostics: BridgeDiagnostic[] } { + const contract = contractNode as NodeRecord; + const stateVariables: BridgeStateVariable[] = []; + const functions: ASTNode[] = []; + const diagnostics: BridgeDiagnostic[] = []; + for (const member of contract.subNodes ?? []) { + const item = member as NodeRecord; + if (item.type === "StateVariableDeclaration") { + for (const rawVariable of item.variables ?? []) { + const variable = rawVariable as NodeRecord; + if (!variable.name) continue; + const typeName = stringifyType(variable.typeName); + stateVariables.push({ + name: variable.name, + typeName, + role: classifyVariable(variable.name, typeName), + isMapping: typeName.startsWith("mapping("), + location: nodeLocation(variable, file), + }); + } + } else if (item.type === "FunctionDefinition" && !item.isConstructor) { + functions.push(member); + } + } + + if (functions.length > limits.maxFunctionsPerContract) { + diagnostics.push({ + code: "BRG_FUNCTION_LIMIT", + severity: "warning", + message: `Contract ${contract.name ?? ""} exceeds the function limit`, + location: nodeLocation(contract, file), + }); + } + const stateNames = new Set(stateVariables.map((variable) => variable.name)); + const transitions: BridgeTransition[] = []; + for (const fn of functions.slice(0, limits.maxFunctionsPerContract)) { + const built = buildTransition(source, file, fn, stateNames, limits); + transitions.push(built.transition); + if (built.truncated) { + diagnostics.push({ + code: "BRG_OPERATION_LIMIT", + severity: "warning", + message: `Function ${built.transition.name} exceeded the operation limit`, + location: built.transition.location, + }); + } + } + + const base: BridgeContractModel = { + name: contract.name ?? "", + file, + adapter: "none", + stateVariables: stateVariables.sort(byLocationThenName), + transitions: transitions.sort(byLocationThenName), + privilegedCalls: [], + messageControlledCalls: [], + assumptions: [], + location: nodeLocation(contract, file), + }; + base.privilegedCalls = transitions.flatMap((transition) => + transition.operations.filter((operation) => + operation.kind === "call" && isPrivilegedCall(operation.name, operation.expression), + ), + ).sort(byOperation); + base.messageControlledCalls = base.privilegedCalls.filter((operation) => + operation.parameterSources.length > 0, + ); + base.adapter = matchBridgeFramework(base).adapter; + base.assumptions = inferAssumptions(base); + return { model: base, diagnostics }; +} + +function buildTransition( + source: string, + file: string, + node: ASTNode, + stateNames: Set, + limits: BridgeAnalysisLimits, +): { transition: BridgeTransition; truncated: boolean } { + const fn = node as NodeRecord; + const parameters = (fn.parameters ?? []) + .map((parameter) => (parameter as NodeRecord).name) + .filter((name): name is string => Boolean(name)); + const parameterSet = new Set(parameters); + const reads = new Set(); + const writes = new Set(); + const calls = new Set(); + const operations: BridgeOperation[] = []; + let truncated = false; + + walkNode(node, (child) => { + if (operations.length >= limits.maxOperationsPerFunction) { + truncated = true; + return false; + } + const record = child as NodeRecord; + if (record.type === "Assignment" || + (record.type === "BinaryOperation" && isAssignmentOperator(record.operator))) { + const names = expressionNames(record.left); + for (const name of names) if (stateNames.has(name)) writes.add(name); + addOperation(operations, "write", [...names].join(",") || "assignment", child, source, file, parameterSet); + } else if (record.type === "UnaryOperation" && ["++", "--", "delete"].includes(record.operator ?? "")) { + const names = expressionNames(record.expression); + for (const name of names) if (stateNames.has(name)) writes.add(name); + addOperation(operations, "write", [...names].join(",") || "unary", child, source, file, parameterSet); + } else if (record.type === "FunctionCall") { + const name = calledName(record.expression); + if (name) { + calls.add(name); + addOperation( + operations, + name === "require" || name === "assert" ? "guard" : "call", + name, + child, + source, + file, + parameterSet, + ); + } + } else if (record.type === "ForStatement" || record.type === "WhileStatement" || record.type === "DoWhileStatement") { + addOperation(operations, "loop", record.type ?? "loop", child, source, file, parameterSet); + } else if (record.type === "IfStatement" && record.condition) { + addOperation(operations, "guard", "if", record.condition, source, file, parameterSet); + } else if (record.type === "BinaryOperation") { + addOperation(operations, "arithmetic", record.operator ?? "binary", child, source, file, parameterSet); + } else if (record.type === "Identifier" && record.name && stateNames.has(record.name)) { + reads.add(record.name); + } else if (record.type === "MemberAccess" && record.memberName && stateNames.has(record.memberName)) { + reads.add(record.memberName); + } + return true; + }); + + const name = fn.name ?? ""; + const transitionSource = nodeSnippet(source, fn); + const built: BridgeTransition = { + name, + role: classifyFunction(name), + visibility: fn.visibility ?? "default", + modifiers: (fn.modifiers ?? []).map(modifierName).filter((value): value is string => Boolean(value)).sort(), + parameters, + reads: [...reads].sort(), + writes: [...writes].sort(), + calls: [...calls].sort(), + operations: operations.sort(byOperation), + location: nodeLocation(fn, file), + source: transitionSource, + mitigations: [], + }; + built.mitigations = detectMitigations(built).map((item) => item.kind); + return { + transition: built, + truncated, + }; +} + +function addOperation( + operations: BridgeOperation[], + kind: BridgeOperation["kind"], + name: string, + node: ASTNode, + source: string, + file: string, + parameters: Set, +): void { + const record = node as NodeRecord; + const expression = compact(nodeSnippet(source, record)); + operations.push({ + order: record.range?.[0] ?? operations.length, + kind, + name, + expression, + parameterSources: [...parameters].filter((parameter) => + new RegExp(`\\b${escapeRegExp(parameter)}\\b`).test(expression), + ).sort(), + location: nodeLocation(record, file), + }); +} + +function classifyVariable(name: string, typeName: string): BridgeVariableRole { + const value = normalize(name); + if (/(sourcechainid|sourcechain|originchain|fromchain)/.test(value)) return "source-chain"; + if (/(destchainid|destchain|destinationchain|tochain|targetchain)/.test(value)) return "destination-chain"; + if (/(chainid|domainseparator|domain|endpointid)/.test(value)) return "chain-domain"; + if (/(messageid|processedmessages|consumedmessages|handledmessages|executedmessages)/.test(value)) return "message-id"; + if (/(nonce|nonces|inboundnonce|outboundnonce)/.test(value)) return "nonce"; + if (/(processed|executed|handled|consumed)/.test(value) && typeName.startsWith("mapping(")) return "processed-messages"; + if (/(replay|seenmessages|usednonces)/.test(value)) return "replay-map"; + if (/(validators|signers|owners|guardians|committee)/.test(value) && + (typeName.startsWith("mapping(") || typeName.endsWith("[]"))) return "validator-set"; + if (/(validatorthreshold|signaturethreshold|multisigthreshold|quorum|threshold)/.test(value)) return "validator-threshold"; + if (/(merkleroot|root|stateRoot|latestroot)/.test(value)) return "merkle-root"; + if (/(stateroot|confirmedroot|finalizedroot)/.test(value)) return "state-root"; + if (/(finalitywindow|finalitydelay|confirmationblocks|challengeperiod)/.test(value)) return "finality-window"; + if (/(lockedamount|totallocked|deposits|escrowed)/.test(value)) return "lock-amount"; + if (/(mintedamount|totalminted|wrappedsupply)/.test(value)) return "mint-amount"; + if (/(burnedamount|totalburned)/.test(value)) return "burn-amount"; + if (/(releasedamount|totalreleased|unlocked)/.test(value)) return "release-amount"; + if (/(paused|isPaused|bridgePaused)/.test(value)) return "bridge-paused"; + if (/(ratelimit|messagelimit|dailyLimit|hourlyLimit)/.test(value)) return "rate-limit"; + if (/(messagequeue|pendingmessages|inboundqueue)/.test(value)) return "message-queue"; + if (/(relayer|authorizedrelayer|trustedrelayer)/.test(value)) return "relayer-role"; + if (/(upgradeauthority|upgrader|proxyadmin)/.test(value)) return "upgrade-authority"; + return "unknown"; +} + +function classifyFunction(name: string): BridgeFunctionRole { + const value = normalize(name); + if (/^(sendmessage|dispatch|send|sendtokens|bridgeout|lzsend|sendpayload)$/.test(value)) return "send-message"; + if (/^(receivemessage|handlemessage|onmessage|lzreceive|processmessage|delivermessage)$/.test(value)) return "receive-message"; + if (/^(verifyproof|verifymerkleproof|checkproof|validateproof)$/.test(value)) return "verify-proof"; + if (/^(checksignatures|validatesignatures|verifysignatures|verifyvalidators)$/.test(value)) return "verify-signatures"; + if (/^(lock|locktokens|deposit|bridgein|escrow)$/.test(value)) return "lock-tokens"; + if (/^(mint|minttokens|wrap|mintwrapped)$/.test(value)) return "mint-tokens"; + if (/^(burn|burntokens|unwrap|burnwrapped)$/.test(value)) return "burn-tokens"; + if (/^(release|releasetokens|unlock|withdraw|claim)$/.test(value)) return "release-tokens"; + if (/^(updatevalidators|setvalidators|addvalidator|removevalidator|rotatevalidators)$/.test(value)) return "update-validator-set"; + if (/^(updatethreshold|setthreshold|changethreshold)$/.test(value)) return "update-threshold"; + if (/^(updateroot|setroot|updatemerkleroot|commitroot)$/.test(value)) return "update-root"; + if (/^(executemessage|relaymessage|finalizemessage|completeTransfer)$/.test(value)) return "execute-message"; + if (/^(relay|retry|resend|resubmitmessage)$/.test(value)) return "relay-message"; + if (/^(pause|pausebridge|emergencypause)$/.test(value)) return "pause-bridge"; + if (/^(unpause|unpausebridge|resume)$/.test(value)) return "unpause-bridge"; + if (/^(upgradeto|upgradetoandcall|authorizeupgrade|upgrade)$/.test(value)) return "upgrade"; + return "unknown"; +} + +function isRelevant(model: BridgeContractModel): boolean { + if (model.transitions.length === 1 && model.stateVariables.length === 0) return false; + const roles = new Set(model.transitions.map((transition) => transition.role)); + const variableRoles = model.stateVariables.filter((variable) => variable.role !== "unknown"); + return roles.has("send-message") || roles.has("receive-message") || roles.has("verify-proof") || + roles.has("verify-signatures") || roles.has("lock-tokens") || roles.has("mint-tokens") || + roles.has("burn-tokens") || roles.has("release-tokens") || roles.has("execute-message") || + roles.has("relay-message") || variableRoles.length >= 3; +} + +function inferAssumptions(model: BridgeContractModel): string[] { + const assumptions: string[] = []; + if (model.transitions.some((transition) => transition.role === "receive-message")) { + assumptions.push("The cross-chain transport may redeliver a previously accepted message"); + } + if (model.transitions.some((transition) => ["mint-tokens", "release-tokens"].includes(transition.role))) { + assumptions.push("Token minting or release implies economic value transfer across chains"); + } + if (model.transitions.some((transition) => transition.role === "verify-signatures")) { + assumptions.push("Validator signatures are the sole authorization for message acceptance"); + } + if (model.transitions.some((transition) => transition.role === "execute-message")) { + assumptions.push("Message payloads can invoke privileged external state transitions"); + } + return assumptions; +} + +function isPrivilegedCall(name: string, expression: string): boolean { + return /^(call|delegatecall|functionCall|functionCallWithValue|upgradeTo|upgradeToAndCall|execute)$/i.test(name) || + /\.call\s*\{|\.delegatecall\s*\(|upgradeTo(?:AndCall)?\s*\(/i.test(expression); +} + +function stringifyType(node: ASTNode | undefined): string { + if (!node) return "unknown"; + const type = node as NodeRecord; + if (type.type === "Mapping") return `mapping(${stringifyType(type.keyType)}=>${stringifyType(type.valueType)})`; + if (type.type === "ArrayTypeName") return `${stringifyType(type.baseTypeName)}[]`; + return type.name ?? type.namePath ?? type.type ?? "unknown"; +} + +function calledName(node: ASTNode | undefined): string | undefined { + const value = node as NodeRecord | undefined; + return value?.name ?? value?.memberName ?? value?.namePath ?? calledName(value?.expression); +} + +function modifierName(node: ASTNode): string | undefined { + const value = node as NodeRecord; + return value.name ?? value.namePath; +} + +function expressionNames(root: ASTNode | undefined): Set { + const names = new Set(); + if (!root) return names; + walkNode(root, (node) => { + const value = node as NodeRecord; + if (value.type === "Identifier" && value.name) names.add(value.name); + if (value.type === "MemberAccess" && value.memberName) names.add(value.memberName); + return true; + }); + return names; +} + +function collectNodes(root: ASTNode, type: string, signal?: BridgeCancellationSignal): ASTNode[] { + const nodes: ASTNode[] = []; + walkNode(root, (node) => { + checkCancelled(signal); + if ((node as NodeRecord).type === type) nodes.push(node); + return true; + }); + return nodes; +} + +function walkNode(root: unknown, visitor: (node: ASTNode) => boolean): void { + const stack: unknown[] = [root]; + const seen = new WeakSet(); + while (stack.length) { + const value = stack.pop(); + if (!value || typeof value !== "object") continue; + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index--) stack.push(value[index]); + continue; + } + if (seen.has(value)) continue; + seen.add(value); + if (!visitor(value as ASTNode)) continue; + const record = value as Record; + for (const key of Object.keys(record).filter((key) => key !== "loc" && key !== "range").sort().reverse()) { + stack.push(record[key]); + } + } +} + +function preflightSourceShape(source: string): { contracts: number; functions: number } { + const code = source.replace( + /\/\*[\s\S]*?\*\/|\/\/[^\n\r]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, + " ", + ); + return { + contracts: countMatches(code, /\b(?:contract|interface|library)\s+[A-Za-z_$][\w$]*/g), + functions: countMatches(code, /\bfunction\b/g), + }; +} + +function countMatches(value: string, expression: RegExp): number { + let count = 0; + while (expression.exec(value)) count += 1; + return count; +} + +function nodeLocation(node: NodeRecord, file: string): BridgeSourceLocation { + return { + file, + line: node.loc?.start?.line ?? 1, + column: (node.loc?.start?.column ?? 0) + 1, + ...(node.loc?.end?.line ? { lineEnd: node.loc.end.line } : {}), + ...(node.loc?.end?.column !== undefined ? { columnEnd: node.loc.end.column + 1 } : {}), + }; +} + +function nodeSnippet(source: string, node: NodeRecord): string { + if (node.range) return source.slice(node.range[0], node.range[1] + 1); + const start = node.loc?.start?.line; + const end = node.loc?.end?.line; + return start && end ? source.split("\n").slice(start - 1, end).join("\n") : ""; +} + +function compact(value: string): string { + const result = value.replace(/\s+/g, " ").trim(); + return result.length > 280 ? `${result.slice(0, 277)}...` : result; +} + +function isAssignmentOperator(operator: string | undefined): boolean { + return operator !== undefined && ["=", "+=", "-=", "*=", "/=", "%=", "|=", "&=", "^=", "<<=", ">>="].includes(operator); +} + +function byOperation(left: BridgeOperation, right: BridgeOperation): number { + return left.order - right.order || left.kind.localeCompare(right.kind) || left.name.localeCompare(right.name); +} + +function byLocationThenName(left: T, right: T): number { + return left.location.line - right.location.line || left.location.column - right.location.column || + left.name.localeCompare(right.name); +} + +function limited( + code: "BRG_SOURCE_LIMIT" | "BRG_CONTRACT_LIMIT" | "BRG_FUNCTION_LIMIT", + message: string, + file: string, +): BuildBridgeModelsResult { + return { models: [], diagnostics: [{ code, severity: "warning", message, location: startLocation(file) }] }; +} + +function parseFailure(file: string, message: string): BuildBridgeModelsResult { + return { + models: [], + diagnostics: [{ code: "BRG_PARSE_ERROR", severity: "error", message, location: startLocation(file) }], + }; +} + +function sanitizeParseError(error: string | undefined): string { + const detail = error?.replace(/^Parse error in :\s*/, "").replace(/\s+/g, " ").trim(); + return `Solidity source could not be parsed${detail ? `: ${detail.slice(0, 300)}` : ""}`; +} + +function sanitizeParserMessage(message: string | undefined): string { + return (message ?? "syntax error").replace(/[\r\n]+/g, " ").slice(0, 300); +} + +function startLocation(file: string): BridgeSourceLocation { + return { file, line: 1, column: 1 }; +} + +function checkCancelled(signal?: BridgeCancellationSignal): void { + if (signal?.aborted) throw new BridgeAnalysisCancelledError(); +} + +function normalize(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/core/src/bridge/payload-tracer.ts b/packages/core/src/bridge/payload-tracer.ts new file mode 100644 index 0000000..807f139 --- /dev/null +++ b/packages/core/src/bridge/payload-tracer.ts @@ -0,0 +1,57 @@ +import type { BridgeOperation, BridgeTransition } from "./types"; + +export type PrivilegedEffect = + | "arbitrary-call" + | "token-mint" + | "token-release" + | "upgrade" + | "role-grant"; + +export interface PayloadTrace { + transition: BridgeTransition; + privilegedCall: BridgeOperation; + effect: PrivilegedEffect; + payloadSources: string[]; + validated: boolean; +} + +/** Trace message payload parameters into privileged state-changing effects. */ +export function tracePayloadEffects(transition: BridgeTransition): PayloadTrace[] { + const traces: PayloadTrace[] = []; + const source = codeText(transition.source); + const validated = /verifyProof|verifySignatures|checkSignatures|require\s*\(\s*processed|require\s*\(\s*!.*processed/i.test(source); + + for (const operation of transition.operations) { + if (operation.kind !== "call") continue; + const effect = classifyEffect(operation); + if (!effect) continue; + traces.push({ + transition, + privilegedCall: operation, + effect, + payloadSources: operation.parameterSources, + validated, + }); + } + return traces; +} + +function classifyEffect(operation: BridgeOperation): PrivilegedEffect | null { + const expr = operation.expression.toLowerCase(); + if (/\.call\s*\{|\.call\(|functioncall|delegatecall/.test(expr)) return "arbitrary-call"; + if (/\.mint\s*\(|minttokens|_mint\s*\(/.test(expr)) return "token-mint"; + if (/\.transfer\s*\(|release|withdraw|unlock|_transfer\s*\(/.test(expr) && /release|unlock|withdraw/.test(expr)) { + return "token-release"; + } + if (/upgradeto|upgradetoandcall|authorizeupgrade/.test(expr)) return "upgrade"; + if (/grantrole|_grantrole/.test(expr)) return "role-grant"; + if (/^call$|^delegatecall$|^execute$/i.test(operation.name)) return "arbitrary-call"; + if (/^mint$/i.test(operation.name)) return "token-mint"; + if (/^release$|^withdraw$|^unlock$/i.test(operation.name)) return "token-release"; + if (/^upgrade$/i.test(operation.name)) return "upgrade"; + return null; +} + +function codeText(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n\r]*/g, " ").replace(/\s+/g, " "); +} diff --git a/packages/core/src/bridge/proof-analysis.ts b/packages/core/src/bridge/proof-analysis.ts new file mode 100644 index 0000000..9416710 --- /dev/null +++ b/packages/core/src/bridge/proof-analysis.ts @@ -0,0 +1,54 @@ +import type { BridgeOperation, BridgeTransition } from "./types"; + +export interface ProofLoopAnalysis { + hasLoop: boolean; + duplicateCheck: boolean; + sortingCheck: boolean; + zeroAddressCheck: boolean; + staleRootCheck: boolean; + thresholdCheck: boolean; + loopExpression: string; +} + +/** Analyze proof and signature verification loops for common vulnerabilities. */ +export function analyzeProofLoop(transition: BridgeTransition): ProofLoopAnalysis { + const source = codeText(transition.source); + const loopOps = transition.operations.filter((op) => op.kind === "loop" || /for\s*\(|while\s*\(/i.test(op.expression)); + const hasLoop = loopOps.length > 0 || /for\s*\(|while\s*\(/i.test(source); + + return { + hasLoop, + duplicateCheck: /seen|visited|duplicate|alreadySigned|hasSigned|signers\[|validators\[/i.test(source), + sortingCheck: /sort|sorted|isSorted|previousSigner|lastSigner|signers\[i\s*-\s*1\]/i.test(source), + zeroAddressCheck: /address\s*\(\s*0\s*\)|zeroAddress|!=\s*0x0|!=\s*address\(0\)/i.test(source), + staleRootCheck: /rootUpdatedAt|rootTimestamp|block\.number\s*-|block\.timestamp\s*-|latestRoot/i.test(source), + thresholdCheck: /threshold|quorum|signatures\.length|validSignatures|count\s*>=/i.test(source), + loopExpression: loopOps[0]?.expression ?? "", + }; +} + +/** Detect unsafe quorum arithmetic in threshold calculations. */ +export function hasUnsafeQuorumArithmetic(transition: BridgeTransition): BridgeOperation | undefined { + return transition.operations.find((operation) => + operation.kind === "arithmetic" && ( + divisionBeforeMultiplication(operation.expression) || + /threshold\s*(?:==|<=)\s*0|signatures\.length\s*<\s*threshold/i.test(operation.expression) + ), + ); +} + +/** Count signature recovery operations in a transition. */ +export function countSignatureRecoveries(transition: BridgeTransition): number { + const source = codeText(transition.source); + const matches = source.match(/ecrecover|recoverSigner|recover\s*\(/gi); + return matches?.length ?? 0; +} + +function divisionBeforeMultiplication(expression: string): boolean { + const value = expression.replace(/\s+/g, ""); + return /^[^;=]+\/[^;=]+\*/.test(value) || /\([^()]+\/[^()]+\)\s*\*/.test(expression); +} + +function codeText(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n\r]*/g, " ").replace(/\s+/g, " "); +} diff --git a/packages/core/src/bridge/rule.ts b/packages/core/src/bridge/rule.ts new file mode 100644 index 0000000..9097c59 --- /dev/null +++ b/packages/core/src/bridge/rule.ts @@ -0,0 +1,37 @@ +import type { ASTNode, Finding } from "../types"; +import { analyzeBridgeSource } from "./api"; + +const BRIDGE_PREFILTER = + /\b(?:sendMessage|receiveMessage|handleMessage|processMessage|relayMessage|verifyProof|verifySignatures|lockTokens|mintTokens|burnTokens|releaseTokens|processedMessages|sourceChainId|destChainId|destinationChain|merkleRoot|validatorThreshold|inboundNonce|outboundNonce|bridge|crossChain|cross-chain|endpointId|domainSeparator|finalityWindow|challengePeriod)\b/i; + +/** Integrates the specialized bridge engine into the ordinary ChainProof scan. */ +export function detectBridgeSafety( + _ast: ASTNode, + source: string, + filePath: string, +): Finding[] { + // Most contracts are unrelated. Avoid a second parse/model pass unless strong bridge signals exist. + if (!BRIDGE_PREFILTER.test(stripCommentsAndStrings(source))) return []; + const report = analyzeBridgeSource(source, filePath); + return report.files.flatMap((file) => file.findings.map((finding): Finding => ({ + id: finding.ruleId, + title: finding.title, + description: finding.description, + recommendation: finding.recommendation, + severity: finding.severity, + file: finding.location.file, + line: finding.location.line, + ...(finding.location.lineEnd ? { lineEnd: finding.location.lineEnd } : {}), + evidence: finding.evidence.map((evidence) => ({ + description: evidence.description, + file: evidence.location.file, + line: evidence.location.line, + })), + assumptions: finding.assumptions, + confidence: finding.confidence, + }))); +} + +function stripCommentsAndStrings(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n\r]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, " "); +} diff --git a/packages/core/src/bridge/serialize.ts b/packages/core/src/bridge/serialize.ts new file mode 100644 index 0000000..80886e4 --- /dev/null +++ b/packages/core/src/bridge/serialize.ts @@ -0,0 +1,80 @@ +import type { BridgeAnalysisReport, BridgeFinding } from "./types"; + +/** Deterministic, recursively key-sorted JSON suitable for versioned CI artifacts. */ +export function serializeBridgeReport(report: BridgeAnalysisReport): string { + return JSON.stringify(sortValue(report), null, 2) + "\n"; +} + +/** Human-readable bridge report with evidence and explicit scope limitations. */ +export function generateBridgeMarkdown(report: BridgeAnalysisReport): string { + const lines = [ + "# Bridge Safety Analysis", + "", + `Schema: \`${report.schemaVersion}\` `, + `Engine: \`${report.engineVersion}\``, + "", + "## Summary", + "", + `- Solidity files analyzed: ${report.summary.files}`, + `- Bridge contracts modeled: ${report.summary.contracts}`, + `- Findings: ${report.summary.total} (${report.summary.critical} critical, ${report.summary.high} high, ${report.summary.medium} medium, ${report.summary.low} low, ${report.summary.info} info)`, + `- Output truncated by a configured limit: ${report.summary.truncated ? "yes" : "no"}`, + "", + ]; + for (const file of report.files) { + lines.push(`## ${escapeMarkdown(file.file)}`, ""); + for (const diagnostic of file.diagnostics) { + lines.push(`> ${diagnostic.severity.toUpperCase()} ${diagnostic.code}: ${escapeMarkdown(diagnostic.message)}`, ""); + } + if (!file.findings.length) lines.push("No structural bridge findings.", ""); + for (const finding of file.findings) appendFinding(lines, finding); + } + lines.push( + "## Scope", + "", + "This report evaluates cross-chain bridge safety: message verification, domain separation, replay protection, " + + "validator thresholds, proof loops, token lock/mint and burn/release ordering, finality windows, and operational " + + "mitigations. It does not prove transport-layer authenticity, oracle correctness, or live-network finality.", + "", + ); + return lines.join("\n"); +} + +function appendFinding(lines: string[], finding: BridgeFinding): void { + lines.push( + `### ${finding.ruleId}: ${escapeMarkdown(finding.title)}`, + "", + `**Severity:** ${finding.severity} `, + `**Confidence:** ${finding.confidence} `, + `**Contract:** \`${escapeMarkdown(finding.contract)}\` `, + `**Location:** \`${escapeMarkdown(finding.location.file)}:${finding.location.line}:${finding.location.column}\``, + "", + finding.description, + "", + `**Recommendation:** ${finding.recommendation}`, + "", + "**Evidence:**", + ); + for (const evidence of finding.evidence) { + lines.push(`- ${escapeMarkdown(evidence.description)} (${evidence.location.line}:${evidence.location.column})`); + } + if (finding.assumptions.length) { + lines.push("", "**Assumptions:**"); + for (const assumption of finding.assumptions) lines.push(`- ${escapeMarkdown(assumption)}`); + } + lines.push(""); +} + +function sortValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortValue); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, sortValue(child)])); + } + return value; +} + +function escapeMarkdown(value: string): string { + return value.replace(/[|]/g, "\\|").replace(/[\r\n]+/g, " "); +} diff --git a/packages/core/src/bridge/types.ts b/packages/core/src/bridge/types.ts new file mode 100644 index 0000000..699dc8c --- /dev/null +++ b/packages/core/src/bridge/types.ts @@ -0,0 +1,199 @@ +import type { Severity } from "../types"; + +export const BRIDGE_REPORT_SCHEMA_VERSION = "1.0.0" as const; +export const BRIDGE_CONFIG_SCHEMA_VERSION = 1 as const; + +export type BridgeRuleId = + | "CP-BRG-001" | "CP-BRG-002" | "CP-BRG-003" | "CP-BRG-004" + | "CP-BRG-005" | "CP-BRG-006" | "CP-BRG-007" | "CP-BRG-008" + | "CP-BRG-009" | "CP-BRG-010" | "CP-BRG-011" | "CP-BRG-012" + | "CP-BRG-013" | "CP-BRG-014" | "CP-BRG-015" | "CP-BRG-016"; + +export type BridgeVariableRole = + | "source-chain" | "destination-chain" | "chain-domain" | "message-id" + | "nonce" | "processed-messages" | "replay-map" | "validator-set" + | "validator-threshold" | "merkle-root" | "state-root" | "finality-window" + | "lock-amount" | "mint-amount" | "burn-amount" | "release-amount" + | "bridge-paused" | "rate-limit" | "message-queue" | "relayer-role" + | "upgrade-authority" | "unknown"; + +export type BridgeFunctionRole = + | "send-message" | "receive-message" | "verify-proof" | "verify-signatures" + | "lock-tokens" | "mint-tokens" | "burn-tokens" | "release-tokens" + | "update-validator-set" | "update-threshold" | "update-root" + | "execute-message" | "relay-message" | "pause-bridge" | "unpause-bridge" + | "upgrade" | "unknown"; + +export type BridgeFrameworkAdapter = + | "lock-mint-bridge" | "burn-release-bridge" | "optimistic-bridge" + | "multisig-validator-bridge" | "merkle-proof-bridge" | "layerzero-style" + | "wormhole-style" | "axelar-style" | "generic-bridge" | "none"; + +export interface BridgeFrameworkAdapterDefinition { + id: Exclude; + displayName: string; + requiredStateGroups: string[][]; + requiredFunctions: string[]; + mitigations: string[]; + limitations: string[]; +} + +export interface BridgeFrameworkMatch { + adapter: BridgeFrameworkAdapter; + matchedState: string[]; + matchedFunctions: string[]; +} + +export interface BridgeSourceLocation { + file: string; + line: number; + column: number; + lineEnd?: number; + columnEnd?: number; +} + +export interface BridgeEvidence { + kind: + | "state-read" | "state-write" | "arithmetic" | "branch" | "call" + | "modifier" | "ordering" | "adapter" | "absence" | "proof-loop" + | "taint-flow" | "mitigation"; + description: string; + location: BridgeSourceLocation; + snippet?: string; +} + +export interface BridgeStateVariable { + name: string; + typeName: string; + role: BridgeVariableRole; + isMapping: boolean; + location: BridgeSourceLocation; +} + +export interface BridgeOperation { + order: number; + kind: "read" | "write" | "call" | "arithmetic" | "guard" | "loop"; + name: string; + expression: string; + parameterSources: string[]; + location: BridgeSourceLocation; +} + +export interface BridgeTransition { + name: string; + role: BridgeFunctionRole; + visibility: string; + modifiers: string[]; + parameters: string[]; + reads: string[]; + writes: string[]; + calls: string[]; + operations: BridgeOperation[]; + location: BridgeSourceLocation; + source: string; + mitigations: string[]; +} + +export interface BridgeContractModel { + name: string; + file: string; + adapter: BridgeFrameworkAdapter; + stateVariables: BridgeStateVariable[]; + transitions: BridgeTransition[]; + privilegedCalls: BridgeOperation[]; + messageControlledCalls: BridgeOperation[]; + assumptions: string[]; + location: BridgeSourceLocation; +} + +export interface BridgeFinding { + ruleId: BridgeRuleId; + title: string; + description: string; + recommendation: string; + severity: Severity; + confidence: "high" | "medium" | "low"; + category: string; + contract: string; + location: BridgeSourceLocation; + evidence: BridgeEvidence[]; + assumptions: string[]; +} + +export interface BridgeDiagnostic { + code: string; + severity: "error" | "warning" | "info"; + message: string; + location?: BridgeSourceLocation; +} + +export interface BridgeAnalysisLimits { + maxSourceBytes: number; + maxFiles: number; + maxContracts: number; + maxFunctionsPerFile: number; + maxFunctionsPerContract: number; + maxOperationsPerFunction: number; + maxFindings: number; + maxEvidencePerFinding: number; +} + +export interface BridgeCancellationSignal { aborted?: boolean; } + +export interface BridgeAnalysisOptions { + limits?: Partial; + includeRules?: BridgeRuleId[]; + excludeRules?: BridgeRuleId[]; + includeModels?: boolean; + signal?: BridgeCancellationSignal; +} + +export interface BridgeSourceInput { file: string; source: string; } + +export interface BridgeFileAnalysis { + file: string; + findings: BridgeFinding[]; + diagnostics: BridgeDiagnostic[]; + models?: BridgeContractModel[]; +} + +export interface BridgeAnalysisReport { + schemaVersion: typeof BRIDGE_REPORT_SCHEMA_VERSION; + engineVersion: string; + files: BridgeFileAnalysis[]; + summary: { + files: number; + contracts: number; + critical: number; + high: number; + medium: number; + low: number; + info: number; + total: number; + truncated: boolean; + }; +} + +export interface BridgeAnalysisConfigV1 { + schemaVersion: typeof BRIDGE_CONFIG_SCHEMA_VERSION; + limits?: Partial; + includeModels?: boolean; + includeRules?: BridgeRuleId[]; + excludeRules?: BridgeRuleId[]; +} + +export interface BridgeAnalysisConfigV0 { + schemaVersion?: 0; + version?: 0; + maxFileSize?: number; + maxIssues?: number; + detectors?: BridgeRuleId[]; + includeModels?: boolean; +} + +export type BridgeAnalysisConfigInput = BridgeAnalysisConfigV1 | BridgeAnalysisConfigV0; + +export interface ValidatedBridgeConfig { + config: BridgeAnalysisConfigV1; + diagnostics: BridgeDiagnostic[]; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d6bb39..3d002a1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -212,6 +212,9 @@ export type { // ─── Governance / timelock safety analysis ────────────────────────────────── export * from "./governance"; + +// ─── Cross-chain bridge and message verification analysis ─────────────────── +export * from "./bridge"; export type { ParseSpecResult, MigrationResult, diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index 7a162a5..67bef5b 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -30,6 +30,7 @@ import { detectVaultInflation } from "./rules/cp122-vault-inflation"; import { detectCallbackReentrancy } from "./rules/callback-analysis"; import { detectStakingAccounting } from "./staking"; import { detectGovernanceSafety } from "./governance"; +import { detectBridgeSafety } from "./bridge"; import { RuleOptions } from "./rules/rule-context"; import { detectGasIssues } from "./rules/gas-optimizer"; import { enhanceFindingsWithLLM } from "./llm/enhancer"; @@ -184,6 +185,9 @@ async function scanFile( // here rather than once per merged inheritance view, which would duplicate findings. findings.push(...detectGovernanceSafety(ast, source, filePath)); + // Bridge analysis runs once per physical file, similar to governance and staking. + findings.push(...detectBridgeSafety(ast, source, filePath)); + if (config.plugins) { for (const plugin of config.plugins) { for (const rule of plugin.rules) { diff --git a/packages/server/src/rules-registry.ts b/packages/server/src/rules-registry.ts index 31dce59..dd554bd 100644 --- a/packages/server/src/rules-registry.ts +++ b/packages/server/src/rules-registry.ts @@ -152,6 +152,15 @@ export const RULES: RuleMeta[] = [ "If the input to keccak256() is a compile-time constant, precompute it as a " + "constant bytes32 to save ~30 gas per call.", }, + { + id: "CP-BRG-001", + title: "Missing source chain binding on inbound messages", + severity: "critical", + category: "security", + description: + "Detects cross-chain bridge receivers that execute messages without binding authorization " + + "to a source chain ID or authenticated bridge endpoint.", + }, { id: "GAS-SMALL-UINT", title: "Small integer type in storage",