diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e22c3..fedaf5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 fee-on-transfer and rebasing assets, multiple rewards, emergency exits, recovery, and vesting boundaries. See [the staking accounting guide](docs/staking-accounting.md). +- Production governance/timelock safety analysis (`packages/core/src/governance/`) with a + normalized proposal lifecycle and ordered state-transition model, explicit adapters for + OpenZeppelin Governor/TimelockController, Compound Governor Bravo, Safe-style multisigs, and + cross-chain receivers, plus 16 evidence-backed rules (`CP-GOV-001`–`CP-GOV-016`) covering + checkpointing, same-block voting power, quorum/window math, timelock readiness, replay, + proposal/operation identity, arbitrary calldata/value flow, guardian bypasses, delay updates, + predecessor/salt handling, role separation, upgrades, cross-chain domains, and threshold + signatures. Includes bounded deterministic APIs, versioned JSON/Markdown output, config v0→v1 + migration, cancellation, `chainproof governance`, scanner integration, secure/vulnerable + fixtures, and regression tests. See [docs/governance-safety.md](docs/governance-safety.md). - Token callback/hook/reentrancy analysis (`@chainproof/core` `packages/core/src/rules/callback-analysis/`): models the implicit control-flow edges ERC-721/1155 receiver hooks, ERC-777 sender/receiver diff --git a/PR_DESCRIPTION_TASK2.md b/PR_DESCRIPTION_TASK2.md new file mode 100644 index 0000000..071949f --- /dev/null +++ b/PR_DESCRIPTION_TASK2.md @@ -0,0 +1,82 @@ +## Summary + +Closes #84. + +Implements Task 2: a production-grade, deterministic governance, timelock, multisig, and proposal +execution safety analyzer for ChainProof. + +## What changed + +- Added a normalized governance state-transition/data-flow model and framework adapters for + OpenZeppelin Governor/TimelockController, Compound Governor Bravo, Safe-style multisigs, and + cross-chain governance receivers. +- Added 16 evidence-backed rules (`CP-GOV-001`–`CP-GOV-016`) covering live/same-block voting power, + snapshots, quorum/window math, timelock readiness, replay, complete proposal/operation identity, + arbitrary target/value/calldata flow, guardian bypasses, delay updates, predecessors, salts, role + separation, upgrades, cross-chain domains, and threshold signatures. +- Added bounded/cancellable public APIs, schema-versioned deterministic JSON/Markdown reports, + sanitized diagnostics, configuration validation, and v0→v1 migration. +- Added `chainproof governance` with rule selection, resource limits, model output, report artifacts, + and configurable CI severity exit thresholds. +- Integrated governance findings into the normal scanner exactly once per physical file. +- Added paired secure/vulnerable fixtures and comprehensive core/scanner/CLI tests. +- Added user/developer documentation and fixed clean-checkout lint/build ordering. + +## Scope + +The analyzer reports structural implementation safety. It does not score political legitimacy, +voter preferences, or proposal outcomes. + +## Architecture and security boundaries + +`model.ts` performs a bounded, cycle-safe AST walk and emits semantic state/transition/operation +records. `adapters.ts` recognizes framework structure without treating a name as proof of safety. +`analyzer.ts` runs pure rules over that model; `api.ts` owns budgets, cancellation, deterministic +ordering, and sanitized filesystem diagnostics; `serialize.ts` and the CLI are transport/presentation +only. The feature performs no RPC, network, package download, compiler subprocess, symbolic +execution, or provider call. Deployment role membership, bridge finality/authenticity, economic +adequacy, and political outcomes remain outside the static source boundary. + +## Precision / recall considerations + +- A cheap, comment/string-stripped governance prefilter protects ordinary scan performance; the + dedicated API skips it and can model generic implementations directly. +- Findings require semantic function/state roles plus ordered guards, writes, calls, or parameter + taint. Framework adapters suppress only mitigations visible in source. +- Secure checkpointed Governor, predecessor-aware/salted TimelockController, Safe-style multisig, + and domain-separated cross-chain fixtures are zero-finding false-positive controls. +- Unresolved inherited modifiers, assembly/computed selectors, proxy storage aliases, deployment + configuration, and external bridge/token behavior may require manual review and are documented. + +## Performance + +Local Node.js benchmark on the final implementation (100 copies of `VulnerableGovernor.sol`, 1,024 +finding cap): **599.2 ms**, **17.4 MiB heap delta**. Preflight source/contract/function checks and +per-function operation, per-finding evidence, project file, and total finding limits provide +deterministic adversarial bounds. Regression tests enforce early contract limiting, an eight-operation +cap on a 200-statement function, cancellation, and a 5-second guardrail for the bounded fixture. + +## Validation + +- `npm run build` +- `npm test` +- `npm run lint` +- `npm run test:ci --workspace=packages/core` +- affected workspace builds/tests and TypeDoc generation + +Latest local evidence after updating from target `master`: **41 core suites / 332 tests** and +**6 CLI suites / 24 tests** pass. Core coverage is 83.9% statements overall and 91.78% statements +for `src/governance`. + +## Follow-up work + +- Add cross-file modifier/body expansion to the governance model using the shared import graph. +- Add optional deployment-manifest checks for concrete role membership and open-executor policy. +- Add assembly-aware selector/value-flow summaries while retaining the current bounded guarantees. + +## Review guide + +1. Start with `packages/core/src/governance/model.ts` and `analyzer.ts`. +2. Review deterministic bounds/config/reporting in `api.ts`, `config.ts`, and `serialize.ts`. +3. Exercise `chainproof governance examples/contracts/governance --format json --fail-on none`. +4. Compare the vulnerable and secure fixtures under `examples/contracts/governance/`. diff --git a/README.md b/README.md index 0c6df06..6732d37 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - [Repository Layout](#repository-layout) - [Installation](#installation) - [CLI Reference](#cli-reference) +- [Governance Safety Analysis](#governance-safety-analysis) - [Invariant DSL](#invariant-dsl) - [Staking Accounting](#staking-accounting) - [VS Code Extension](#vs-code-extension) @@ -304,6 +305,56 @@ chainproof invariants migrate legacy-spec.json --output vault.cpinv.json `check` exits `1` if any invariant `fail`s or `error`s, `0` otherwise (`timeout`/`skipped` do not fail the build by themselves — inspect `bounded.timeExceeded`/`stepsExceededIds` in `--format json` output). +### `chainproof governance` + +Run the bounded governance, timelock, multisig, and cross-chain proposal safety analyzer: + +```bash +chainproof governance contracts/ --format markdown --output governance-report.md +chainproof governance contracts/Governor.sol --format json --fail-on high +chainproof governance contracts/ --include-rule CP-GOV-001 --include-rule CP-GOV-002 +chainproof governance contracts/ --config governance.config.json --include-models +``` + +The command emits deterministic, schema-versioned JSON or Markdown. `--fail-on` accepts +`none|info|low|medium|high|critical`; the default is `high`. Rule include/exclude flags are +repeatable, and bounded-analysis flags limit sources, files, contracts, functions, operations, +evidence, and findings. See [Governance Safety Analysis](#governance-safety-analysis). + +--- + +## Governance Safety Analysis + +The specialized engine in `packages/core/src/governance/` builds a normalized state-transition +model for proposal creation, checkpointed voting, quorum math, queue/schedule, timelock delay, +cancel, execute, multisig signature validation, emergency authority, upgrades, and cross-chain +message delivery. It traces proposal-controlled target/value/calldata into privileged calls and +checks guards and state writes in source order. + +It recognizes OpenZeppelin Governor and TimelockController shapes, Compound Governor Bravo, +Safe-style threshold multisigs, cross-chain governors, and generic checkpoint/timelock patterns. +The ordinary `chainproof scan` pipeline also runs these rules once per physical Solidity file. + +```typescript +import { + analyzeGovernanceFiles, + serializeGovernanceReport, +} from '@chainproof/core'; + +const report = analyzeGovernanceFiles(['contracts/'], { + includeModels: true, + limits: { maxFindings: 200 }, + excludeRules: ['CP-GOV-009'], +}); +process.stdout.write(serializeGovernanceReport(report)); +``` + +Reports describe structural implementation safety only. They do not judge voter preferences, +political legitimacy, or whether a proposal's outcome is desirable. Full rule semantics, +configuration schema, threat model, limitations, and troubleshooting are documented in +**[docs/governance-safety.md](docs/governance-safety.md)**. Secure/vulnerable fixtures live under +[`examples/contracts/governance/`](examples/contracts/governance/SecureGovernor.sol). + --- ## Staking Accounting diff --git a/docs/governance-safety.md b/docs/governance-safety.md new file mode 100644 index 0000000..84c6f71 --- /dev/null +++ b/docs/governance-safety.md @@ -0,0 +1,267 @@ +# Governance, Timelock, and Proposal Execution Safety + +ChainProof's governance analyzer is a deterministic static-analysis pass for Solidity governance +systems. It models how proposals, voting weight, queue state, timelock operations, emergency +authority, multisig approvals, upgrades, and cross-chain messages reach privileged state changes. + +The analyzer evaluates **structural implementation safety**. It does not decide whether governance +is politically legitimate, whether voters made a good choice, or whether a proposal outcome is +desirable. + +## Threat model + +The engine assumes an adversary may: + +- borrow, transfer, or delegate voting tokens within a block; +- choose proposal targets, ETH values, calldata, salts, and action ordering; +- resubmit an approved proposal, multisig transaction, or bridge message; +- trigger reentrant callbacks from a proposal action; +- compromise one configured guardian, proposer, executor, or administrator; +- exploit integer truncation at quorum and threshold boundaries; +- collide incomplete proposal/operation identifiers; +- deliver a valid cross-chain message more than once or on the wrong domain. + +The engine does not assume that any named framework is safe merely because its contract name or +inheritance list resembles that framework. Adapters recognize structure and suppress only risks +whose required guards, state, data binding, and ordering are visible in the analyzed source. + +## Normalized model + +For each relevant contract the analyzer records: + +- state variables and semantic roles such as `vote-snapshot`, `minimum-delay`, `nonce`, `guardian`, + `proposer-role`, `executor-role`, `message-id`, and `chain-domain`; +- transitions and roles such as `propose`, `cast-vote`, `quorum`, `schedule`, `execute`, `cancel`, + `update-delay`, `hash-operation`, `emergency-execute`, `multisig-execute`, and + `cross-chain-receive`; +- source-ordered reads, writes, guards, arithmetic operations, and calls; +- direct target/value/calldata taint from function parameters into low-level calls, delegatecalls, + upgrade primitives, and generic execution entry points; +- adapter matches and the assumptions that apply to the modeled lifecycle. + +The AST walk is iterative and cycle-safe. No RPC, chain state, package download, compiler process, +symbolic executor, or network request is used. + +## Rule reference + +| Rule | Default severity | Structural condition | +| --- | --- | --- | +| `CP-GOV-001` | critical | Voting power reads a live `balanceOf` without past checkpoints. | +| `CP-GOV-002` | critical | Voting power is acquired and read at the current block. | +| `CP-GOV-003` | high | Quorum/threshold math divides before multiplication or accepts zero. | +| `CP-GOV-004` | high | Proposal lifecycle lacks separate non-zero delay and voting period. | +| `CP-GOV-005` | critical | Privileged execution lacks queued timelock readiness proof. | +| `CP-GOV-006` | critical | Execution lacks both replay guard and pre-call consumption. | +| `CP-GOV-007` | high | Proposal identity omits action-defining fields. | +| `CP-GOV-008` | critical | Proposal-controlled target/value/calldata reaches an unbounded call. | +| `CP-GOV-009` | critical | Guardian/emergency execution bypasses normal controls. | +| `CP-GOV-010` | critical | Minimum delay is mutable outside a scheduled self-call. | +| `CP-GOV-011` | high | Execution accepts but does not enforce its predecessor. | +| `CP-GOV-012` | high | Scheduling/hashing accepts but omits the operation salt. | +| `CP-GOV-013` | medium | Scheduling and execution authority are not separated. | +| `CP-GOV-014` | critical | Proposal-controlled input reaches an immediate upgrade primitive. | +| `CP-GOV-015` | critical | Cross-chain execution lacks pre-call replay consumption or domain binding. | +| `CP-GOV-016` | critical | Multisig execution lacks threshold signature proof and nonce consumption. | + +Every finding contains category, contract, exact source location, confidence, evidence, explicit +assumptions, and a remediation. Absence findings are labeled as such rather than presented as a +runtime proof. + +## Framework adapters + +### OpenZeppelin Governor + +Recognized from proposal threshold, voting delay/period, and proposal/vote/snapshot/deadline +functions. The adapter does not assume `_getVotes` queries a safe past checkpoint and does not +assume the configured executor is a timelock. + +### OpenZeppelin TimelockController + +Recognized from minimum-delay and timestamp state with hash/schedule/execute/update-delay +transitions. Complete action hashing, predecessor completion, readiness, pre-call consumption, +self-authorized delay changes, and distinct roles are still checked. + +### Compound Governor Bravo + +Recognized from proposal count/threshold/quorum plus propose, vote, queue, execute, and state +transitions. `getPriorVotes` boundaries and guardian behavior remain independently analyzed. + +### Safe-style multisig + +Recognized from owners/signers, threshold, nonce, `execTransaction`, and `checkSignatures`. +ChainProof checks structural threshold use and pre-call nonce consumption. Modules, guards, +fallback handlers, owner uniqueness, signature malleability, and deployment configuration require +additional review. + +### Cross-chain governor + +Recognized from message replay state, source-chain/domain state, and a message receiver. Bridge +authenticity, finality, relayer incentives, and source-governor deployment correctness are external +assumptions. + +## CLI + +```bash +# Markdown for review +chainproof governance contracts/ --output governance-report.md --fail-on high + +# Stable JSON artifact for CI +chainproof governance contracts/ --format json --output governance-report.json --fail-on critical + +# Focused investigation +chainproof governance contracts/Governor.sol \ + --include-rule CP-GOV-001 \ + --include-rule CP-GOV-002 \ + --include-models \ + --fail-on none +``` + +### CLI options + +| Option | Meaning | +| --- | --- | +| `--format json\|markdown` | Output encoding; default `markdown`. | +| `--output ` | Write the artifact instead of stdout. | +| `--config ` | Load versioned JSON configuration. | +| `--include-models` | Include normalized contract models in JSON. | +| `--include-rule ` | Run one rule; repeat to build an allowlist. | +| `--exclude-rule ` | Skip one rule; repeat to build a denylist. | +| `--max-source-bytes ` | Per-file UTF-8 source budget. | +| `--max-files ` | Project file budget. | +| `--max-contracts ` | Per-file contract budget. | +| `--max-functions ` | Per-file and per-contract function budget. | +| `--max-operations ` | Per-function modeled-operation budget. | +| `--max-findings ` | Report finding budget. | +| `--fail-on ` | Exit 1 at or above the selected severity; default `high`. | + +Exit code `0` means the configured threshold was not met, `1` means it was met, and `2` means +configuration/usage/analysis failed. JSON mode never prints a banner to stdout. + +## Core API + +```typescript +import { + analyzeGovernanceSource, + analyzeGovernanceSources, + analyzeGovernanceFiles, + generateGovernanceMarkdown, + serializeGovernanceReport, +} from '@chainproof/core'; + +const report = analyzeGovernanceFiles(['contracts/Governor.sol', 'contracts/Timelock.sol'], { + includeModels: true, + includeRules: ['CP-GOV-005', 'CP-GOV-006', 'CP-GOV-008'], + limits: { + maxSourceBytes: 2 * 1024 * 1024, + maxFiles: 100, + maxFindings: 500, + }, + signal: abortController.signal, +}); + +await fs.promises.writeFile('governance.json', serializeGovernanceReport(report)); +``` + +`analyzeGovernanceSource` is useful for editor buffers. `analyzeGovernanceSources` accepts explicit +`{ file, source }` values and sorts them by file before analysis. `analyzeGovernanceFiles` accepts +files or directories, skips symlinks, collects `.sol` files recursively, and reports sanitized IO +diagnostics. + +## Configuration + +Current configuration schema: + +```json +{ + "schemaVersion": 1, + "includeModels": false, + "includeRules": ["CP-GOV-001", "CP-GOV-002", "CP-GOV-005"], + "excludeRules": [], + "limits": { + "maxSourceBytes": 2097152, + "maxFiles": 256, + "maxContracts": 128, + "maxFunctionsPerFile": 512, + "maxFunctionsPerContract": 512, + "maxOperationsPerFunction": 2048, + "maxFindings": 1024, + "maxEvidencePerFinding": 12 + } +} +``` + +All limit values must be positive safe integers. Included and excluded rule lists may not overlap. +Rule IDs outside `CP-GOV-001` through `CP-GOV-016` are rejected. + +Legacy schema v0 is migrated in memory: + +| v0 field | v1 field | +| --- | --- | +| `maxFileSize` | `limits.maxSourceBytes` | +| `maxIssues` | `limits.maxFindings` | +| `detectors` | `includeRules` | + +Malformed JSON, unsupported future schemas, invalid limits, rule-list conflicts, and unreadable +configuration files raise `GovernanceConfigError`. Errors contain a bounded message and stable code; +they do not include configuration contents or source contents. + +## Bounds and cancellation + +Default limits prevent an untrusted repository from producing unbounded AST/model/report work. +When a source, contract, function, operation, file, finding, or evidence limit is reached, the +report contains a `GOV_*_LIMIT` diagnostic and sets `summary.truncated` where output is incomplete. + +Pass an `AbortSignal`-compatible `{ aborted, reason }` object in `options.signal`. Cancellation is +checked before IO, parsing, contract modeling, and each rule pass and raises +`GovernanceAnalysisCancelledError` (`GOV_CANCELLED`). + +## Diagnostics + +| Code | Meaning | +| --- | --- | +| `GOV_PARSE_ERROR` | Solidity parser could not produce a usable AST. | +| `GOV_SOURCE_LIMIT` | Source/file budget reached. | +| `GOV_CONTRACT_LIMIT` | Contract budget reached. | +| `GOV_FUNCTION_LIMIT` | Function budget reached. | +| `GOV_OPERATION_LIMIT` | Per-function operation budget reached. | +| `GOV_FINDING_LIMIT` | Finding output budget reached. | +| `GOV_CANCELLED` | Caller requested cancellation. | +| `GOV_CONFIG_INVALID` | Configuration failed validation/migration. | +| `GOV_FILE_UNREADABLE` | Solidity source could not be read. | + +## Output stability + +The report schema version is `1.0.0`. JSON serialization recursively sorts object keys, and file, +contract, transition, finding, evidence, and diagnostic ordering is deterministic. There is no +timestamp in the specialized report, so identical sources and configuration produce byte-identical +JSON. Consumers should check `schemaVersion` before deserializing future reports. + +## Known limitations + +- Analysis is syntactic/structural and intra-file; it is not an EVM execution proof. +- Modifier bodies inherited from unresolved imports may contain guards not visible to the model. +- Dynamic assembly, computed selectors, proxy storage aliases, and delegatecall effects can require + manual tracing. +- Adapter matches describe recognizable structure, not correct deployment role assignments. +- Token checkpoint correctness, flash-loan availability, bridge authenticity/finality, multisig + signer independence, and timelock role membership can depend on external contracts or deployment. +- Economic threshold adequacy and political governance design are intentionally not scored. + +Use findings as auditable leads alongside tests, deployment review, formal properties, and an +independent security assessment. + +## Fixtures and tests + +`examples/contracts/governance/` contains paired secure/vulnerable Governor, TimelockController, +multisig, and cross-chain fixtures. Core tests cover all 16 rule paths, adapters, source ordering, +serialization, bounds, cancellation, parsing, config migration/corruption, scanner integration, +and false-positive suppression. CLI tests cover JSON cleanliness, Markdown artifacts, CI threshold +exit codes, and invalid rule handling. + +Run: + +```bash +npm run build +npm test +npm run lint +``` diff --git a/examples/contracts/governance/SecureCrossChainGovernor.sol b/examples/contracts/governance/SecureCrossChainGovernor.sol new file mode 100644 index 0000000..044b004 --- /dev/null +++ b/examples/contracts/governance/SecureCrossChainGovernor.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract SecureCrossChainGovernor { + address public bridge; + uint256 public sourceChainId; + mapping(bytes32 => bool) public processedMessages; + uint256 public proposalCount; + uint256 public quorumVotes; + + constructor(address bridge_, uint256 sourceChainId_) { + bridge = bridge_; + sourceChainId = sourceChainId_; + } + + function receiveMessage(bytes32 messageId, uint256 origin, address target, bytes calldata data) external { + require(msg.sender == bridge, "bridge"); + require(origin == sourceChainId, "domain"); + require(!processedMessages[messageId], "replayed"); + processedMessages[messageId] = true; + (bool ok,) = target.call(data); + require(ok, "message execution failed"); + } +} diff --git a/examples/contracts/governance/SecureGovernor.sol b/examples/contracts/governance/SecureGovernor.sol new file mode 100644 index 0000000..6633f31 --- /dev/null +++ b/examples/contracts/governance/SecureGovernor.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface ICheckpointVotes { + function getPastVotes(address account, uint256 timepoint) external view returns (uint256); + function getPastTotalSupply(uint256 timepoint) external view returns (uint256); +} + +interface IGovernanceTimelock { + function execute(address target, uint256 value, bytes calldata data, bytes32 predecessor, bytes32 salt) + external payable returns (bytes memory); +} + +/// Minimal secure fixture modeling checkpointed voting and a separate timelock executor. +contract SecureGovernor { + ICheckpointVotes public governanceToken; + IGovernanceTimelock public timelock; + uint256 public proposalThreshold = 1e18; + uint256 public votingDelay = 7200; + uint256 public votingPeriod = 50400; + uint256 public quorumNumerator = 4; + uint256 public quorumDenominator = 100; + uint256 public proposalCount; + mapping(uint256 => uint256) private proposalSnapshots; + mapping(uint256 => uint256) private deadlines; + mapping(uint256 => bool) public executed; + + constructor(ICheckpointVotes token_, IGovernanceTimelock timelock_) { + governanceToken = token_; + timelock = timelock_; + } + + function propose(address[] calldata targets, uint256[] calldata values, bytes[] calldata calldatas) + external returns (uint256 proposalId) + { + require(targets.length != 0 && targets.length == values.length, "actions"); + require(values.length == calldatas.length, "actions"); + proposalId = ++proposalCount; + proposalSnapshots[proposalId] = block.number + votingDelay; + deadlines[proposalId] = proposalSnapshots[proposalId] + votingPeriod; + } + + function proposalSnapshot(uint256 proposalId) public view returns (uint256) { + return proposalSnapshots[proposalId]; + } + + function proposalDeadline(uint256 proposalId) public view returns (uint256) { + return deadlines[proposalId]; + } + + function castVote(uint256 proposalId, bool support) external returns (uint256) { + require(block.number >= proposalSnapshots[proposalId] && block.number <= deadlines[proposalId], "window"); + support; + return governanceToken.getPastVotes(msg.sender, proposalSnapshots[proposalId]); + } + + function quorum(uint256 snapshot) public view returns (uint256) { + return governanceToken.getPastTotalSupply(snapshot) * quorumNumerator / quorumDenominator; + } + + function hashProposal( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata calldatas, + bytes32 descriptionHash + ) public pure returns (bytes32) { + return keccak256(abi.encode(targets, values, calldatas, descriptionHash)); + } + + function execute( + uint256 proposalId, + address target, + uint256 value, + bytes calldata data, + bytes32 predecessor, + bytes32 salt + ) external { + require(!executed[proposalId], "already executed"); + executed[proposalId] = true; + timelock.execute(target, value, data, predecessor, salt); + } +} diff --git a/examples/contracts/governance/SecureMultisig.sol b/examples/contracts/governance/SecureMultisig.sol new file mode 100644 index 0000000..877d772 --- /dev/null +++ b/examples/contracts/governance/SecureMultisig.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract SecureMultisig { + address[] public owners; + uint256 public threshold; + uint256 public nonce; + + constructor(address[] memory owners_, uint256 threshold_) { + require(threshold_ > 0 && threshold_ <= owners_.length, "threshold"); + owners = owners_; + threshold = threshold_; + } + + function checkSignatures(bytes32 transactionHash, bytes calldata signatures) + public view returns (bool) + { + transactionHash; + return signatures.length / 65 >= threshold; + } + + function execTransaction(address target, uint256 value, bytes calldata data, bytes calldata signatures) + external returns (bool) + { + bytes32 transactionHash = keccak256(abi.encode(block.chainid, address(this), nonce, target, value, data)); + require(checkSignatures(transactionHash, signatures), "signatures"); + require(threshold > 0, "threshold"); + nonce += 1; + (bool ok,) = target.call{value: value}(data); + return ok; + } +} diff --git a/examples/contracts/governance/SecureTimelockController.sol b/examples/contracts/governance/SecureTimelockController.sol new file mode 100644 index 0000000..4db4341 --- /dev/null +++ b/examples/contracts/governance/SecureTimelockController.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract SecureTimelockController { + bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); + bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); + uint256 private constant DONE_TIMESTAMP = 1; + uint256 private _minDelay; + mapping(bytes32 => uint256) private _timestamps; + mapping(bytes32 => mapping(address => bool)) private roles; + + modifier onlyRole(bytes32 role) { + require(roles[role][msg.sender], "role"); + _; + } + + constructor(uint256 delay, address proposer, address executor) { + require(delay > 0, "delay"); + _minDelay = delay; + roles[PROPOSER_ROLE][proposer] = true; + roles[EXECUTOR_ROLE][executor] = true; + } + + function hashOperation(address target, uint256 value, bytes calldata data, bytes32 predecessor, bytes32 salt) + public pure returns (bytes32) + { + return keccak256(abi.encode(target, value, data, predecessor, salt)); + } + + function schedule(address target, uint256 value, bytes calldata data, bytes32 predecessor, bytes32 salt) + external onlyRole(PROPOSER_ROLE) + { + bytes32 id = hashOperation(target, value, data, predecessor, salt); + require(_timestamps[id] == 0, "scheduled"); + _timestamps[id] = block.timestamp + _minDelay; + } + + function execute(address target, uint256 value, bytes calldata data, bytes32 predecessor, bytes32 salt) + external onlyRole(EXECUTOR_ROLE) + { + bytes32 id = hashOperation(target, value, data, predecessor, salt); + require(_timestamps[id] > DONE_TIMESTAMP && _timestamps[id] <= block.timestamp, "not ready"); + require(predecessor == bytes32(0) || _timestamps[predecessor] == DONE_TIMESTAMP, "dependency"); + _timestamps[id] = DONE_TIMESTAMP; + (bool ok,) = target.call{value: value}(data); + require(ok, "execution failed"); + } + + function updateDelay(uint256 newDelay) external { + require(msg.sender == address(this), "self only"); + require(newDelay > 0, "delay"); + _minDelay = newDelay; + } +} diff --git a/examples/contracts/governance/VulnerableCrossChainGovernor.sol b/examples/contracts/governance/VulnerableCrossChainGovernor.sol new file mode 100644 index 0000000..43df8de --- /dev/null +++ b/examples/contracts/governance/VulnerableCrossChainGovernor.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract VulnerableCrossChainGovernor { + address public bridge; + bytes32 public messageId; + uint256 public proposalCount; + uint256 public quorumVotes; + + constructor(address bridge_) { + bridge = bridge_; + } + + function receiveMessage( + bytes32 incomingMessageId, + uint256 sourceChainId, + address target, + bytes calldata data + ) external { + require(msg.sender == bridge, "bridge"); + incomingMessageId; + sourceChainId; + (bool ok,) = target.call(data); + require(ok, "message execution failed"); + } +} diff --git a/examples/contracts/governance/VulnerableGovernor.sol b/examples/contracts/governance/VulnerableGovernor.sol new file mode 100644 index 0000000..5cb9374 --- /dev/null +++ b/examples/contracts/governance/VulnerableGovernor.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IVulnerableVotes { + function balanceOf(address account) external view returns (uint256); + function getVotes(address account, uint256 timepoint) external view returns (uint256); + function totalSupply() external view returns (uint256); +} + +/// Intentionally vulnerable fixture: each unsafe branch is covered by the governance analyzer tests. +contract VulnerableGovernor { + IVulnerableVotes public governanceToken; + uint256 public proposalThreshold; + uint256 public quorumNumerator; + uint256 public proposalCount; + address public guardian; + mapping(uint256 => bool) public executed; + + constructor(IVulnerableVotes token_, address guardian_) { + governanceToken = token_; + guardian = guardian_; + proposalThreshold = 1; + quorumNumerator = 4; + } + + function propose(address[] calldata targets, uint256[] calldata values, bytes[] calldata calldatas) + external returns (uint256) + { + require(governanceToken.balanceOf(msg.sender) >= proposalThreshold, "threshold"); + require(targets.length == values.length && targets.length == calldatas.length, "length"); + proposalCount += 1; + return proposalCount; + } + + function getVotes(address account) public view returns (uint256) { + // Live balance and current-block checkpoint both permit atomic voting-power acquisition. + return governanceToken.balanceOf(account) + governanceToken.getVotes(account, block.number); + } + + function castVote(uint256, bool) external returns (uint256) { + uint256 weight = getVotes(msg.sender); + return weight; + } + + function quorum(uint256) public view returns (uint256) { + // Division before multiplication can truncate a non-zero quorum fraction to zero. + return governanceToken.totalSupply() / 100 * quorumNumerator; + } + + function hashProposal( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata calldatas, + bytes32 descriptionHash + ) public pure returns (bytes32) { + return keccak256(abi.encode(targets, descriptionHash)); + } + + function execute(uint256 proposalId, address target, uint256 value, bytes calldata data) external { + (bool ok,) = target.call{value: value}(data); + require(ok, "execution failed"); + executed[proposalId] = true; + } + + function emergencyExecute(address target, bytes calldata data) external { + require(msg.sender == guardian, "guardian"); + (bool ok,) = target.call(data); + require(ok, "guardian execution failed"); + } + + function emergencyUpgrade(address proxy, address implementation, bytes calldata data) external { + require(msg.sender == guardian, "guardian"); + (bool ok,) = proxy.call( + abi.encodeWithSignature("upgradeToAndCall(address,bytes)", implementation, data) + ); + require(ok, "upgrade failed"); + } +} diff --git a/examples/contracts/governance/VulnerableMultisig.sol b/examples/contracts/governance/VulnerableMultisig.sol new file mode 100644 index 0000000..4b7b62b --- /dev/null +++ b/examples/contracts/governance/VulnerableMultisig.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract VulnerableMultisig { + address[] public owners; + uint256 public threshold; + uint256 public nonce; + mapping(bytes32 => bool) public executed; + + constructor(address[] memory owners_, uint256 threshold_) { + owners = owners_; + threshold = threshold_; + } + + function execTransaction(address target, uint256 value, bytes calldata data, bytes calldata signatures) + external returns (bool) + { + signatures; + (bool ok,) = target.call{value: value}(data); + return ok; + } +} diff --git a/examples/contracts/governance/VulnerableTimelock.sol b/examples/contracts/governance/VulnerableTimelock.sol new file mode 100644 index 0000000..9ed20e2 --- /dev/null +++ b/examples/contracts/governance/VulnerableTimelock.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract VulnerableTimelock { + address public admin; + uint256 public minDelay; + mapping(bytes32 => uint256) public timestamps; + + modifier onlyAdmin() { + require(msg.sender == admin, "admin"); + _; + } + + constructor(address admin_) { + admin = admin_; + minDelay = 2 days; + } + + function hashOperation(address target, uint256 value, bytes calldata data, bytes32 predecessor, bytes32 salt) + public pure returns (bytes32) + { + return keccak256(abi.encode(target, value, data)); + } + + function schedule(address target, uint256 value, bytes calldata data, bytes32 predecessor, bytes32 salt) + external onlyAdmin + { + bytes32 id = keccak256(abi.encode(target, value, data)); + timestamps[id] = block.timestamp + minDelay; + } + + function execute(address target, uint256 value, bytes calldata data, bytes32 predecessor) + external onlyAdmin + { + predecessor; + bytes32 id = keccak256(abi.encode(target, value, data)); + (bool ok,) = target.call{value: value}(data); + require(ok, "execution failed"); + timestamps[id] = 1; + } + + function updateDelay(uint256 newDelay) external onlyAdmin { + minDelay = newDelay; + } +} diff --git a/packages/cli/src/__tests__/governance.test.ts b/packages/cli/src/__tests__/governance.test.ts new file mode 100644 index 0000000..aa6c110 --- /dev/null +++ b/packages/cli/src/__tests__/governance.test.ts @@ -0,0 +1,63 @@ +import { execFileSync, spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const CLI = path.resolve(__dirname, "../../dist/cli.js"); +const FIXTURES = path.resolve(__dirname, "../../../../examples/contracts/governance"); + +describe("governance CLI", () => { + beforeAll(() => { + execFileSync("npm", ["run", "build", "--workspace=packages/core"], { cwd: path.resolve(__dirname, "../../../..") }); + execFileSync("npm", ["run", "build", "--workspace=packages/server"], { cwd: path.resolve(__dirname, "../../../..") }); + execFileSync("npm", ["run", "build", "--workspace=packages/cli"], { cwd: path.resolve(__dirname, "../../../..") }); + }, 60_000); + + it("prints machine-readable deterministic JSON without a banner", () => { + const result = spawnSync(process.execPath, [ + CLI, "governance", path.join(FIXTURES, "VulnerableGovernor.sol"), + "--format", "json", "--fail-on", "none", "--include-rule", "CP-GOV-001", + ], { encoding: "utf8" }); + expect(result.status).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.schemaVersion).toBe("1.0.0"); + expect(report.files[0].findings.map((finding: { ruleId: string }) => finding.ruleId)) + .toEqual(["CP-GOV-001"]); + expect(result.stdout).not.toContain("████"); + }); + + it("uses the configured fail threshold for CI", () => { + const result = spawnSync(process.execPath, [ + CLI, "governance", path.join(FIXTURES, "VulnerableTimelock.sol"), + "--format", "json", "--fail-on", "high", + ], { encoding: "utf8" }); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).summary.total).toBeGreaterThan(0); + }); + + it("writes Markdown to a requested artifact", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "chainproof-governance-cli-")); + const output = path.join(directory, "report.md"); + const result = spawnSync(process.execPath, [ + CLI, "governance", path.join(FIXTURES, "SecureGovernor.sol"), + "--output", output, "--fail-on", "none", + ], { encoding: "utf8" }); + expect(result.status).toBe(0); + expect(fs.readFileSync(output, "utf8")).toContain("# Governance Safety Analysis"); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("rejects corrupt rule configuration with a sanitized usage error", () => { + const result = spawnSync(process.execPath, [ + CLI, "governance", path.join(FIXTURES, "SecureGovernor.sol"), + "--format", "json", "--include-rule", "CP-GOV-999", + ], { encoding: "utf8" }); + expect(result.status).toBe(2); + expect(result.stderr).toContain("unknown rule CP-GOV-999"); + expect(result.stderr).not.toContain(vulnerableSourceMarker()); + }); +}); + +function vulnerableSourceMarker(): string { + return "Intentionally vulnerable fixture"; +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 47ab316..f1d07dc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -29,6 +29,7 @@ import type { ServerOptions } from "@chainproof/server"; import { registerWatchCommand } from "./commands/watch"; import { registerInvariantsCommand } from "./commands/invariants"; import { registerStakingCommand } from "./commands/staking"; +import { registerGovernanceCommand } from "./commands/governance"; // ─── ASCII Banner ───────────────────────────────────────────────────────────── @@ -629,5 +630,6 @@ program registerWatchCommand(program, printBanner); registerInvariantsCommand(program, printBanner); registerStakingCommand(program); +registerGovernanceCommand(program, printBanner); program.parse(); diff --git a/packages/cli/src/commands/governance.ts b/packages/cli/src/commands/governance.ts new file mode 100644 index 0000000..f58cd5e --- /dev/null +++ b/packages/cli/src/commands/governance.ts @@ -0,0 +1,182 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import * as fs from "fs"; +import { + analyzeGovernanceFiles, + generateGovernanceMarkdown, + GovernanceAnalysisCancelledError, + GovernanceConfigError, + loadGovernanceConfigFile, + serializeGovernanceReport, +} from "@chainproof/core"; +import type { + GovernanceAnalysisLimits, + GovernanceAnalysisOptions, + GovernanceAnalysisReport, + GovernanceRuleId, +} from "@chainproof/core"; + +type OutputFormat = "json" | "markdown"; +type FailSeverity = "none" | "info" | "low" | "medium" | "high" | "critical"; + +interface GovernanceCliOptions { + 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-GOV-(?:00[1-9]|01[0-6])$/; +const SEVERITY_RANK: Record = { + none: 99, + info: 1, + low: 2, + medium: 3, + high: 4, + critical: 5, +}; + +export function registerGovernanceCommand(program: Command, printBanner: () => void): void { + program + .command("governance ") + .description("Analyze governance, timelock, multisig, and proposal execution safety") + .option("--format ", "Output format: json|markdown", "markdown") + .option("--output ", "Write the report to a file") + .option("--config ", "Load a versioned governance analysis configuration") + .option("--include-models", "Include the normalized governance 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: GovernanceCliOptions) => { + const json = raw.format === "json"; + if (!json && raw.format === "markdown") printBanner(); + try { + validateFormat(raw.format); + validateFailSeverity(raw.failOn); + const configured = raw.config ? loadGovernanceConfigFile(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: GovernanceAnalysisOptions = { + limits, + includeModels: raw.includeModels ?? configured?.config.includeModels ?? false, + ...(includeRules ? { includeRules } : {}), + ...(excludeRules ? { excludeRules } : {}), + }; + const report = analyzeGovernanceFiles(targets, options); + const output = raw.format === "json" + ? serializeGovernanceReport(report) + : generateGovernanceMarkdown(report); + if (raw.output) { + writeReport(raw.output, output); + if (!json) console.log(chalk.green(`\n Governance report written to ${raw.output}`)); + } else { + process.stdout.write(output); + } + process.exit(exitCode(report, raw.failOn)); + } catch (error) { + const message = error instanceof GovernanceConfigError || + error instanceof GovernanceAnalysisCancelledError || error instanceof Error + ? error.message + : "Governance analysis failed"; + console.error(chalk.red(`Governance 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 GovernanceConfigError("analysis limits must be positive integers"); + const result = Number(value); + if (!Number.isSafeInteger(result) || result <= 0) { + throw new GovernanceConfigError("analysis limits must be positive safe integers"); + } + return result; +} + +function validateRules(values: string[], option: string): GovernanceRuleId[] { + const result = new Set(); + for (const value of values) { + if (!RULE_PATTERN.test(value)) throw new GovernanceConfigError(`${option} contains unknown rule ${value}`); + result.add(value as GovernanceRuleId); + } + return [...result].sort(); +} + +function rejectOverlap(include: GovernanceRuleId[] | undefined, exclude: GovernanceRuleId[] | undefined): void { + if (!include || !exclude) return; + const overlap = include.filter((rule) => exclude.includes(rule)); + if (overlap.length) throw new GovernanceConfigError(`included and excluded rules overlap: ${overlap.join(", ")}`); +} + +function validateFormat(value: string): asserts value is OutputFormat { + if (value !== "json" && value !== "markdown") { + throw new GovernanceConfigError("--format must be json or markdown"); + } +} + +function validateFailSeverity(value: string): asserts value is FailSeverity { + if (!(value in SEVERITY_RANK)) { + throw new GovernanceConfigError("--fail-on must be none, info, low, medium, high, or critical"); + } +} + +function exitCode(report: GovernanceAnalysisReport, threshold: FailSeverity): number { + const rank = SEVERITY_RANK[threshold]; + return report.files.some((file) => file.findings.some((finding) => + SEVERITY_RANK[finding.severity] >= rank, + )) ? 1 : 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 GovernanceConfigError(`report file could not be written (${safeCode})`); + } +} diff --git a/packages/core/src/governance/__tests__/adversarial.test.ts b/packages/core/src/governance/__tests__/adversarial.test.ts new file mode 100644 index 0000000..b95bbc6 --- /dev/null +++ b/packages/core/src/governance/__tests__/adversarial.test.ts @@ -0,0 +1,62 @@ +import * as os from "os"; +import * as path from "path"; +import { analyzeGovernanceFiles, analyzeGovernanceSource } from "../api"; + +describe("governance adversarial and performance safeguards", () => { + it("preflights excessive contract counts before building an AST model", () => { + const source = Array.from({ length: 30 }, (_, index) => `contract Governor${index} {}`).join("\n"); + const report = analyzeGovernanceSource(source, "many.sol", { limits: { maxContracts: 4 } }); + expect(report.files[0].diagnostics[0]).toMatchObject({ code: "GOV_CONTRACT_LIMIT" }); + expect(report.summary.total).toBe(0); + expect(report.summary.truncated).toBe(true); + }); + + it("bounds operation modeling for a syntactically valid oversized function", () => { + const statements = Array.from({ length: 200 }, () => "proposalCount += 1;").join("\n"); + const source = `pragma solidity ^0.8.20; contract OversizedGovernor { + uint256 public proposalCount; + uint256 public proposalThreshold; + uint256 public votingDelay; + uint256 public votingPeriod; + function propose() external { ${statements} } + }`; + const started = Date.now(); + const report = analyzeGovernanceSource(source, "oversized.sol", { + includeModels: true, + limits: { maxOperationsPerFunction: 8 }, + }); + expect(report.files[0].diagnostics.some((item) => item.code === "GOV_OPERATION_LIMIT")).toBe(true); + expect(report.files[0].models?.[0].transitions[0].operations.length).toBeLessThanOrEqual(8); + expect(Date.now() - started).toBeLessThan(5_000); + }); + + it("detects statically duplicated proposal actions", () => { + const source = `pragma solidity ^0.8.20; contract DuplicateGovernor { + uint256 public proposalCount; uint256 public proposalThreshold; + uint256 public votingDelay; uint256 public votingPeriod; + mapping(uint256 => bool) public executed; + function execute(uint256 id, address target, bytes calldata data) external { + require(!executed[id]); executed[id] = true; + target.call(data); + target.call(data); + } + }`; + const report = analyzeGovernanceSource(source, "duplicate.sol", { + includeRules: ["CP-GOV-007"], + }); + expect(report.files[0].findings).toHaveLength(1); + expect(report.files[0].findings[0].title).toContain("duplicate action"); + expect(report.files[0].findings[0].evidence).toHaveLength(2); + }); + + it("returns an actionable diagnostic for a missing filesystem target", () => { + const missing = path.join(os.tmpdir(), `chainproof-governance-missing-${process.pid}.sol`); + const report = analyzeGovernanceFiles([missing]); + expect(report.summary.total).toBe(0); + expect(report.files[0].diagnostics[0]).toMatchObject({ + code: "GOV_FILE_UNREADABLE", + message: "Solidity target could not be read (ENOENT)", + }); + expect(report.files[0].diagnostics[0].message).not.toContain(missing); + }); +}); diff --git a/packages/core/src/governance/__tests__/analyzer.test.ts b/packages/core/src/governance/__tests__/analyzer.test.ts new file mode 100644 index 0000000..aa6c07e --- /dev/null +++ b/packages/core/src/governance/__tests__/analyzer.test.ts @@ -0,0 +1,100 @@ +import * as fs from "fs"; +import * as path from "path"; +import { analyzeGovernanceFiles, analyzeGovernanceSource } from "../api"; +import type { GovernanceRuleId } from "../types"; + +const FIXTURES = path.resolve(__dirname, "../../../../../examples/contracts/governance"); + +function fixture(name: string) { + return analyzeGovernanceFiles([path.join(FIXTURES, `${name}.sol`)], { includeModels: true }); +} + +function rules(name: string): GovernanceRuleId[] { + return fixture(name).files.flatMap((file) => file.findings.map((finding) => finding.ruleId)); +} + +describe("governance safety analyzer", () => { + it("models live-balance, same-block, lifecycle, quorum, identity, execution, replay and guardian risks", () => { + const ids = new Set(rules("VulnerableGovernor")); + for (const expected of [ + "CP-GOV-001", "CP-GOV-002", "CP-GOV-003", "CP-GOV-004", "CP-GOV-005", + "CP-GOV-006", "CP-GOV-007", "CP-GOV-008", "CP-GOV-009", "CP-GOV-014", + ] satisfies GovernanceRuleId[]) { + expect(ids).toContain(expected); + } + }); + + it("recognizes checkpointed voting and an external timelock boundary", () => { + const report = fixture("SecureGovernor"); + expect(report.files[0].findings).toEqual([]); + expect(report.files[0].models?.[0].adapter).toBe("openzeppelin-governor"); + expect(report.files[0].models?.[0].transitions.find((item) => item.name === "castVote")?.calls) + .toContain("getPastVotes"); + }); + + it("detects unsafe timelock identity, delay, ordering, role, readiness and replay behavior", () => { + const ids = new Set(rules("VulnerableTimelock")); + for (const expected of [ + "CP-GOV-005", "CP-GOV-006", "CP-GOV-008", "CP-GOV-010", "CP-GOV-011", + "CP-GOV-012", "CP-GOV-013", + ] satisfies GovernanceRuleId[]) { + expect(ids).toContain(expected); + } + }); + + it("recognizes ordered, salted, predecessor-aware TimelockController behavior", () => { + const report = fixture("SecureTimelockController"); + expect(report.files[0].findings).toEqual([]); + expect(report.files[0].models?.[0].adapter).toBe("openzeppelin-timelock-controller"); + const execute = report.files[0].models?.[0].transitions.find((item) => item.name === "execute"); + const call = execute?.operations.find((item) => item.kind === "call" && item.name === "call"); + const consumption = execute?.operations.find((item) => + item.kind === "write" && item.expression.includes("_timestamps")); + expect(consumption!.order).toBeLessThan(call!.order); + }); + + it("separates incomplete multisig validation from Safe-style validation", () => { + expect(new Set(rules("VulnerableMultisig"))).toEqual(new Set(["CP-GOV-006", "CP-GOV-016"])); + const secure = fixture("SecureMultisig"); + expect(secure.files[0].findings).toEqual([]); + expect(secure.files[0].models?.[0].adapter).toBe("safe-multisig"); + }); + + it("detects cross-chain replay/domain gaps and recognizes their secure ordering", () => { + expect(rules("VulnerableCrossChainGovernor")).toContain("CP-GOV-015"); + const secure = fixture("SecureCrossChainGovernor"); + expect(secure.files[0].findings).toEqual([]); + expect(secure.files[0].models?.[0].adapter).toBe("cross-chain-governor"); + }); + + it("attaches bounded evidence, assumptions, confidence, and precise locations", () => { + const finding = fixture("VulnerableGovernor").files[0].findings.find((item) => + item.ruleId === "CP-GOV-008"); + expect(finding).toMatchObject({ severity: "critical", confidence: "high", category: "execution" }); + expect(finding?.evidence[0]).toMatchObject({ kind: "taint-flow" }); + expect(finding?.evidence[0].description).toContain("target"); + expect(finding?.assumptions.length).toBeGreaterThan(0); + expect(finding?.location.line).toBeGreaterThan(1); + }); + + it("supports deterministic include/exclude selection", () => { + const source = fs.readFileSync(path.join(FIXTURES, "VulnerableGovernor.sol"), "utf8"); + const included = analyzeGovernanceSource(source, "Governor.sol", { + includeRules: ["CP-GOV-001", "CP-GOV-008"], + }); + expect(new Set(included.files[0].findings.map((finding) => finding.ruleId))) + .toEqual(new Set(["CP-GOV-001", "CP-GOV-008"])); + const excluded = analyzeGovernanceSource(source, "Governor.sol", { + excludeRules: ["CP-GOV-001"], + }); + expect(excluded.files[0].findings.some((finding) => finding.ruleId === "CP-GOV-001")).toBe(false); + }); + + it("reports only structural implementation safety, not proposal preferences", () => { + const source = `pragma solidity ^0.8.20; contract PoliticalText { + string public proposalOutcome = "unpopular"; + string public voterPreference = "against"; + }`; + expect(analyzeGovernanceSource(source).summary.total).toBe(0); + }); +}); diff --git a/packages/core/src/governance/__tests__/api.test.ts b/packages/core/src/governance/__tests__/api.test.ts new file mode 100644 index 0000000..660d668 --- /dev/null +++ b/packages/core/src/governance/__tests__/api.test.ts @@ -0,0 +1,90 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + analyzeGovernanceFiles, + analyzeGovernanceSource, + analyzeGovernanceSources, + collectGovernanceSolidityFiles, +} from "../api"; +import { GovernanceAnalysisCancelledError } from "../config"; +import { generateGovernanceMarkdown, serializeGovernanceReport } from "../serialize"; + +const FIXTURES = path.resolve(__dirname, "../../../../../examples/contracts/governance"); +const vulnerable = fs.readFileSync(path.join(FIXTURES, "VulnerableGovernor.sol"), "utf8"); +const secure = fs.readFileSync(path.join(FIXTURES, "SecureGovernor.sol"), "utf8"); + +describe("governance analysis API", () => { + it("sorts sources and serialized object keys deterministically", () => { + const first = analyzeGovernanceSources([ + { file: "z.sol", source: vulnerable }, + { file: "a.sol", source: secure }, + ]); + const second = analyzeGovernanceSources([ + { file: "a.sol", source: secure }, + { file: "z.sol", source: vulnerable }, + ]); + expect(serializeGovernanceReport(first)).toBe(serializeGovernanceReport(second)); + expect(first.files.map((file) => file.file)).toEqual(["a.sol", "z.sol"]); + expect(serializeGovernanceReport(first)).toMatch(/^\{\n "engineVersion"/); + }); + + it("produces a versioned Markdown artifact with evidence and scope", () => { + const markdown = generateGovernanceMarkdown(analyzeGovernanceSource(vulnerable, "Gov.sol")); + expect(markdown).toContain("# Governance Safety Analysis"); + expect(markdown).toContain("CP-GOV-001"); + expect(markdown).toContain("**Evidence:**"); + expect(markdown).toContain("does not rate political legitimacy"); + }); + + it("emits a parse diagnostic instead of throwing or leaking internals", () => { + const report = analyzeGovernanceSource("contract Broken { function x( ", "broken.sol"); + expect(report.files[0].diagnostics[0]).toMatchObject({ code: "GOV_PARSE_ERROR", severity: "error" }); + expect(report.files[0].diagnostics[0].message).not.toContain(process.cwd()); + }); + + it("enforces source, file, operation, evidence and finding bounds", () => { + const sourceLimited = analyzeGovernanceSource(vulnerable, "large.sol", { limits: { maxSourceBytes: 20 } }); + expect(sourceLimited.files[0].diagnostics[0].code).toBe("GOV_SOURCE_LIMIT"); + expect(sourceLimited.summary.truncated).toBe(true); + + const findingLimited = analyzeGovernanceSource(vulnerable, "governor.sol", { + limits: { maxFindings: 2, maxEvidencePerFinding: 1, maxOperationsPerFunction: 500 }, + }); + expect(findingLimited.summary.total).toBe(2); + expect(findingLimited.summary.truncated).toBe(true); + expect(findingLimited.files[0].findings.every((finding) => finding.evidence.length <= 1)).toBe(true); + + const fileLimited = analyzeGovernanceSources([ + { file: "a.sol", source: vulnerable }, { file: "b.sol", source: vulnerable }, + ], { limits: { maxFiles: 1 } }); + expect(fileLimited.files.some((file) => file.file === "")).toBe(true); + }); + + it("honors cancellation before expensive parsing", () => { + expect(() => analyzeGovernanceSource(vulnerable, "cancelled.sol", { + signal: { aborted: true, reason: "test" }, + })).toThrow(GovernanceAnalysisCancelledError); + }); + + it("collects files in stable order, skips symlinks and ignores non-Solidity files", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "chainproof-governance-")); + fs.mkdirSync(path.join(directory, "nested")); + fs.writeFileSync(path.join(directory, "b.sol"), "contract B {}", "utf8"); + fs.writeFileSync(path.join(directory, "nested", "a.sol"), "contract A {}", "utf8"); + fs.writeFileSync(path.join(directory, "notes.txt"), "ignored", "utf8"); + fs.symlinkSync(path.join(directory, "b.sol"), path.join(directory, "linked.sol")); + const files = collectGovernanceSolidityFiles([directory]); + expect(files).toEqual([...files].sort()); + expect(files.map((file) => path.basename(file))).toEqual(["b.sol", "a.sol"]); + expect(files.some((file) => file.endsWith("linked.sol"))).toBe(false); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("analyzes a directory as one deterministic report", () => { + const report = analyzeGovernanceFiles([FIXTURES], { includeModels: true }); + expect(report.summary.files).toBe(8); + expect(report.summary.contracts).toBe(8); + expect(report.files.map((file) => file.file)).toEqual([...report.files.map((file) => file.file)].sort()); + }); +}); diff --git a/packages/core/src/governance/__tests__/config.test.ts b/packages/core/src/governance/__tests__/config.test.ts new file mode 100644 index 0000000..9b3b60e --- /dev/null +++ b/packages/core/src/governance/__tests__/config.test.ts @@ -0,0 +1,64 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + GovernanceConfigError, + loadGovernanceConfigFile, + migrateGovernanceConfig, + resolveGovernanceLimits, + validateGovernanceConfig, +} from "../config"; + +describe("governance configuration", () => { + it("migrates legacy v0 names to the versioned v1 shape", () => { + const migrated = migrateGovernanceConfig({ + version: 0, + maxFileSize: 1234, + maxIssues: 9, + detectors: ["CP-GOV-008", "CP-GOV-001", "CP-GOV-008"], + includeModels: true, + }); + expect(migrated.config).toEqual({ + schemaVersion: 1, + limits: { maxSourceBytes: 1234, maxFindings: 9 }, + includeModels: true, + includeRules: ["CP-GOV-001", "CP-GOV-008"], + }); + expect(migrated.diagnostics[0].message).toContain("v0 to v1"); + }); + + it("validates rule IDs, overlap, booleans and positive bounded integers", () => { + expect(() => validateGovernanceConfig({ schemaVersion: 1, includeRules: ["CP-GOV-999"] })) + .toThrow(GovernanceConfigError); + expect(() => validateGovernanceConfig({ + schemaVersion: 1, includeRules: ["CP-GOV-001"], excludeRules: ["CP-GOV-001"], + })).toThrow(/overlap/); + expect(() => validateGovernanceConfig({ schemaVersion: 1, includeModels: "yes" })) + .toThrow(/boolean/); + expect(() => resolveGovernanceLimits({ maxFiles: 0 })).toThrow(/positive safe integer/); + expect(() => resolveGovernanceLimits({ maxFiles: Number.MAX_VALUE })).toThrow(/positive safe integer/); + expect(() => validateGovernanceConfig({ schemaVersion: 1, secretToken: "not echoed" })) + .toThrow("configuration contains unknown field secretToken"); + expect(() => validateGovernanceConfig({ schemaVersion: 1, limits: { maxDepth: 3 } })) + .toThrow("limits contains unknown field maxDepth"); + }); + + it("distinguishes malformed JSON and unreadable files without including file contents", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "chainproof-config-")); + const corrupt = path.join(directory, "corrupt.json"); + fs.writeFileSync(corrupt, '{"schemaVersion": 1, "secret": "TOKEN",', "utf8"); + expect(() => loadGovernanceConfigFile(corrupt)).toThrow("configuration file contains invalid JSON"); + try { + loadGovernanceConfigFile(path.join(directory, "missing.json")); + } catch (error) { + expect(error).toBeInstanceOf(GovernanceConfigError); + expect((error as Error).message).toMatch(/could not be read \(ENOENT\)/); + expect((error as Error).message).not.toContain(directory); + } + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("rejects unsupported future schemas", () => { + expect(() => validateGovernanceConfig({ schemaVersion: 2 })).toThrow(/unsupported/); + }); +}); diff --git a/packages/core/src/governance/__tests__/scanner-integration.test.ts b/packages/core/src/governance/__tests__/scanner-integration.test.ts new file mode 100644 index 0000000..973d274 --- /dev/null +++ b/packages/core/src/governance/__tests__/scanner-integration.test.ts @@ -0,0 +1,30 @@ +import * as path from "path"; +import { scan } from "../../scanner"; + +const FIXTURES = path.resolve(__dirname, "../../../../../examples/contracts/governance"); + +describe("governance scanner integration", () => { + it("adds specialized findings exactly once per physical file", async () => { + const result = await scan({ + targets: [path.join(FIXTURES, "VulnerableGovernor.sol")], + useSlither: false, + useLLM: false, + useMetrics: false, + }); + const liveBalance = result.files[0].findings.filter((finding) => finding.id === "CP-GOV-001"); + expect(liveBalance).toHaveLength(1); + expect(liveBalance[0].evidence?.length).toBeGreaterThan(0); + expect(liveBalance[0].confidence).toBe("high"); + }); + + it("does not add governance findings to an unrelated contract", async () => { + const result = await scan({ + targets: [path.resolve(FIXTURES, "../UnrelatedRatioMath.sol")], + useSlither: false, + useLLM: false, + useMetrics: false, + }); + expect(result.files.flatMap((file) => file.findings).some((finding) => + finding.id.startsWith("CP-GOV-"))).toBe(false); + }); +}); diff --git a/packages/core/src/governance/adapters.ts b/packages/core/src/governance/adapters.ts new file mode 100644 index 0000000..14ce0bd --- /dev/null +++ b/packages/core/src/governance/adapters.ts @@ -0,0 +1,136 @@ +import type { + GovernanceContractModel, + GovernanceFrameworkAdapterDefinition, + GovernanceFrameworkMatch, +} from "./types"; + +export const GOVERNANCE_FRAMEWORK_ADAPTERS: readonly GovernanceFrameworkAdapterDefinition[] = + Object.freeze([ + { + id: "openzeppelin-governor", + displayName: "OpenZeppelin Governor with checkpointed votes", + requiredStateGroups: [["proposalThreshold"], ["votingDelay"], ["votingPeriod"]], + requiredFunctions: ["propose", "castVote", "proposalSnapshot", "proposalDeadline"], + mitigations: [ + "Proposal lifecycle has explicit snapshot and deadline functions", + "Voting delay and voting period are independently represented", + ], + limitations: [ + "The adapter does not prove _getVotes uses a past checkpoint", + "Queue and execution safety depend on the configured executor/timelock", + ], + }, + { + id: "openzeppelin-timelock-controller", + displayName: "OpenZeppelin TimelockController operation lifecycle", + requiredStateGroups: [["_minDelay", "minDelay"], ["timestamps", "_timestamps"]], + requiredFunctions: ["hashOperation", "schedule", "execute", "updateDelay"], + mitigations: [ + "Operations are identified by target, value, calldata, predecessor, and salt", + "Delay updates are expected to execute through the timelock itself", + ], + limitations: [ + "Role assignments and open executor policy remain deployment-specific", + "The adapter does not prove predecessor checks dominate external calls", + ], + }, + { + id: "compound-governor-bravo", + displayName: "Compound Governor Bravo proposal lifecycle", + requiredStateGroups: [["proposalCount"], ["proposalThreshold"], ["quorumVotes"]], + requiredFunctions: ["propose", "castVote", "queue", "execute", "state"], + mitigations: [ + "Proposal state, queue, and execution are represented as separate transitions", + ], + limitations: [ + "The adapter does not assume token.getPriorVotes uses safe block boundaries", + "Guardian cancellation and abdication need independent review", + ], + }, + { + id: "safe-multisig", + displayName: "Safe-style threshold multisignature execution", + requiredStateGroups: [["threshold"], ["owners", "signers"], ["nonce"]], + requiredFunctions: ["execTransaction", "checkSignatures"], + mitigations: [ + "Execution carries a nonce and a separately validated signature set", + ], + limitations: [ + "Modules, guards, fallback handlers, and owner uniqueness remain configuration-sensitive", + ], + }, + { + id: "cross-chain-governor", + displayName: "Domain-separated cross-chain governance receiver", + requiredStateGroups: [["messageId", "processedMessages"], ["sourceChainId", "chainId", "domain"]], + requiredFunctions: ["receiveMessage"], + mitigations: [ + "Messages have replay state and an explicit source-chain or domain signal", + ], + limitations: [ + "The adapter does not establish bridge authenticity or finality assumptions", + ], + }, + ]); + +export function matchGovernanceFramework( + model: Pick, +): GovernanceFrameworkMatch { + 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 GOVERNANCE_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 (stateRoles.has("vote-snapshot") && roles.has("cast-vote")) { + return { adapter: "checkpointed-governance", matchedState: [], matchedFunctions: [] }; + } + if (roles.has("schedule") || stateRoles.has("minimum-delay")) { + return { adapter: "generic-timelock", matchedState: [], matchedFunctions: [] }; + } + if (roles.has("propose") || roles.has("cast-vote")) { + return { adapter: "generic-governance", matchedState: [], matchedFunctions: [] }; + } + return { adapter: "none", matchedState: [], matchedFunctions: [] }; +} + +export function getGovernanceFrameworkAdapter( + id: GovernanceFrameworkAdapterDefinition["id"], +): GovernanceFrameworkAdapterDefinition { + const adapter = GOVERNANCE_FRAMEWORK_ADAPTERS.find((candidate) => candidate.id === id); + if (!adapter) throw new Error(`Unknown governance framework adapter: ${id}`); + return adapter; +} + +function normalize(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} diff --git a/packages/core/src/governance/analyzer.ts b/packages/core/src/governance/analyzer.ts new file mode 100644 index 0000000..449bc86 --- /dev/null +++ b/packages/core/src/governance/analyzer.ts @@ -0,0 +1,747 @@ +import type { + GovernanceAnalysisOptions, + GovernanceContractModel, + GovernanceEvidence, + GovernanceFinding, + GovernanceOperation, + GovernanceRuleId, + GovernanceStateVariable, + GovernanceTransition, + GovernanceVariableRole, +} from "./types"; + +type Rule = (model: GovernanceContractModel) => GovernanceFinding[]; + +const RULE_ORDER: readonly GovernanceRuleId[] = Array.from({ length: 16 }, (_, index) => + `CP-GOV-${String(index + 1).padStart(3, "0")}` as GovernanceRuleId, +); + +const RULES: Record = { + "CP-GOV-001": detectLiveBalanceVoting, + "CP-GOV-002": detectSameBlockVoting, + "CP-GOV-003": detectWeakQuorumArithmetic, + "CP-GOV-004": detectUnsafeVotingWindow, + "CP-GOV-005": detectMissingTimelockReadiness, + "CP-GOV-006": detectReplayableExecution, + "CP-GOV-007": detectIncompleteProposalIdentity, + "CP-GOV-008": detectArbitraryProposalExecution, + "CP-GOV-009": detectGuardianBypass, + "CP-GOV-010": detectUnsafeDelayUpdate, + "CP-GOV-011": detectMissingPredecessorDependency, + "CP-GOV-012": detectSaltCollision, + "CP-GOV-013": detectCollapsedRoles, + "CP-GOV-014": detectProposalControlledUpgrade, + "CP-GOV-015": detectCrossChainReplay, + "CP-GOV-016": detectWeakMultisigExecution, +}; + +export function analyzeGovernanceModel( + model: GovernanceContractModel, + options: GovernanceAnalysisOptions = {}, +): GovernanceFinding[] { + const include = options.includeRules ? new Set(options.includeRules) : null; + const exclude = new Set(options.excludeRules ?? []); + const findings: GovernanceFinding[] = []; + 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 detectLiveBalanceVoting(model: GovernanceContractModel): GovernanceFinding[] { + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["voting-power", "cast-vote"])) { + const balanceCall = transition.operations.find((operation) => + operation.kind === "call" && operation.name.toLowerCase() === "balanceof", + ); + if (!balanceCall) continue; + const source = codeText(transition.source); + if (/getpastvotes|getpriorvotes|checkpoints?\s*\[/i.test(source)) continue; + findings.push(finding({ + ruleId: "CP-GOV-001", + title: `Live token balance determines voting power in ${transition.name}`, + description: + "Voting weight is read from the token's current balance rather than a proposal snapshot. " + + "Borrowed or temporarily transferred tokens can vote and then leave without preserving the " + + "economic state on which the vote was authorized.", + recommendation: + "Use checkpointed delegated voting and query getPastVotes/getPriorVotes at a proposal snapshot " + + "strictly before the current block. Keep the snapshot fixed for the entire voting period.", + severity: "critical", + confidence: "high", + category: "voting-power", + model, + transition, + evidence: [operationEvidence(balanceCall, "Voting path reads a live token balance")], + assumptions: ["Governance tokens can be transferred or borrowed during the voting lifecycle"], + })); + } + return findings; +} + +function detectSameBlockVoting(model: GovernanceContractModel): GovernanceFinding[] { + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["cast-vote", "voting-power"])) { + const source = codeText(transition.source); + const usesCurrentBlock = /\bblock\.number\b/.test(source) && + !/block\.number\s*-\s*1/.test(source) && !/getPastVotes|getPriorVotes/i.test(source) && + !/proposalSnapshot|snapshotBlock|startBlock/i.test(source); + const currentVoteCall = transition.operations.find((operation) => + operation.kind === "call" && /getvotes|getpastvotes|getpriorvotes/i.test(operation.name) && + /block\.number/.test(operation.expression) && !/block\.number\s*-\s*1/.test(operation.expression), + ); + if (!usesCurrentBlock && !currentVoteCall) continue; + findings.push(finding({ + ruleId: "CP-GOV-002", + title: `Voting power can be acquired and used in the same block`, + description: + `${transition.name} resolves voting power at the current block rather than a finalized earlier ` + + "snapshot. A flash-loan or atomic delegation can therefore acquire voting power, cast a vote, " + + "and unwind before the transaction or block completes.", + recommendation: + "Set proposal snapshots at least one block after creation and only query finalized checkpoints " + + "from a block strictly less than block.number. Reject future/current timepoints in vote tokens.", + severity: "critical", + confidence: "high", + category: "voting-power", + model, + transition, + evidence: currentVoteCall + ? [operationEvidence(currentVoteCall, "Vote lookup uses the current block")] + : [absenceEvidence(transition, "Current-block lookup has no earlier proposal snapshot")], + assumptions: ["Voting power can be delegated or borrowed atomically"], + })); + } + return findings; +} + +function detectWeakQuorumArithmetic(model: GovernanceContractModel): GovernanceFinding[] { + const findings: GovernanceFinding[] = []; + const quorumVariables = variables(model, ["quorum", "quorum-numerator", "quorum-denominator"]); + for (const transition of byRoles(model, ["quorum", "proposal-state"])) { + const badDivision = transition.operations.find((operation) => + operation.kind === "arithmetic" && divisionBeforeMultiplication(operation.expression), + ); + const source = codeText(transition.source); + const zeroAcceptance = /(?:quorum|threshold)\s*(?:==|<=)\s*0|return\s+0\s*;/i.test(source); + if (!badDivision && !zeroAcceptance) continue; + findings.push(finding({ + ruleId: "CP-GOV-003", + title: `Quorum or threshold arithmetic can collapse to zero`, + description: + "Governance acceptance math truncates before multiplication or explicitly permits a zero " + + "threshold. Small supplies and low numerator values can reduce the required participation to " + + "zero or materially below the configured fraction.", + recommendation: + "Multiply total checkpointed supply by the quorum numerator before division using full-precision " + + "mulDiv, require a non-zero denominator and result, and define inclusive boundary behavior.", + severity: "high", + confidence: "high", + category: "quorum-threshold", + model, + transition, + evidence: [ + ...(badDivision ? [operationEvidence(badDivision, "Division occurs before quorum multiplication")] : []), + ...(quorumVariables[0] ? [variableEvidence(quorumVariables[0], "Governance quorum state")] : []), + ], + assumptions: ["Solidity integer truncation applies to the quorum calculation"], + })); + } + return findings; +} + +function detectUnsafeVotingWindow(model: GovernanceContractModel): GovernanceFinding[] { + const proposals = byRoles(model, ["propose"]); + if (!proposals.length) return []; + const delays = variables(model, ["voting-delay"]); + const periods = variables(model, ["voting-period"]); + const findings: GovernanceFinding[] = []; + for (const transition of proposals) { + const source = codeText(transition.source); + const sameBlockWindow = /(?:startBlock|voteStart|snapshot)\s*=\s*block\.number[^;]*;[^}]*(?:endBlock|deadline)\s*=\s*block\.number\s*;/i.test(source); + if (delays.length && periods.length && !sameBlockWindow) continue; + findings.push(finding({ + ruleId: "CP-GOV-004", + title: "Proposal lifecycle lacks a complete non-zero voting window", + description: + "Proposal creation does not expose both an independent voting delay and voting period, or " + + "sets snapshot and deadline to the same block. Reviewers and delegates may have no stable " + + "interval in which to observe, delegate, and vote on the proposal.", + recommendation: + "Persist a future snapshot and a strictly later deadline. Validate votingDelay > 0 and " + + "votingPeriod > 0 at configuration and proposal creation boundaries.", + severity: "high", + confidence: delays.length || periods.length ? "medium" : "high", + category: "proposal-lifecycle", + model, + transition, + evidence: [absenceEvidence(transition, "A complete delayed voting window was not modeled")], + assumptions: ["Proposal creation is expected to provide delegates time to react"], + })); + } + return findings; +} + +function detectMissingTimelockReadiness(model: GovernanceContractModel): GovernanceFinding[] { + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["execute", "multisig-execute"])) { + const call = privilegedCall(transition); + if (!call || transition.role === "multisig-execute") continue; + const ready = hasTimelockReadiness(transition); + if (ready) continue; + findings.push(finding({ + ruleId: "CP-GOV-005", + title: `Proposal execution has no queued timelock readiness proof`, + description: + `${transition.name} reaches a privileged external call without proving that the proposal was ` + + "queued and its execution delay elapsed. A passing vote or privileged caller can execute " + + "immediately, removing the reaction period expected from governance.", + recommendation: + "Require a proposal-specific queued operation ID, eta, and ready state from a timelock. Consume " + + "the queued operation before the call and enforce grace-period/expiry policy explicitly.", + severity: "critical", + confidence: "high", + category: "timelock", + model, + transition, + evidence: [ + operationEvidence(call, "Privileged external call is reachable"), + absenceEvidence(transition, "No queued-operation readiness guard was identified"), + ], + assumptions: ["Governance execution is intended to be delayed after approval"], + })); + } + return findings; +} + +function detectReplayableExecution(model: GovernanceContractModel): GovernanceFinding[] { + const executed = new Set(variables(model, ["executed-state", "operation-hash", "nonce"]).map((item) => item.name)); + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["execute", "multisig-execute"])) { + const call = privilegedCall(transition); + if (!call) continue; + const write = firstWrite(transition, executed); + const guard = transition.operations.find((operation) => + operation.kind === "guard" && [...executed].some((name) => operation.expression.includes(name)), + ); + const signedNonce = transition.role === "multisig-execute" && write && write.order < call.order && + /checksignatures|validatesignatures|recover\s*\(/i.test(codeText(transition.source)) && + /\bnonce\b/i.test(codeText(transition.source)); + if ((guard && write && write.order < call.order) || signedNonce) continue; + findings.push(finding({ + ruleId: "CP-GOV-006", + title: `Proposal or transaction can be replayed through ${transition.name}`, + description: + "Execution does not both reject an already-consumed proposal/nonce and mark it consumed before " + + "the external interaction. The same approved action can be replayed directly or through reentry.", + recommendation: + "Bind execution to a unique proposal/operation hash or nonce, require it unused, and consume it " + + "before external calls. Preserve the consumed state even when actions contain callbacks.", + severity: "critical", + confidence: "high", + category: "replay", + model, + transition, + evidence: [ + operationEvidence(call, "External proposal action executes"), + ...(write ? [operationEvidence(write, "Consumption state is written after or without a guard")] : + [absenceEvidence(transition, "No proposal/operation consumption write was identified")]), + ], + assumptions: ["The same execution parameters can be submitted more than once"], + })); + } + return findings; +} + +function detectIncompleteProposalIdentity(model: GovernanceContractModel): GovernanceFinding[] { + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["hash-proposal"])) { + const actionParameters = transition.parameters.filter((parameter) => + /target|value|calldata|signature|description|salt/i.test(parameter), + ); + const missing = actionParameters.filter((parameter) => + !new RegExp(`\\b${escapeRegExp(parameter)}\\b`).test(codeText(transition.source).replace(/function[^{]+{/, "")), + ); + if (!missing.length) continue; + findings.push(finding({ + ruleId: "CP-GOV-007", + title: "Proposal identity omits action-defining fields", + description: + `The proposal hash omits ${missing.join(", ")}. Distinct or duplicate action batches can ` + + "share an identifier, overwrite state, reuse approvals, or execute calldata different from " + + "what voters reviewed.", + recommendation: + "Hash the complete ordered targets, values, calldata/signatures, description hash, predecessor, " + + "salt, and domain as applicable. Reject duplicate action tuples in one proposal.", + severity: "high", + confidence: "high", + category: "proposal-lifecycle", + model, + transition, + evidence: [absenceEvidence(transition, `Hash body omits: ${missing.join(", ")}`)], + assumptions: ["The hash is used as the authoritative proposal identity"], + })); + } + for (const transition of byRoles(model, ["execute"])) { + const seen = new Map(); + for (const operation of transition.operations.filter((item) => + item.kind === "call" && /call|delegatecall|functioncall|execute|upgradeto/i.test(item.name), + )) { + const identity = operation.expression.replace(/\s+/g, " ").trim(); + const previous = seen.get(identity); + if (!previous) { + seen.set(identity, operation); + continue; + } + findings.push(finding({ + ruleId: "CP-GOV-007", + title: "Proposal execution contains a duplicate action", + description: + "The same target/value/calldata expression is executed more than once in one proposal path. " + + "A duplicated transfer or privileged selector can apply an approved state transition twice.", + recommendation: + "Reject duplicate action tuples during proposal creation and bind the ordered, length-prefixed " + + "action array into the proposal hash. If repetition is intentional, document and test it explicitly.", + severity: "high", + confidence: "high", + category: "proposal-lifecycle", + model, + transition, + location: operation.location, + evidence: [ + operationEvidence(previous, "First occurrence of the proposal action"), + operationEvidence(operation, "Duplicate occurrence of the same proposal action"), + ], + assumptions: ["Both statically identical actions are reachable in the same execution"], + })); + } + } + return findings; +} + +function detectArbitraryProposalExecution(model: GovernanceContractModel): GovernanceFinding[] { + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["execute"])) { + const call = transition.operations.find((operation) => + operation.kind === "call" && operation.parameterSources.some((name) => /target|value|data|calldata/i.test(name)) && + /call|execute|functioncall/i.test(operation.name), + ); + if (!call) continue; + const source = codeText(transition.source); + const bounded = /isoperationready|timelock|allowlist|whitelist|approvedtarget/i.test(source) || + hasTimelockReadiness(transition); + if (bounded) continue; + findings.push(finding({ + ruleId: "CP-GOV-008", + title: "Proposal-controlled target, value, and calldata reach an arbitrary call", + description: + "Action parameters controlled by a proposal flow directly into a privileged low-level call " + + "without a timelock or target/selector policy. Any governance capture immediately becomes " + + "arbitrary asset transfer, configuration, or authorization control.", + recommendation: + "Execute only operation hashes queued in a separate timelock, bind the complete calldata/value " + + "to the approved proposal ID, and consider selector/target restrictions for sensitive systems.", + severity: "critical", + confidence: "high", + category: "execution", + model, + transition, + evidence: [taintEvidence(call, "Proposal parameters flow into a privileged external call")], + assumptions: ["A proposal author can choose the modeled action arrays"], + })); + } + return findings; +} + +function detectGuardianBypass(model: GovernanceContractModel): GovernanceFinding[] { + const guardians = variables(model, ["guardian"]); + if (!guardians.length) return []; + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["emergency-execute", "cancel", "upgrade"])) { + const call = privilegedCall(transition); + if (!call) continue; + const source = codeText(transition.source); + if (/timelock|isoperationready|multisig|threshold/i.test(source)) continue; + findings.push(finding({ + ruleId: "CP-GOV-009", + title: `Guardian path bypasses governance execution controls`, + description: + `${transition.name} gives a guardian or emergency council a privileged execution path without ` + + "the proposal, quorum, timelock, or multisig conditions applied to normal governance.", + recommendation: + "Limit emergency authority to narrowly enumerated pause/cancel selectors, require multisig " + + "approval, prevent upgrades/asset transfers, and route any broader action through the timelock.", + severity: "critical", + confidence: "high", + category: "authorization", + model, + transition, + evidence: [ + variableEvidence(guardians[0], "Guardian authority is persistent state"), + operationEvidence(call, "Guardian path reaches a privileged call"), + ], + assumptions: ["The guardian can satisfy the path's access-control condition"], + })); + } + return findings; +} + +function detectUnsafeDelayUpdate(model: GovernanceContractModel): GovernanceFinding[] { + const delays = new Set(variables(model, ["minimum-delay"]).map((item) => item.name)); + if (!delays.size) return []; + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["set-delay"])) { + const write = firstWrite(transition, delays); + if (!write) continue; + const source = codeText(transition.source); + const onlySelf = /msg\.sender\s*==\s*address\s*\(\s*this\s*\)|onlyself/i.test(source) || + transition.modifiers.some((modifier) => /onlyself|timelock/i.test(modifier)); + if (onlySelf) continue; + findings.push(finding({ + ruleId: "CP-GOV-010", + title: "Timelock delay can be changed outside the timelock lifecycle", + description: + "The minimum delay is directly mutable by a caller rather than only by a scheduled self-call. " + + "An administrator can reduce the delay and execute a privileged action before users can react.", + recommendation: + "Require msg.sender == address(this) for delay updates, schedule the update with the current " + + "delay, and impose a non-zero minimum or bounded reduction policy.", + severity: "critical", + confidence: "high", + category: "timelock", + model, + transition, + evidence: [ + operationEvidence(write, "Minimum-delay state is updated"), + absenceEvidence(transition, "No scheduled self-call authorization was identified"), + ], + assumptions: ["A privileged role can invoke the delay update path"], + })); + } + return findings; +} + +function detectMissingPredecessorDependency(model: GovernanceContractModel): GovernanceFinding[] { + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["execute"])) { + const predecessor = transition.parameters.find((parameter) => /predecessor/i.test(parameter)); + if (!predecessor) continue; + const sourceBody = codeText(transition.source).replace(/function[^{]+{/, ""); + const enforced = /isoperationdone|missingdependency|timelock\.execute|predecessor\s*==\s*bytes32\s*\(\s*0\s*\)/i.test(sourceBody) || + transition.operations.some((operation) => operation.kind === "guard" && + /predecessor/i.test(operation.expression) && /timestamp|done|completed/i.test(operation.expression)); + if (enforced) continue; + findings.push(finding({ + ruleId: "CP-GOV-011", + title: "Timelock predecessor is accepted but not enforced", + description: + `${transition.name} accepts a predecessor operation but does not require it to be completed. ` + + "Dependent governance actions can execute out of order and violate staged migration or upgrade invariants.", + recommendation: + "Before any external action, require predecessor == bytes32(0) or isOperationDone(predecessor), " + + "and include the predecessor in the operation hash.", + severity: "high", + confidence: "high", + category: "timelock", + model, + transition, + evidence: [absenceEvidence(transition, "Predecessor parameter has no completion guard")], + assumptions: ["Callers rely on predecessor ordering for dependent operations"], + })); + } + return findings; +} + +function detectSaltCollision(model: GovernanceContractModel): GovernanceFinding[] { + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["hash-operation", "schedule"])) { + const salt = transition.parameters.find((parameter) => /salt/i.test(parameter)); + if (!salt) continue; + const body = codeText(transition.source).replace(/function[^{]+{/, ""); + if (new RegExp(`\\b${escapeRegExp(salt)}\\b`).test(body)) continue; + findings.push(finding({ + ruleId: "CP-GOV-012", + title: "Timelock operation identity omits its salt", + description: + "The operation accepts a salt but does not bind it into scheduling or hashing. Identical action " + + "tuples collide, preventing independent scheduling or allowing old operation state to authorize a new action.", + recommendation: + "Include the caller-provided salt, predecessor, target, value, calldata, and chain/domain in the " + + "operation hash. Require an unused operation ID before scheduling.", + severity: "high", + confidence: "high", + category: "replay", + model, + transition, + evidence: [absenceEvidence(transition, "Operation body does not use the salt parameter")], + assumptions: ["Operation hashes identify queue and execution state"], + })); + } + return findings; +} + +function detectCollapsedRoles(model: GovernanceContractModel): GovernanceFinding[] { + const schedules = byRoles(model, ["schedule", "queue"]); + const executes = byRoles(model, ["execute"]); + if (!schedules.length || !executes.length) return []; + const roleState = variables(model, ["proposer-role", "executor-role"]); + if (roleState.length >= 2) return []; + for (const schedule of schedules) { + for (const execute of executes) { + const common = schedule.modifiers.filter((modifier) => execute.modifiers.includes(modifier)); + if (!common.length && (schedule.modifiers.length || execute.modifiers.length)) continue; + return [finding({ + ruleId: "CP-GOV-013", + title: "Proposal scheduling and execution authority are not separated", + description: + "The same access-control path can schedule and execute operations without distinct proposer " + + "and executor roles. A single compromised administrator controls both admission and completion.", + recommendation: + "Separate proposer, canceller, executor, and default-admin roles. Renounce bootstrap admin after " + + "configuration and document whether the executor role is intentionally open.", + severity: "medium", + confidence: "medium", + category: "authorization", + model, + transition: execute, + evidence: [absenceEvidence(execute, "Distinct proposer and executor role state was not modeled")], + assumptions: ["Role separation is part of the intended governance trust model"], + })]; + } + } + return []; +} + +function detectProposalControlledUpgrade(model: GovernanceContractModel): GovernanceFinding[] { + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["execute", "emergency-execute", "upgrade"])) { + const call = transition.operations.find((operation) => + operation.kind === "call" && /delegatecall|upgradeto|upgradetoandcall/i.test(operation.name + operation.expression) && + operation.parameterSources.length > 0, + ); + if (!call) continue; + const source = codeText(transition.source); + if (/timelock|isoperationready/i.test(source) && transition.role === "execute") continue; + findings.push(finding({ + ruleId: "CP-GOV-014", + title: "Proposal-controlled calldata reaches an immediate upgrade primitive", + description: + "A proposal, guardian, or caller controls data passed to delegatecall/upgradeTo without a proven " + + "timelock boundary. Governance capture can replace implementation logic immediately.", + recommendation: + "Bind implementation and initialization calldata to a queued proposal hash, enforce the timelock, " + + "and keep upgrade authorization independent from emergency execution authority.", + severity: "critical", + confidence: "high", + category: "upgrade", + model, + transition, + evidence: [taintEvidence(call, "Caller/proposal parameters flow into an upgrade primitive")], + assumptions: ["The called target is or can control an upgradeable proxy"], + })); + } + return findings; +} + +function detectCrossChainReplay(model: GovernanceContractModel): GovernanceFinding[] { + const messageState = new Set(variables(model, ["message-id", "nonce"]).map((item) => item.name)); + const domains = variables(model, ["chain-domain"]); + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["cross-chain-receive"])) { + const call = privilegedCall(transition); + if (!call) continue; + const write = firstWrite(transition, messageState); + const source = codeText(transition.source); + const domainGuard = domains.some((domain) => source.includes(domain.name)) || + /sourcechain|domainseparator|block\.chainid/i.test(source); + if (write && write.order < call.order && domainGuard) continue; + findings.push(finding({ + ruleId: "CP-GOV-015", + title: "Cross-chain governance message lacks replay or domain protection", + description: + "A received governance message reaches privileged execution without consuming a unique message ID " + + "before the call and binding authorization to a source chain/domain. Relayers or bridges can redeliver it.", + recommendation: + "Authenticate the bridge and source governor, include source/destination chain IDs and nonce in the " + + "message hash, reject consumed IDs, and mark the ID consumed before external execution.", + severity: "critical", + confidence: "high", + category: "cross-chain", + model, + transition, + evidence: [ + operationEvidence(call, "Cross-chain path reaches a privileged call"), + ...(write ? [] : [absenceEvidence(transition, "No pre-call message consumption write was identified")]), + ...(!domainGuard ? [absenceEvidence(transition, "No source-chain/domain binding was identified")] : []), + ], + assumptions: ["The transport can deliver duplicate or cross-domain messages"], + })); + } + return findings; +} + +function detectWeakMultisigExecution(model: GovernanceContractModel): GovernanceFinding[] { + const thresholds = variables(model, ["signature-threshold"]); + const nonces = new Set(variables(model, ["nonce"]).map((item) => item.name)); + const signers = variables(model, ["signer-set"]); + if (!thresholds.length && !signers.length) return []; + const findings: GovernanceFinding[] = []; + for (const transition of byRoles(model, ["multisig-execute"])) { + const call = privilegedCall(transition); + if (!call) continue; + const source = codeText(transition.source); + const checksSignatures = /checksignatures|validatesignatures|recover\s*\(/i.test(source); + const readsThreshold = thresholds.some((threshold) => source.includes(threshold.name)); + const nonceWrite = firstWrite(transition, nonces); + if (checksSignatures && readsThreshold && nonceWrite && nonceWrite.order < call.order) continue; + findings.push(finding({ + ruleId: "CP-GOV-016", + title: "Multisig execution does not prove threshold signatures and nonce consumption", + description: + "The multisig execution path reaches an external call without visibly validating distinct signer " + + "approvals against the stored threshold and consuming a nonce before interaction.", + recommendation: + "Hash the full transaction and domain with a monotonic nonce, recover unique sorted owners, require " + + "valid signatures >= threshold, increment the nonce, then execute. Bound threshold to [1, owners.length].", + severity: "critical", + confidence: "high", + category: "multisig", + model, + transition, + evidence: [ + operationEvidence(call, "Multisig path reaches an external call"), + absenceEvidence(transition, "Complete threshold-signature and pre-call nonce proof was not identified"), + ], + assumptions: ["No inherited modifier performs the missing signature validation"], + })); + } + return findings; +} + +interface FindingInput { + ruleId: GovernanceRuleId; + title: string; + description: string; + recommendation: string; + severity: GovernanceFinding["severity"]; + confidence: GovernanceFinding["confidence"]; + category: GovernanceFinding["category"]; + model: GovernanceContractModel; + transition?: GovernanceTransition; + location?: GovernanceFinding["location"]; + evidence: GovernanceEvidence[]; + assumptions: string[]; +} + +function finding(input: FindingInput): GovernanceFinding { + 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: GovernanceContractModel, + roles: GovernanceTransition["role"][], +): GovernanceTransition[] { + const selected = new Set(roles); + return model.transitions.filter((transition) => selected.has(transition.role)); +} + +function variables( + model: GovernanceContractModel, + roles: GovernanceVariableRole[], +): GovernanceStateVariable[] { + const selected = new Set(roles); + return model.stateVariables.filter((variable) => selected.has(variable.role)); +} + +function privilegedCall(transition: GovernanceTransition): GovernanceOperation | undefined { + return transition.operations.find((operation) => + operation.kind === "call" && (/call|delegatecall|functioncall|execute|upgradeto/i.test(operation.name) || + /\.call\s*\{|\.delegatecall\s*\(/i.test(operation.expression)), + ); +} + +function hasTimelockReadiness(transition: GovernanceTransition): boolean { + const source = codeText(transition.source); + if (/isoperationready|isoperationdone|eta|queuedat|minimumdelay|mindelay|timelock\.execute|block\.timestamp\s*>=/i.test(source)) { + return true; + } + if (transition.modifiers.some((modifier) => /timelock|onlyready|queued/i.test(modifier))) return true; + return transition.operations.some((operation) => operation.kind === "guard" && + /block\.timestamp/i.test(operation.expression) && /timestamp|operation|eta|queued/i.test(operation.expression)); +} + +function firstWrite( + transition: GovernanceTransition, + names: Set, +): GovernanceOperation | undefined { + return transition.operations.find((operation) => + operation.kind === "write" && [...names].some((name) => + operation.name.split(",").includes(name) || operation.expression.includes(name), + ), + ); +} + +function operationEvidence(operation: GovernanceOperation, description: string): GovernanceEvidence { + 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: GovernanceOperation, description: string): GovernanceEvidence { + return { + kind: "taint-flow", + description: `${description}; sources: ${operation.parameterSources.join(", ")}`, + location: operation.location, + snippet: operation.expression, + }; +} + +function variableEvidence(variable: GovernanceStateVariable, description: string): GovernanceEvidence { + return { + kind: "state-read", + description, + location: variable.location, + snippet: `${variable.typeName} ${variable.name}`, + }; +} + +function absenceEvidence(transition: GovernanceTransition, description: string): GovernanceEvidence { + return { kind: "absence", description, location: transition.location }; +} + +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, " "); +} + +function compareFindings(left: GovernanceFinding, right: GovernanceFinding): 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); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/core/src/governance/api.ts b/packages/core/src/governance/api.ts new file mode 100644 index 0000000..1861807 --- /dev/null +++ b/packages/core/src/governance/api.ts @@ -0,0 +1,257 @@ +import * as fs from "fs"; +import * as path from "path"; +import { analyzeGovernanceModel } from "./analyzer"; +import { + GovernanceAnalysisCancelledError, + resolveGovernanceLimits, +} from "./config"; +import { buildGovernanceModels } from "./model"; +import type { + GovernanceAnalysisOptions, + GovernanceAnalysisReport, + GovernanceContractModel, + GovernanceDiagnostic, + GovernanceFileAnalysis, + GovernanceFinding, + GovernanceSourceInput, +} from "./types"; + +export const GOVERNANCE_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 analyzeGovernanceSource( + source: string, + file = ".sol", + options: GovernanceAnalysisOptions = {}, +): GovernanceAnalysisReport { + return analyzeGovernanceSources([{ file, source }], options); +} + +/** Analyze an explicitly supplied, deterministic set of Solidity sources. */ +export function analyzeGovernanceSources( + inputs: GovernanceSourceInput[], + options: GovernanceAnalysisOptions = {}, +): GovernanceAnalysisReport { + const limits = resolveGovernanceLimits(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: GovernanceFileAnalysis[] = []; + 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 = buildGovernanceModels(input.source, input.file, limits, options.signal); + contractCount += built.models.length; + const findings: GovernanceFinding[] = []; + for (const model of built.models) { + checkCancelled(options); + for (const finding of analyzeGovernanceModel(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: "GOV_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 collectGovernanceSolidityFiles(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 analyzeGovernanceFiles( + targets: string[], + options: GovernanceAnalysisOptions = {}, +): GovernanceAnalysisReport { + const limits = resolveGovernanceLimits(options.limits); + checkCancelled(options); + const discovered = collectGovernanceSolidityFiles(targets); + const inputs: GovernanceSourceInput[] = []; + const unreadable: GovernanceFileAnalysis[] = []; + 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 = analyzeGovernanceSources(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: "GOV_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: GovernanceFileAnalysis[], + truncated: boolean, + contractCount: number, +): GovernanceAnalysisReport { + 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) { + summary[finding.severity] += 1; + summary.total += 1; + } + if (file.diagnostics.some((diagnostic) => + diagnostic.code === "GOV_FINDING_LIMIT" || diagnostic.code.endsWith("_LIMIT"))) { + summary.truncated = true; + } + } + return { + schemaVersion: "1.0.0", + engineVersion: GOVERNANCE_ENGINE_VERSION, + files, + summary, + }; +} + +function sortModel(model: GovernanceContractModel): GovernanceContractModel { + 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), + proposalControlledCalls: [...model.proposalControlledCalls].sort((left, right) => left.order - right.order), + }; +} + +function limitDiagnostic(file: string, limit: number): GovernanceDiagnostic { + return { + code: "GOV_FINDING_LIMIT", + severity: "warning", + message: `Finding output was limited to ${limit} records`, + location: { file, line: 1, column: 1 }, + }; +} + +function compareFindings(left: GovernanceFinding, right: GovernanceFinding): 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: GovernanceDiagnostic, right: GovernanceDiagnostic): number { + return (left.location?.line ?? 0) - (right.location?.line ?? 0) || + left.code.localeCompare(right.code) || left.message.localeCompare(right.message); +} + +function checkCancelled(options: GovernanceAnalysisOptions): void { + if (options.signal?.aborted) throw new GovernanceAnalysisCancelledError(); +} + +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): GovernanceFileAnalysis { + return { + file, + findings: [], + diagnostics: [{ + code: "GOV_FILE_UNREADABLE", + severity: "error", + message: `Solidity target could not be read (${safeErrorCode(error)})`, + location: { file, line: 1, column: 1 }, + }], + }; +} + +export const GOVERNANCE_SEVERITY_ORDER = SEVERITIES; diff --git a/packages/core/src/governance/config.ts b/packages/core/src/governance/config.ts new file mode 100644 index 0000000..9610e74 --- /dev/null +++ b/packages/core/src/governance/config.ts @@ -0,0 +1,216 @@ +import * as fs from "fs"; +import { + GOVERNANCE_CONFIG_SCHEMA_VERSION, + type GovernanceAnalysisConfigInput, + type GovernanceAnalysisConfigV1, + type GovernanceAnalysisLimits, + type GovernanceDiagnostic, + type GovernanceRuleId, + type ValidatedGovernanceConfig, +} from "./types"; + +export const DEFAULT_GOVERNANCE_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-GOV-${String(index + 1).padStart(3, "0")}`, +)); + +const LIMIT_KEYS: Array = [ + "maxSourceBytes", + "maxFiles", + "maxContracts", + "maxFunctionsPerFile", + "maxFunctionsPerContract", + "maxOperationsPerFunction", + "maxFindings", + "maxEvidencePerFinding", +]; + +export class GovernanceConfigError extends Error { + readonly code = "GOV_CONFIG_INVALID"; + + constructor(message: string) { + super(message); + this.name = "GovernanceConfigError"; + } +} + +export class GovernanceAnalysisCancelledError extends Error { + readonly code = "GOV_CANCELLED"; + + constructor() { + super("Governance safety analysis was cancelled"); + this.name = "GovernanceAnalysisCancelledError"; + } +} + +export function resolveGovernanceLimits( + input?: Partial, +): GovernanceAnalysisLimits { + if (input !== undefined && !isRecord(input)) { + throw new GovernanceConfigError("limits must be an object"); + } + const result: GovernanceAnalysisLimits = { ...DEFAULT_GOVERNANCE_LIMITS }; + for (const key of LIMIT_KEYS) { + const value = input?.[key]; + if (value === undefined) continue; + if (!Number.isSafeInteger(value) || value <= 0) { + throw new GovernanceConfigError(`${key} must be a positive safe integer`); + } + result[key] = value; + } + return result; +} + +export function migrateGovernanceConfig( + input: GovernanceAnalysisConfigInput, +): ValidatedGovernanceConfig { + if (!isRecord(input)) throw new GovernanceConfigError("configuration root must be an object"); + if (input.schemaVersion === GOVERNANCE_CONFIG_SCHEMA_VERSION) return validateV1(input); + if (input.schemaVersion !== undefined && input.schemaVersion !== 0) { + throw new GovernanceConfigError( + `unsupported governance 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: GovernanceDiagnostic[] = migrated ? [{ + code: "GOV_CONFIG_INVALID", + severity: "info", + message: "Migrated governance configuration from legacy schema v0 to v1", + }] : []; + + const config: GovernanceAnalysisConfigV1 = { + schemaVersion: GOVERNANCE_CONFIG_SCHEMA_VERSION, + ...(Object.keys(limits).length ? { limits } : {}), + ...(typeof input.includeModels === "boolean" ? { includeModels: input.includeModels } : {}), + ...(includeRules ? { includeRules } : {}), + }; + resolveGovernanceLimits(config.limits); + return { config, diagnostics }; +} + +export function validateGovernanceConfig( + input: GovernanceAnalysisConfigInput, +): ValidatedGovernanceConfig { + return migrateGovernanceConfig(input); +} + +export function loadGovernanceConfigFile(filePath: string): ValidatedGovernanceConfig { + let content: string; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (error) { + throw new GovernanceConfigError(`configuration file could not be read (${errorCode(error)})`); + } + try { + return validateGovernanceConfig(JSON.parse(content) as GovernanceAnalysisConfigInput); + } catch (error) { + if (error instanceof GovernanceConfigError) throw error; + throw new GovernanceConfigError("configuration file contains invalid JSON"); + } +} + +function validateV1(input: Record): ValidatedGovernanceConfig { + rejectUnknownKeys(input, [ + "schemaVersion", "limits", "includeModels", "includeRules", "excludeRules", + ], "configuration"); + if (input.includeModels !== undefined && typeof input.includeModels !== "boolean") { + throw new GovernanceConfigError("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 GovernanceConfigError(`includeRules and excludeRules overlap: ${overlap.join(", ")}`); + } + } + return { + config: { + schemaVersion: GOVERNANCE_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 GovernanceConfigError("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); + } + resolveGovernanceLimits(limits); + return limits; +} + +function validateRules(value: unknown, field: string): GovernanceRuleId[] { + if (!Array.isArray(value)) throw new GovernanceConfigError(`${field} must be an array`); + const result = new Set(); + for (const rule of value) { + if (typeof rule !== "string" || !RULE_IDS.has(rule)) { + throw new GovernanceConfigError(`${field} contains unknown rule ${String(rule)}`); + } + result.add(rule as GovernanceRuleId); + } + return [...result].sort(); +} + +function positiveInteger(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new GovernanceConfigError(`${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 GovernanceConfigError(`${field} contains unknown field ${unknown[0]}`); +} diff --git a/packages/core/src/governance/index.ts b/packages/core/src/governance/index.ts new file mode 100644 index 0000000..39172ea --- /dev/null +++ b/packages/core/src/governance/index.ts @@ -0,0 +1,57 @@ +export { + analyzeGovernanceSource, + analyzeGovernanceSources, + analyzeGovernanceFiles, + collectGovernanceSolidityFiles, + GOVERNANCE_ENGINE_VERSION, + GOVERNANCE_SEVERITY_ORDER, +} from "./api"; +export { analyzeGovernanceModel } from "./analyzer"; +export { buildGovernanceModels } from "./model"; +export { + GOVERNANCE_FRAMEWORK_ADAPTERS, + getGovernanceFrameworkAdapter, + matchGovernanceFramework, +} from "./adapters"; +export { + DEFAULT_GOVERNANCE_LIMITS, + GovernanceAnalysisCancelledError, + GovernanceConfigError, + loadGovernanceConfigFile, + migrateGovernanceConfig, + resolveGovernanceLimits, + validateGovernanceConfig, +} from "./config"; +export { generateGovernanceMarkdown, serializeGovernanceReport } from "./serialize"; +export { detectGovernanceSafety } from "./rule"; +export { + GOVERNANCE_CONFIG_SCHEMA_VERSION, + GOVERNANCE_REPORT_SCHEMA_VERSION, +} from "./types"; +export type { + GovernanceAnalysisConfigInput, + GovernanceAnalysisConfigV0, + GovernanceAnalysisConfigV1, + GovernanceAnalysisLimits, + GovernanceAnalysisOptions, + GovernanceAnalysisReport, + GovernanceCancellationSignal, + GovernanceContractModel, + GovernanceDiagnostic, + GovernanceEvidence, + GovernanceFileAnalysis, + GovernanceFinding, + GovernanceFrameworkAdapter, + GovernanceFrameworkAdapterDefinition, + GovernanceFrameworkMatch, + GovernanceFunctionRole, + GovernanceOperation, + GovernanceRuleId, + GovernanceSourceInput, + GovernanceSourceLocation, + GovernanceStateVariable, + GovernanceTransition, + GovernanceVariableRole, + ValidatedGovernanceConfig, +} from "./types"; +export type { BuildGovernanceModelsResult } from "./model"; diff --git a/packages/core/src/governance/model.ts b/packages/core/src/governance/model.ts new file mode 100644 index 0000000..b4c6399 --- /dev/null +++ b/packages/core/src/governance/model.ts @@ -0,0 +1,522 @@ +import { parseSolidity } from "../ast/parser"; +import type { ASTNode } from "../types"; +import { matchGovernanceFramework } from "./adapters"; +import { GovernanceAnalysisCancelledError } from "./config"; +import type { + GovernanceAnalysisLimits, + GovernanceCancellationSignal, + GovernanceContractModel, + GovernanceDiagnostic, + GovernanceFunctionRole, + GovernanceOperation, + GovernanceSourceLocation, + GovernanceStateVariable, + GovernanceTransition, + GovernanceVariableRole, +} 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 BuildGovernanceModelsResult { + models: GovernanceContractModel[]; + diagnostics: GovernanceDiagnostic[]; +} + +export function buildGovernanceModels( + source: string, + file: string, + limits: GovernanceAnalysisLimits, + signal?: GovernanceCancellationSignal, +): BuildGovernanceModelsResult { + checkCancelled(signal); + if (Buffer.byteLength(source, "utf8") > limits.maxSourceBytes) { + return limited("GOV_SOURCE_LIMIT", `Source exceeds the ${limits.maxSourceBytes}-byte limit`, file); + } + const shape = preflightSourceShape(source); + if (shape.contracts > limits.maxContracts) { + return limited("GOV_CONTRACT_LIMIT", `Source declares more than ${limits.maxContracts} contracts`, file); + } + if (shape.functions > limits.maxFunctionsPerFile) { + return limited("GOV_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: "GOV_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: GovernanceDiagnostic[] = []; + if (contracts.length > limits.maxContracts) { + diagnostics.push({ + code: "GOV_CONTRACT_LIMIT", + severity: "warning", + message: `Only the first ${limits.maxContracts} contracts were analyzed`, + location: startLocation(file), + }); + } + const models: GovernanceContractModel[] = []; + 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: GovernanceAnalysisLimits, +): { model: GovernanceContractModel; diagnostics: GovernanceDiagnostic[] } { + const contract = contractNode as NodeRecord; + const stateVariables: GovernanceStateVariable[] = []; + const functions: ASTNode[] = []; + const diagnostics: GovernanceDiagnostic[] = []; + 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: "GOV_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: GovernanceTransition[] = []; + 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: "GOV_OPERATION_LIMIT", + severity: "warning", + message: `Function ${built.transition.name} exceeded the operation limit`, + location: built.transition.location, + }); + } + } + + const base: GovernanceContractModel = { + name: contract.name ?? "", + file, + adapter: "none", + stateVariables: stateVariables.sort(byLocationThenName), + transitions: transitions.sort(byLocationThenName), + privilegedCalls: [], + proposalControlledCalls: [], + 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.proposalControlledCalls = base.privilegedCalls.filter((operation) => + operation.parameterSources.length > 0, + ); + base.adapter = matchGovernanceFramework(base).adapter; + base.assumptions = inferAssumptions(base); + return { model: base, diagnostics }; +} + +function buildTransition( + source: string, + file: string, + node: ASTNode, + stateNames: Set, + limits: GovernanceAnalysisLimits, +): { transition: GovernanceTransition; 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: GovernanceOperation[] = []; + 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 === "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 ?? ""; + return { + transition: { + 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: nodeSnippet(source, fn), + }, + truncated, + }; +} + +function addOperation( + operations: GovernanceOperation[], + kind: GovernanceOperation["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): GovernanceVariableRole { + const value = normalize(name); + if (/(governancetoken|votestoken|govtoken|token)/.test(value) && !/(timelock|tokenuri)/.test(value)) return "governance-token"; + if (/(proposalcount|proposalnonce|latestproposalid)/.test(value)) return "proposal-count"; + if (/(proposals|proposalstate|proposalstatus)/.test(value)) return "proposal-state"; + if (/proposalthreshold/.test(value)) return "proposal-threshold"; + if (/quorumnumerator/.test(value)) return "quorum-numerator"; + if (/quorumdenominator/.test(value)) return "quorum-denominator"; + if (/(quorumvotes|quorumthreshold|quorum)/.test(value)) return "quorum"; + if (/votingdelay/.test(value)) return "voting-delay"; + if (/(votingperiod|votingwindow)/.test(value)) return "voting-period"; + if (/(proposalsnapshot|snapshotblock|startblock|votestart)/.test(value)) return "vote-snapshot"; + if (/(receipts|hasvoted|votereceipt)/.test(value)) return "vote-receipt"; + if (/(votingpower|voteweight|votescast)/.test(value)) return "vote-weight"; + if (/(proposaleta|queuedat|executiontime|eta)/.test(value)) return "proposal-eta"; + if (/(mindelay|minimumdelay|timelockdelay)/.test(value)) return "minimum-delay"; + if (/(operationhash|operationid|timestamps)/.test(value)) return "operation-hash"; + if (/(executed|isexecuted)/.test(value)) return "executed-state"; + if (/(canceled|cancelled|iscanceled)/.test(value)) return "canceled-state"; + if (/(nonce|nonces)/.test(value)) return "nonce"; + if (/salt/.test(value)) return "salt"; + if (/predecessor/.test(value)) return "predecessor"; + if (/proposerrole/.test(value)) return "proposer-role"; + if (/executorrole/.test(value)) return "executor-role"; + if (/(adminrole|defaultadminrole|timelockadmin)/.test(value)) return "admin-role"; + if (/(guardian|emergencycouncil|securitycouncil)/.test(value)) return "guardian"; + if (/(owners|signers|members)/.test(value) && (typeName.startsWith("mapping(") || typeName.endsWith("[]"))) return "signer-set"; + if (/(signaturethreshold|multisigthreshold|threshold)/.test(value)) return "signature-threshold"; + if (/(sourcechainid|chainid|domainseparator|domain)/.test(value)) return "chain-domain"; + if (/(messageid|processedmessages|consumedmessages)/.test(value)) return "message-id"; + if (/(upgradeauthority|upgrader|proxyadmin)/.test(value)) return "upgrade-authority"; + return "unknown"; +} + +function classifyFunction(name: string): GovernanceFunctionRole { + const value = normalize(name); + if (/^(propose|createproposal|submitproposal)$/.test(value)) return "propose"; + if (/^(castvote|castvotewithreason|vote|castvotebysig)$/.test(value)) return "cast-vote"; + if (/^(getvotes|getpastvotes|getpriorvotes|votingpower|getvotingpower)$/.test(value)) return "voting-power"; + if (/^(quorum|quorumvotes|getquorum)$/.test(value)) return "quorum"; + if (/^(state|proposalstate|getproposalstate)$/.test(value)) return "proposal-state"; + if (/^(queue|queueproposal)$/.test(value)) return "queue"; + if (/^(schedule|schedulebatch|scheduleoperation)$/.test(value)) return "schedule"; + if (/^(execute|executeproposal|executebatch)$/.test(value)) return "execute"; + if (/^(cancel|cancelproposal|canceloperation)$/.test(value)) return "cancel"; + if (/^(updatedelay|setdelay|setmindelay|changetimelockdelay)$/.test(value)) return "set-delay"; + if (/^(hashproposal|getproposalid)$/.test(value)) return "hash-proposal"; + if (/^(hashoperation|hashoperationbatch)$/.test(value)) return "hash-operation"; + if (/^grantrole$/.test(value)) return "grant-role"; + if (/^revokerole$/.test(value)) return "revoke-role"; + if (/^(emergencyexecute|guardianexecute|fastexecute|emergencyupgrade)$/.test(value)) return "emergency-execute"; + if (/^(upgradeto|upgradetoandcall|authorizeupgrade|upgrade)$/.test(value)) return "upgrade"; + if (/^(receivemessage|handlemessage|executemessage|processmessage|relaymessage)$/.test(value)) return "cross-chain-receive"; + if (/^(checksignatures|validatesignatures|verifysignatures)$/.test(value)) return "validate-signatures"; + if (/^(exectransaction|executetransaction|multisigexecute)$/.test(value)) return "multisig-execute"; + if (/^(delegate|delegatevotes|delegatebysig)$/.test(value)) return "delegate-votes"; + return "unknown"; +} + +function isRelevant(model: GovernanceContractModel): boolean { + const roles = new Set(model.transitions.map((transition) => transition.role)); + const variableRoles = model.stateVariables.filter((variable) => variable.role !== "unknown"); + return roles.has("propose") || roles.has("cast-vote") || roles.has("schedule") || + roles.has("multisig-execute") || roles.has("cross-chain-receive") || + (roles.has("execute") && variableRoles.length >= 2) || variableRoles.length >= 4; +} + +function inferAssumptions(model: GovernanceContractModel): string[] { + const assumptions: string[] = []; + if (model.transitions.some((transition) => transition.role === "cast-vote")) { + assumptions.push("Recorded voting weight is intended to remain stable for a proposal snapshot"); + } + if (model.transitions.some((transition) => ["execute", "schedule"].includes(transition.role))) { + assumptions.push("Queued operations can invoke privileged external state transitions"); + } + if (model.transitions.some((transition) => transition.role === "cross-chain-receive")) { + assumptions.push("The cross-chain transport may redeliver a previously accepted message"); + } + 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?: GovernanceCancellationSignal): 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): GovernanceSourceLocation { + 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: GovernanceOperation, right: GovernanceOperation): 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: "GOV_SOURCE_LIMIT" | "GOV_CONTRACT_LIMIT" | "GOV_FUNCTION_LIMIT", + message: string, + file: string, +): BuildGovernanceModelsResult { + return { models: [], diagnostics: [{ code, severity: "warning", message, location: startLocation(file) }] }; +} + +function parseFailure(file: string, message: string): BuildGovernanceModelsResult { + return { + models: [], + diagnostics: [{ code: "GOV_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): GovernanceSourceLocation { + return { file, line: 1, column: 1 }; +} + +function checkCancelled(signal?: GovernanceCancellationSignal): void { + if (signal?.aborted) throw new GovernanceAnalysisCancelledError(); +} + +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/governance/rule.ts b/packages/core/src/governance/rule.ts new file mode 100644 index 0000000..59bd63c --- /dev/null +++ b/packages/core/src/governance/rule.ts @@ -0,0 +1,37 @@ +import type { ASTNode, Finding } from "../types"; +import { analyzeGovernanceSource } from "./api"; + +const GOVERNANCE_PREFILTER = + /\b(?:Governor|TimelockController|proposalThreshold|proposalSnapshot|votingDelay|votingPeriod|castVote|getPastVotes|getPriorVotes|quorum|scheduleBatch|hashOperation|predecessor|guardian|emergencyCouncil|execTransaction|checkSignatures|processedMessages|receiveMessage)\b/; + +/** Integrates the specialized governance engine into the ordinary ChainProof scan. */ +export function detectGovernanceSafety( + _ast: ASTNode, + source: string, + filePath: string, +): Finding[] { + // Most contracts are unrelated. Avoid a second parse/model pass unless strong governance signals exist. + if (!GOVERNANCE_PREFILTER.test(stripCommentsAndStrings(source))) return []; + const report = analyzeGovernanceSource(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/governance/serialize.ts b/packages/core/src/governance/serialize.ts new file mode 100644 index 0000000..5cd122f --- /dev/null +++ b/packages/core/src/governance/serialize.ts @@ -0,0 +1,78 @@ +import type { GovernanceAnalysisReport, GovernanceFinding } from "./types"; + +/** Deterministic, recursively key-sorted JSON suitable for versioned CI artifacts. */ +export function serializeGovernanceReport(report: GovernanceAnalysisReport): string { + return JSON.stringify(sortValue(report), null, 2) + "\n"; +} + +/** Human-readable governance report with evidence and explicit scope limitations. */ +export function generateGovernanceMarkdown(report: GovernanceAnalysisReport): string { + const lines = [ + "# Governance Safety Analysis", + "", + `Schema: \`${report.schemaVersion}\` `, + `Engine: \`${report.engineVersion}\``, + "", + "## Summary", + "", + `- Solidity files analyzed: ${report.summary.files}`, + `- Governance 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 governance findings.", ""); + for (const finding of file.findings) appendFinding(lines, finding); + } + lines.push( + "## Scope", + "", + "This report evaluates structural implementation safety: state transitions, authorization, replay protection, ordering, and data flow. It does not rate political legitimacy, voter preferences, or proposal outcomes.", + "", + ); + return lines.join("\n"); +} + +function appendFinding(lines: string[], finding: GovernanceFinding): 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/governance/types.ts b/packages/core/src/governance/types.ts new file mode 100644 index 0000000..8d23080 --- /dev/null +++ b/packages/core/src/governance/types.ts @@ -0,0 +1,293 @@ +import type { Severity } from "../types"; + +export const GOVERNANCE_REPORT_SCHEMA_VERSION = "1.0.0" as const; +export const GOVERNANCE_CONFIG_SCHEMA_VERSION = 1 as const; + +export type GovernanceRuleId = + | "CP-GOV-001" + | "CP-GOV-002" + | "CP-GOV-003" + | "CP-GOV-004" + | "CP-GOV-005" + | "CP-GOV-006" + | "CP-GOV-007" + | "CP-GOV-008" + | "CP-GOV-009" + | "CP-GOV-010" + | "CP-GOV-011" + | "CP-GOV-012" + | "CP-GOV-013" + | "CP-GOV-014" + | "CP-GOV-015" + | "CP-GOV-016"; + +export type GovernanceVariableRole = + | "governance-token" + | "proposal-count" + | "proposal-state" + | "proposal-threshold" + | "quorum" + | "quorum-numerator" + | "quorum-denominator" + | "voting-delay" + | "voting-period" + | "vote-snapshot" + | "vote-receipt" + | "vote-weight" + | "proposal-eta" + | "minimum-delay" + | "operation-hash" + | "executed-state" + | "canceled-state" + | "nonce" + | "salt" + | "predecessor" + | "proposer-role" + | "executor-role" + | "admin-role" + | "guardian" + | "signer-set" + | "signature-threshold" + | "chain-domain" + | "message-id" + | "upgrade-authority" + | "unknown"; + +export type GovernanceFunctionRole = + | "propose" + | "cast-vote" + | "voting-power" + | "quorum" + | "proposal-state" + | "queue" + | "schedule" + | "execute" + | "cancel" + | "set-delay" + | "hash-proposal" + | "hash-operation" + | "grant-role" + | "revoke-role" + | "emergency-execute" + | "upgrade" + | "cross-chain-receive" + | "validate-signatures" + | "multisig-execute" + | "delegate-votes" + | "unknown"; + +export type GovernanceFrameworkAdapter = + | "openzeppelin-governor" + | "openzeppelin-timelock-controller" + | "compound-governor-bravo" + | "safe-multisig" + | "cross-chain-governor" + | "checkpointed-governance" + | "generic-governance" + | "generic-timelock" + | "none"; + +export interface GovernanceFrameworkAdapterDefinition { + id: Exclude< + GovernanceFrameworkAdapter, + "checkpointed-governance" | "generic-governance" | "generic-timelock" | "none" + >; + displayName: string; + requiredStateGroups: string[][]; + requiredFunctions: string[]; + mitigations: string[]; + limitations: string[]; +} + +export interface GovernanceFrameworkMatch { + adapter: GovernanceFrameworkAdapter; + matchedState: string[]; + matchedFunctions: string[]; +} + +export interface GovernanceSourceLocation { + file: string; + line: number; + column: number; + lineEnd?: number; + columnEnd?: number; +} + +export interface GovernanceEvidence { + kind: + | "state-read" + | "state-write" + | "arithmetic" + | "branch" + | "call" + | "modifier" + | "ordering" + | "taint-flow" + | "adapter" + | "absence"; + description: string; + location: GovernanceSourceLocation; + snippet?: string; +} + +export interface GovernanceStateVariable { + name: string; + typeName: string; + role: GovernanceVariableRole; + isMapping: boolean; + location: GovernanceSourceLocation; +} + +export interface GovernanceOperation { + order: number; + kind: "read" | "write" | "call" | "arithmetic" | "guard"; + name: string; + expression: string; + parameterSources: string[]; + location: GovernanceSourceLocation; +} + +export interface GovernanceTransition { + name: string; + role: GovernanceFunctionRole; + visibility: string; + modifiers: string[]; + parameters: string[]; + reads: string[]; + writes: string[]; + calls: string[]; + operations: GovernanceOperation[]; + location: GovernanceSourceLocation; + source: string; +} + +export interface GovernanceContractModel { + name: string; + file: string; + adapter: GovernanceFrameworkAdapter; + stateVariables: GovernanceStateVariable[]; + transitions: GovernanceTransition[]; + privilegedCalls: GovernanceOperation[]; + proposalControlledCalls: GovernanceOperation[]; + assumptions: string[]; + location: GovernanceSourceLocation; +} + +export interface GovernanceFinding { + ruleId: GovernanceRuleId; + title: string; + description: string; + recommendation: string; + severity: Exclude; + confidence: "high" | "medium" | "low"; + category: + | "voting-power" + | "proposal-lifecycle" + | "quorum-threshold" + | "timelock" + | "replay" + | "authorization" + | "execution" + | "upgrade" + | "cross-chain" + | "multisig"; + contract: string; + location: GovernanceSourceLocation; + evidence: GovernanceEvidence[]; + assumptions: string[]; +} + +export interface GovernanceDiagnostic { + code: + | "GOV_PARSE_ERROR" + | "GOV_SOURCE_LIMIT" + | "GOV_CONTRACT_LIMIT" + | "GOV_FUNCTION_LIMIT" + | "GOV_OPERATION_LIMIT" + | "GOV_FINDING_LIMIT" + | "GOV_CANCELLED" + | "GOV_CONFIG_INVALID" + | "GOV_FILE_UNREADABLE"; + severity: "error" | "warning" | "info"; + message: string; + location?: GovernanceSourceLocation; +} + +export interface GovernanceAnalysisLimits { + maxSourceBytes: number; + maxFiles: number; + maxContracts: number; + maxFunctionsPerFile: number; + maxFunctionsPerContract: number; + maxOperationsPerFunction: number; + maxFindings: number; + maxEvidencePerFinding: number; +} + +export interface GovernanceCancellationSignal { + readonly aborted: boolean; + readonly reason?: unknown; +} + +export interface GovernanceAnalysisOptions { + limits?: Partial; + signal?: GovernanceCancellationSignal; + includeModels?: boolean; + includeRules?: GovernanceRuleId[]; + excludeRules?: GovernanceRuleId[]; +} + +export interface GovernanceAnalysisConfigV1 { + schemaVersion: 1; + limits?: Partial; + includeModels?: boolean; + includeRules?: GovernanceRuleId[]; + excludeRules?: GovernanceRuleId[]; +} + +export interface GovernanceAnalysisConfigV0 { + version?: 0; + maxFileSize?: number; + maxIssues?: number; + detectors?: GovernanceRuleId[]; + includeModels?: boolean; +} + +export type GovernanceAnalysisConfigInput = + | GovernanceAnalysisConfigV1 + | GovernanceAnalysisConfigV0 + | Record; + +export interface GovernanceFileAnalysis { + file: string; + findings: GovernanceFinding[]; + diagnostics: GovernanceDiagnostic[]; + models?: GovernanceContractModel[]; +} + +export interface GovernanceAnalysisReport { + schemaVersion: typeof GOVERNANCE_REPORT_SCHEMA_VERSION; + engineVersion: string; + files: GovernanceFileAnalysis[]; + summary: { + files: number; + contracts: number; + critical: number; + high: number; + medium: number; + low: number; + info: number; + total: number; + truncated: boolean; + }; +} + +export interface GovernanceSourceInput { + file: string; + source: string; +} + +export interface ValidatedGovernanceConfig { + config: GovernanceAnalysisConfigV1; + diagnostics: GovernanceDiagnostic[]; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e162691..8d6bb39 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -209,6 +209,9 @@ export type { StakingSourceLocation, ValidatedStakingConfig, } from "./staking"; + +// ─── Governance / timelock safety analysis ────────────────────────────────── +export * from "./governance"; export type { ParseSpecResult, MigrationResult, diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index db7cff5..7a162a5 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -29,6 +29,7 @@ import { import { detectVaultInflation } from "./rules/cp122-vault-inflation"; import { detectCallbackReentrancy } from "./rules/callback-analysis"; import { detectStakingAccounting } from "./staking"; +import { detectGovernanceSafety } from "./governance"; import { RuleOptions } from "./rules/rule-context"; import { detectGasIssues } from "./rules/gas-optimizer"; import { enhanceFindingsWithLLM } from "./llm/enhancer"; @@ -179,6 +180,10 @@ async function scanFile( // inheritance view would duplicate evidence and findings. findings.push(...detectStakingAccounting(ast, source, filePath)); + // The governance engine models all contracts in a physical file together. Run it once + // here rather than once per merged inheritance view, which would duplicate findings. + findings.push(...detectGovernanceSafety(ast, source, filePath)); + if (config.plugins) { for (const plugin of config.plugins) { for (const rule of plugin.rules) {