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)。
标签:MCP, Neo4j, Slither, Solidity, 云安全监控, 代码属性图, 智能合约审计, 请求拦截, 逆向工具, 静态分析