broccolirob/solidity-audit-graph
GitHub: broccolirob/solidity-audit-graph
基于 Neo4j 代码属性图和只读 MCP server 的 Solidity 审计辅助工具,让 AI 代理通过图查询回答跨合约可达性和状态依赖问题。
Stars: 0 | Forks: 0
# solidity-audit-graph
一个基于 Neo4j 的 Solidity 代码库代码属性图(code-property graph),通过只读的 MCP server 暴露给编码代理,使得审计智能合约的代理可以通过图查询而不是 grep 来回答跨合约和状态依赖的问题。
该流水线每次摄入一个目标代码库:Slither 编译代码库,一个遍历器将 Slither 的 IR 转换为节点和边,最后由 MCP server 将图作为 15 个精选工具提供给 Claude Code。它是一个单用户个人审计工具,而不是托管服务。
## 架构
```
flowchart LR
A[Solidity repo] -->|Slither 0.11.5| B[SlithIR walk +
IR-failure capture] B -->|batched MERGE| C[(Neo4j 5.26
code-property graph)] C --> D[MCP server
15 read-only tools] D -->|stdio| E[Coding agent
Claude Code] E --> F[Human review
against source] D -.->|JSONL session logs| G[scripts/analyze_sessions.py] ``` 该图建模了六种节点标签 —— `Repo`、`Contract`、`Function`(函数和修饰符)、`StateVariable`、`Event`、`UnresolvedCall` —— 通过 11 种关系类型连接:`INHERITS_FROM`、`DEFINES`、`USES_MODIFIER`、`CALLS_INTERNAL`、`CALLS_LIBRARY`、`CALLS_EXTERNAL`(带有 `kind` 属性:`call`、`staticcall`、`delegatecall`、`transfer`、`send`、`low_level`),`CALLS_UNRESOLVED`、`READS`、`WRITES`、`EMITS` 和 `OVERRIDES`。每个节点都带有一个稳定的 `canonical_id`;Slither 未能生成其 IR 的函数会被标记为 `ir_extraction_status: failed`,以便在遍历报告不足时查询可以发出警告。摄入是幂等的(在 `canonical_id` 上执行 `MERGE`),并且 `Repo` 节点会记录来源:git commit、dirty flag 以及工具链版本。 MCP 工具分为三组(注册在 [`src/solidity_audit_graph/mcp_server/`](src/solidity_audit_graph/mcp_server/)): - **11 个目录工具** 包装了 [`src/solidity_audit_graph/queries/`](src/solidity_audit_graph/queries/) 中的 `.cypher` 文件:合约概览、正向/反向可达性、状态读取器/写入器、重入候选、delegatecall 和外部接口、IR 失败函数、未解析的调用以及状态依赖交集。 - **3 个原语** 组成常见的审计形态:`transitive_state_writers`、`reach_with_chain`、`function_callers_partitioned`。 - **1 个逃生舱**,`cypher_query`,用于任意只读 Cypher。 每个工具返回相同的结构:`{rows, warnings, truncated, row_count}`。 ## 它旨在回答的问题 在对 Panoptic(2025 年 12 月的一场 Code4rena 比赛)的审计期间,调查的不变式是 `totalAssets() == s_depositedAssets + s_assetsInAMM + unrealizedGlobalInterest()`, 其中未实现的利息是根据一个状态变量 `s_marketState` 计算得出的。核心问题 —— *是否有任何外部入口可以在不先累计利息的情况下写入 `s_depositedAssets`?* —— 是一个 grep 无法回答的可达性问题,因为“累计”发生在 1 到 4 个调用跳数之外,有时还会通过另一个文件中的库进行。 两次图查询解决了这个问题:一个 `WRITES` 匹配枚举了 `s_depositedAssets` 的直接写入器 —— 其中七个是外部边界入口点 —— 而一个可变长度的 `CALLS_INTERNAL|CALLS_LIBRARY|CALLS_EXTERNAL` 遍历显示这七个中有六个传递地触达了 `_accrueInterest`,并证明了 `settleLiquidation` 并没有。随后阅读源码证实了这种不对称性所指向的漏洞:一个没有未平仓头寸的清算跳过了刷新 `s_marketState` 的销毁路径,因此支付是针对陈旧的利息计算的。该发现与 Code4rena M-14 相符。包括所用 Cypher 在内的完整调查记录在 [docs/audits/collateral-tracker-totalAssets.md](docs/audits/collateral-tracker-totalAssets.md) 中。 该图证明了可达性和不可达性;调用*顺序*仍然需要阅读源码 —— 请参阅下文的限制。 ## 快速开始 前置条件:Python ≥ 3.12、[uv](https://docs.astral.sh/uv/)、Docker,以及一个你的目标可以编译的 `solc`(`uv run solc-select install`)。
```
git clone && cd solidity-audit-graph
uv sync --extra mcp # the MCP server needs the [mcp] extra
docker compose up -d neo4j # Neo4j 5.26 Enterprise (eval license)
```
配置服务器连接所用的只读角色([指南 §2](docs/mcp_server_setup.md)):
```
docker exec -it audit-graph-neo4j cypher-shell -u neo4j -p auditpass123
# CREATE USER sag_reader SET PASSWORD 'changeme' CHANGE NOT REQUIRED;
# GRANT ROLE reader TO sag_reader;
```
compose 文件附带了仅用于开发的凭据(`neo4j/auditpass123`、`sag_reader/changeme`);在除本地沙盒之外的任何环境中使用时,请更改这两者。
摄入目标代码库(摄入过程以管理员用户身份连接,因为它需要写入):
```
cp .env.example .env # set NEO4J_URI/USER/PASSWORD and TARGET_REPO
uv run ingest --install-schema --wipe
```
在不使用 Neo4j 的情况下对服务器连线进行冒烟测试(预期输出:15 个工具名称):
```
uv run python -c "
from solidity_audit_graph.mcp_server.config import Config
from solidity_audit_graph.mcp_server.server import build_server
mcp = build_server(Config.from_env({'SAG_SESSION_LOG_DIR': '/tmp/sag-smoke'}))
print(sorted(mcp._tool_manager._tools.keys()))
"
```
向 Claude Code 注册 —— 要么在此代码库中打开 Claude Code(已提交的 [`.mcp.json`](.mcp.json) 会运行 `uv run sag-mcp-server`),要么按用户注册:
```
claude mcp add-json solidity-audit-graph '{
"command": "sag-mcp-server",
"args": [],
"env": {
"SAG_NEO4J_URI": "bolt://localhost:7687",
"SAG_NEO4J_USER": "sag_reader",
"SAG_NEO4J_PASSWORD": "changeme"
}
}'
```
`/mcp` 应该列出 `solidity-audit-graph`,显示它已连接并提供 15 个工具。
## 示例工具调用
摘自[操作指南 §9](docs/mcp_server_setup.md)中记录的验收演练,针对 Panoptic 摄入运行:
```
transitive_state_writers(state_var_name="balanceOf")
```
返回了 21 行按 `write_kind` 分区的行:12 个 `direct` 写入器(例如 `ERC1155::_mint`、`ERC1155::_burn`、`CollateralTracker::delegate`),每个都带有 `chain = []`,以及 9 个 `transitive` 写入器,每个都有其调用链,例如:
```
["PanopticPool::_forceExercise(...)", "CollateralTracker::delegate(address)"]
```
当结果触及 IR 提取失败的函数时,返回的信封会带有警告(原文引自同一次演练):
```
{
"type": "ir_extraction_incomplete_in_result",
"functions_failed": [
{"canonical_id": "contracts/SemiFungiblePositionManager.sol::SemiFungiblePositionManager::initializeAMMPool(address,address,uint24,uint8)"}
],
"message": "Edges from these functions may be missing or incomplete..."
}
```
## 安全模型
- **数据库强制只读。** 服务器默认使用 `sag_reader` 用户(Neo4j `reader` 角色)。`cypher_query` 逃生舱不进行任何客户端解析或过滤 —— 尝试 `CREATE` 会在驱动程序处失败并从数据库收到 `Forbidden` 错误。如果未配置 reader 用户,则身份验证将自动关闭。角色强制实施需要 Neo4j Enterprise;在 Community Edition 中,逃生舱写入将会成功(在接受该风险之前,请参阅[操作指南 §8](docs/mcp_server_setup.md))。
- **有界结果。** 行在 `SAG_MAX_RESULT_ROWS`(默认为 200)处被截断;截断的信封会设置 `truncated: true`,并以 `row_count` 作为下限。
- **查询超时。** 每个查询都使用 `SAG_QUERY_TIMEOUT_S`(默认为 30 秒)的服务器端超时运行。
- **谓词验证。** `reach_with_chain` 将调用者提供的谓词插入到 Cypher 中,因此它会在数据库看到它们之前,按词法拒绝写入和控制关键字(`CREATE`、`MERGE`、`DELETE`、`DETACH`、`SET`、`REMOVE`、`DROP`、`FOREACH`、`CALL`)以及链式/注释 token。
## 可观测性
每次工具调用都会向 `/-.jsonl`(默认为 `~/.solidity-audit-graph/sessions/`)追加一条 JSONL 记录,并立即进行 flush 和 fsync。字段包括:`tool_name`、`params`、`cypher_string`(生成的 Cypher,为 `cypher_query` 和 `reach_with_chain` 填充)、`result_row_count`、`latency_ms`、`error`(`{type, message}` 或 null)、`timestamp`、`session_id`;失败的调用也会被记录并设置 `error`。`uv run python scripts/analyze_sessions.py` 会将日志聚合为一份按审计划分的报告:工具使用频率、错误和延迟分布、逃生舱 Cypher 语料库以及回退到源码的注释。
## 已知限制
- 仅支持 Solidity,通过 Slither(固定为 0.11.5 版本);该分析是静态的。
- 该图处于关系级别,而不是执行级别:它对*谁*进行了调用、读取和写入进行建模,但不涉及语句顺序、条件守卫、循环语义或调用点处的参数值。“Reaches”的意思是“可能调用”,而不是“在 X 之前调用” —— 顺序问题仍然需要阅读源码。
- 如果 Slither 未能为某个函数生成 IR,则该函数的出边将会缺失;图会标记这些情况,并且涉及受影响函数的结果会带有 `ir_extraction_incomplete_in_result` 警告,但盲点依然存在。
- 隐藏在内联汇编中的外部调用对 Slither 的 IR 不可见;遍历器仅为常见的 Solmate/Solady `SafeTransferLib` 形态合成边。其他汇编包装的转账库不予支持。
- 通过存储指针参数进行的写入会归属于传递指针的调用者,而不是执行写入的函数。
- `state_writers`/`state_readers` 根据声明合约匹配直接边,并不会遍历继承关系;`contract_overview` 会针对状态变量遍历继承关系,但不会针对事件、修饰符或函数。
- `reentrancy_candidates` 是一种结构性启发式方法(在 2 跳内写入 + 可达的外部调用) —— 命中只是一个候选,而不是一个漏洞。
- 不会摄入测试、NatSpec 和注释。一次只能摄入一个代码库;重新摄入需要手动使用 `--wipe`。
## 状态与测试
- **单元测试** 涵盖了提取(规范 ID、IR 错误捕获、工具链探测)和 MCP server(配置、会话日志、结果信封、截断、IR 警告、谓词验证、15 个工具的注册)。
- **集成测试** 将捆绑的 fixture [`tests/fixtures/sample/Sample.sol`](tests/fixtures/sample/Sample.sol) 摄入到一个隔离的 Neo4j testcontainer 中,并针对它运行所有 11 个目录查询以及原语。CI 会在每个 PR 以及推送到 master 分支时运行 lint 和完整的测试套件([`.github/workflows/ci.yml`](.github/workflows/ci.yml))。
- **手动检查** 记录在操作指南中:无数据库冒烟测试(§4)、实时连接检查(§5),以及带有记录输出的五点验收演练(§9),涵盖预期写入器、分区语义、数据库端写入拒绝、失败写入的会话日志捕获以及 IR 警告增强。写入拒绝在演练中是手动验证的 —— 因为 testcontainer 用户具有写入权限,所以故意跳过了自动化变体。
- **真实世界验证** 包括上述的 totalAssets 调查,以及在相同代码库上进行的 8 次带/不带 MCP 的对比测试([docs/audit-improvements.md](docs/audit-improvements.md))。目前还没有评估基准。
## 文档
- [操作指南](docs/mcp_server_setup.md) —— 安装、角色配置、环境参考、冒烟测试、注册、日志配方、验收演练。
- [totalAssets 调查](docs/audits/collateral-tracker-totalAssets.md) —— 验证该方法的实际审计工作,包含所使用的 Cypher。
- [审计改进](docs/audit-improvements.md) —— 8 次带/不带 MCP 的验证测试及其产生的修复。
- [Effect/order 探测](docs/effect-order-probe.md) —— 针对执行顺序层的可行性研究(仅供参考;不属于图 schema 的一部分)。
- [路线图](docs/roadmap.md) 和 [目录缺口](docs/catalog-gaps.md)。
IR-failure capture] B -->|batched MERGE| C[(Neo4j 5.26
code-property graph)] C --> D[MCP server
15 read-only tools] D -->|stdio| E[Coding agent
Claude Code] E --> F[Human review
against source] D -.->|JSONL session logs| G[scripts/analyze_sessions.py] ``` 该图建模了六种节点标签 —— `Repo`、`Contract`、`Function`(函数和修饰符)、`StateVariable`、`Event`、`UnresolvedCall` —— 通过 11 种关系类型连接:`INHERITS_FROM`、`DEFINES`、`USES_MODIFIER`、`CALLS_INTERNAL`、`CALLS_LIBRARY`、`CALLS_EXTERNAL`(带有 `kind` 属性:`call`、`staticcall`、`delegatecall`、`transfer`、`send`、`low_level`),`CALLS_UNRESOLVED`、`READS`、`WRITES`、`EMITS` 和 `OVERRIDES`。每个节点都带有一个稳定的 `canonical_id`;Slither 未能生成其 IR 的函数会被标记为 `ir_extraction_status: failed`,以便在遍历报告不足时查询可以发出警告。摄入是幂等的(在 `canonical_id` 上执行 `MERGE`),并且 `Repo` 节点会记录来源:git commit、dirty flag 以及工具链版本。 MCP 工具分为三组(注册在 [`src/solidity_audit_graph/mcp_server/`](src/solidity_audit_graph/mcp_server/)): - **11 个目录工具** 包装了 [`src/solidity_audit_graph/queries/`](src/solidity_audit_graph/queries/) 中的 `.cypher` 文件:合约概览、正向/反向可达性、状态读取器/写入器、重入候选、delegatecall 和外部接口、IR 失败函数、未解析的调用以及状态依赖交集。 - **3 个原语** 组成常见的审计形态:`transitive_state_writers`、`reach_with_chain`、`function_callers_partitioned`。 - **1 个逃生舱**,`cypher_query`,用于任意只读 Cypher。 每个工具返回相同的结构:`{rows, warnings, truncated, row_count}`。 ## 它旨在回答的问题 在对 Panoptic(2025 年 12 月的一场 Code4rena 比赛)的审计期间,调查的不变式是 `totalAssets() == s_depositedAssets + s_assetsInAMM + unrealizedGlobalInterest()`, 其中未实现的利息是根据一个状态变量 `s_marketState` 计算得出的。核心问题 —— *是否有任何外部入口可以在不先累计利息的情况下写入 `s_depositedAssets`?* —— 是一个 grep 无法回答的可达性问题,因为“累计”发生在 1 到 4 个调用跳数之外,有时还会通过另一个文件中的库进行。 两次图查询解决了这个问题:一个 `WRITES` 匹配枚举了 `s_depositedAssets` 的直接写入器 —— 其中七个是外部边界入口点 —— 而一个可变长度的 `CALLS_INTERNAL|CALLS_LIBRARY|CALLS_EXTERNAL` 遍历显示这七个中有六个传递地触达了 `_accrueInterest`,并证明了 `settleLiquidation` 并没有。随后阅读源码证实了这种不对称性所指向的漏洞:一个没有未平仓头寸的清算跳过了刷新 `s_marketState` 的销毁路径,因此支付是针对陈旧的利息计算的。该发现与 Code4rena M-14 相符。包括所用 Cypher 在内的完整调查记录在 [docs/audits/collateral-tracker-totalAssets.md](docs/audits/collateral-tracker-totalAssets.md) 中。 该图证明了可达性和不可达性;调用*顺序*仍然需要阅读源码 —— 请参阅下文的限制。 ## 快速开始 前置条件:Python ≥ 3.12、[uv](https://docs.astral.sh/uv/)、Docker,以及一个你的目标可以编译的 `solc`(`uv run solc-select install
标签:MCP, Neo4j, Slither, Solidity, 云安全监控, 代码属性图, 智能合约审计, 请求拦截, 逆向工具, 静态分析