# W13.1 Anthropic Plugin Audit **Auditor**: 杨帆 (architect-yang) **Date**: 2026-05-27 **File**: plugins/anthropic/src/anthropic_plugin.cpp (497 行) **Wave Coverage**: W2.2 (仅 secure_zero 追加,全文从未审计) --- ## 文件总览 | 函数 | 行号 | 类型 | 用途 | |------|------|------|------| | `secure_zero` | 34-37 | 静态辅助 | volatile 写零擦除敏感数据 | | `extract_host_port` | 42-62 | 静态辅助 | URL 解析 host/port/target | | `build_headers_json` | 67-73 | 静态辅助 | 构建 Anthropic headers JSON (含 api_key) | | `build_request_json` | 78-124 | 静态辅助 | 构建 Messages API 请求体 | | `parse_response` | 129-195 | 静态辅助 | 解析非流式 JSON 响应 | | `parse_sse_data` | 203-238 | 静态辅助 | 解析 SSE data 行 (Anthropic 格式) | | **`my_configure`** | **243-261** | **vtable** | AI 服务 configure | | **`my_chat`** | **266-306** | **vtable** | 非流式聊天 | | **`my_chat_stream`** | **348-428** | **vtable** | 流式聊天 (SSE) | | `sse_line_callback` | 321-346 | 回调 (C fn ptr) | SSE 行回调,传给 HTTP 服务 | | **`my_free_result`** | **433-439** | **vtable** | 释放 chat_result 字段 | | **`on_init`** | **454-468** | **生命周期** | 插件初始化 | | **`on_shutdown`** | **470-478** | **生命周期** | 插件销毁 | | `dstalk_plugin_init` | 494-497 | **导出入口** | 返回插件描述符 | **vtable 绑定**: `g_service` (L444-449) 绑定 configure/chat/chat_stream/free_result。 **全局状态**: `g_host` (L14), `g_http` (L15), `g_config` (L16), `g_cfg` (L29)。 --- ## 1. 审计维度: §5 + §8 异常安全 **规则 (§8.1)**: 所有 C ABI 导出函数(on_init/on_shutdown/on_event + vtable 函数指针)必须 try/catch 包裹。 ### 发现 1.1 — [H] 6 个 C ABI 函数零 try/catch 保护 以下所有通过 vtable 或 `dstalk_plugin_info_t` 暴露给 host 的函数,内部使用 `std::string` / `boost::json` / STL,但**无一包裹 try/catch**: | 函数 | 行号 | 使用的可抛类型 | 风险 | |------|------|---------------|------| | `my_configure` | L243-261 | `std::string::operator=` (L247-250) | `std::bad_alloc` → `std::terminate()` | | `my_chat` | L266-306 | `std::string` x5 (L279-286) + `boost::json` (build 系列) | 同上 | | `my_chat_stream` | L348-428 | `std::string` x4 (L361-368) + `boost::json` | 同上 | | `on_init` | L454-468 | `g_host->log` 可变参数 + query_service | 低概率但可能 | | `on_shutdown` | L470-478 | `std::string::clear()` (noexcept) + `g_host->log` | 极低概率 | | `sse_line_callback` | L321-346 | `std::string line_str(line)` (L326) | `std::bad_alloc` 抛向调用方 (网络插件) | **违反 §8.1 条目 1+2+4**: on_init/on_shutdown + vtable 函数指针 + register_service 注册的函数。 **代价**: 任何 OOM 或 STL 异常 → 穿越 C 函数指针边界 → `std::terminate()` → 进程崩溃。 W11.1 对 context_plugin 发现了完全相同的模式 (trim_impl),本插件问题范围更广(6 个函数 vs 1 个)。 **对比 deepseek_plugin.cpp**: deepseek 同函数**同样零 try/catch**(my_configure L238-256, my_chat L261-301, my_chat_stream L337-417, on_init L443-457, sse_line_callback L317-335),说明此反模式是项目级共性问题。 ### 发现 1.2 — [M] sse_line_callback 无保护但可能被网络插件兜底 L326 `std::string line_str(line)` 可抛 `std::bad_alloc`。回调通过 C 函数指针 `dstalk_stream_cb` 传入网络插件的 `post_stream`。根据 security-logging.md 对 network_plugin 的描述(L280-282 有 `catch (std::exception& e)`),网络插件的 do_post_stream 可能兜底捕获。但依赖调用方的异常保护是脆弱假设——若网络插件变更或不同版本,此防护消失。 --- ## 2. 审计维度: §9 字符串返回值生命周期 **规则 (§9)**: vtable 中返回 `const char*` 的函数必须符合模式 A(拥有权转移,host 堆分配 + 调用方 host->free)或模式 B(静态生命周期)。 ### 发现 2.1 — [L] 临时 std::string + c_str() + strdup 的脆弱模式 L405-406: ```c r.error = g_host->strdup(("HTTP " + std::to_string(status_code)).c_str()); ``` 临时 `std::string` 在完整表达式结束前有效,`strdup` 在其析构前完成复制。**当前安全**,但重构时若分离 `c_str()` 和 `strdup` 调用会导致悬垂指针。Deepseek 同位置 (L393-395) 使用相同模式。 ### 合规确认 | 检查项 | 状态 | 证据 | |--------|------|------| | chat_result 字段全部经 strdup 分配 | PASS | L144, L146, L150, L168, L176, L179, L186, L191, L275, L296, L357, L396-397, L403, L405-406, L418, L424 | | free_result 全部经 host->free 释放 | PASS | L436-438 | | 无锁外 c_str() 返回 | PASS | 插件无 mutex/lock | | 无栈上 buffer 返回 | PASS | 全部 strdup 复制 | | 无 `std::string{...}.c_str()` 裸返回 | PASS | 临时对象均在 strdup 前存活 | --- ## 3. 审计维度: §2 + §3 跨 DLL 堆纪律 **规则 (§3.2)**: 严禁直接调用 malloc/free/strdup/new/delete 处理跨 DLL 边界数据。 ### 发现 3.1 — [H] response_body 泄漏:my_chat 错误路径未释放 L291-297 (`my_chat`): ```c int ret = g_http->post_json(..., &response_body, &status_code); if (ret != 0) { r.error = g_host->strdup("http request failed"); return r; // ← response_body 未释放! } ``` 成功路径 (L302-304) 正确释放了 `response_body`,但 `ret != 0` 错误路径泄漏。 **对比**: `my_chat_stream` (L408, L414) 在**所有路径**正确释放 `response_body`。`my_chat` 应与 `my_chat_stream` 保持一致。 **deepseek_plugin.cpp 同位置** (L286-293) 有相同泄漏——此为项目级模式 bug。 ### 合规确认 | 检查项 | 状态 | 证据 | |--------|------|------| | 无裸 malloc/free | PASS | 全文 0 处 | | 无裸 strdup | PASS | 全文 0 处 | | 无裸 new/delete | PASS | 全文 0 处 | | 跨边界分配均经 host->strdup | PASS | 全部 chat_result 字段 | | 跨边界释放均经 host->free | PASS | L302-304, L408, L414, L436-438 | | 插件内部 std::string 不出边界 | PASS | 仅用于局部逻辑 | --- ## 4. 审计维度: §6 atomic 回调 + 线程安全 **规则 (§6.1)**: 诊断回调用 `std::atomic` + acquire/release。服务注册表/事件总线均有内部锁。 ### 发现 4.1 — [H] 全局指针无同步保护 (g_host / g_http / g_config) | 变量 | 写点 (on_shutdown) | 读点 (服务函数) | 同步机制 | |------|-------------------|-----------------|----------| | `g_host` | L477 `= nullptr` | L255, L274, L296, L357, L436 等 | **无** | | `g_http` | L475 `= nullptr` | L274, L356 | **无** | | `g_config` | L476 `= nullptr` | (未读取,死变量) | **无** | **Race 场景**: 线程 T1 在 `my_chat()` L274 读取 `g_http`(非 null),线程 T2 在 `on_shutdown()` L475 写入 `nullptr`。T1 随后 L291 调用 `g_http->post_json(...)` → **空指针解引用**。 **缓解因素**: host 应保证 shutdown 前无 in-flight 调用,但 ABI 未显式保证。与 W11.1 发现的 context_plugin Race A 问题同根。 ### 发现 4.2 — [L] g_config 死变量 L16 声明 `g_config`,L458 赋值,L476 置 null。全文无任何读取点。不造成泄漏但暗示设计与实现脱节。 ### 发现 4.3 — [PASS] 流式回调路径无需 atomic `StreamContext` (L312-318) 在 `my_chat_stream` 栈上创建 (L370),回调 `sse_line_callback` 在 `post_stream` 同步调用期间使用,无多线程竞争。 --- ## 5. 审计维度: api_key 安全 ### 发现 5.1 — [PASS] on_shutdown 正确 secure_zero L473-474: ```c secure_zero(g_cfg.api_key.data(), g_cfg.api_key.size()); g_cfg.api_key.clear(); ``` W2.2 修复已验证生效:先 volatile 零覆盖,再 clear 释放。`secure_zero` (L34-37) 使用 `volatile char*` 写入循环,是标准可移植的编译器优化防护手段。 ### 发现 5.2 — [PASS] 日志不泄漏 api_key W9.3 已审计确认:`my_configure` L255-258 的日志调用有意排除 `api_key`,仅输出 model/base_url/max_tokens/temperature。`build_headers_json` (L67-73) 构建的 headers JSON 仅通过内存传递至 HTTP 请求,不进入日志管道。 ### 发现 5.3 — [L] 堆内存残留 `build_headers_json` 返回的 `std::string` 含 `x-api-key: sk-...`,在 `my_chat`/`my_chat_stream` 栈上存活至函数返回。RAII 析构释放内存但不擦除。攻击者读取进程堆可发现 api_key 残留。这是所有内存中处理凭证的固有限制,非本插件特有,secure_zero 仅覆盖 `g_cfg.api_key` 主副本。 --- ## 6. 审计维度: 错误处理路径 ### 发现 6.1 — [L] my_chat 对 post_json 错误仅返回通用消息 L291-297: `ret != 0` 时仅返回 "http request failed",未利用 `status_code` 区分超时/SSL/DNS 错误。`my_chat_stream` (L402-406) 有更细粒度的错误消息(区分 `status_code <= 0` 为 "transport error")。 ### 发现 6.2 — [PASS] 空响应/空 body 不崩溃 - `parse_response` L136/L158: `body ? body : "{}"` 保护 null 输入 - L177-179: content 数组为空 → "empty response" - `my_chat_stream` L416-418: `accumulated.empty() && !saw_data_line` → "no content received" - `parse_sse_data` L234: catch(...) 静默忽略 JSON 解析失败 ### 发现 6.3 — [PASS] HTTP 错误码正确传播 `parse_response` L134-154: 非 2xx 状态码提取 error.message 或退化为 "HTTP NNN"。 `my_chat_stream` L388-411: 同模式,附加 `status_code <= 0` 的传输错误处理。 --- ## 7. 审计维度: 资源生命周期 ### 发现 7.1 — [PASS] 流式中断资源正确释放 - `StreamContext` (L312-318) 栈分配,RAII 保证析构 - `response_body` 在 `my_chat_stream` L408 + L414 双路径释放 - `user_cb` 返回 0 时(L340)`sse_line_callback` 返回 0,`post_stream` 应据此停止——无孤儿连接 - 流结束后 `ctx.accumulated` 正确取出赋值 L424 ### 发现 7.2 — [L] my_chat_stream 忽略 post_stream 返回值 L379-383: `int ret = g_http->post_stream(...)` 的结果未被检查。`my_chat` 检查了 `ret`(L295),但 `my_chat_stream` 仅在 status_code 上做判断。若 `ret != 0` 但 `status_code == 0`(连接完全失败),会落入 L402 `status_code <= 0` → "transport error" 分支——实际可工作,但不一致。 --- ## 8. 补充发现 ### 8.1 — [L] Anthropic 特有: tool_use 块被静默忽略 `parse_response` L163-173 仅提取 `type == "text"` 的 content block。Anthropic API 在 tool-use 场景下返回 `type == "tool_use"` 的块,其内容不会被提取到 `r.content`,`r.tool_calls_json` 始终被设为 `nullptr` (L153, L171, L182, L187, L193)。调用方期待 tool_calls 时将收到空 content + 空 tool_calls——静默数据丢失。不过 `dstalk_ai_service_t.chat` 签名设有 `tools_json` 入参但未在 Anthropic 请求体中设置(对比 deepseek L129 有 `root["tools"] = json::parse(tools_json)`),因此当前工具调用完全不可用。 ### 8.2 — [L] system 消息合并到顶层字段 L92-97: Anthropic API 要求 system 为顶层字段(非 messages 数组元素),插件正确实现了此分离。但多段 system 消息用 `"\n\n"` 拼接 (L95),跨消息语义边界可能模糊。 --- ## TOP 3 严重问题 ### 发现 1 — [H] 6 个 C ABI 函数零 try/catch (§8 违反正面清单) **行号**: L243 (my_configure), L266 (my_chat), L348 (my_chat_stream), L321 (sse_line_callback), L454 (on_init), L470 (on_shutdown) **问题**: 全部使用 std::string/boost::json 但无异常保护。任何 std::bad_alloc → std::terminate()。 **修复**: 每个函数外层加 try { ... } catch (const std::exception& e) { g_host->log(ERROR, ...); return 错误码; } catch (...) { return 错误码; } ### 发现 2 — [H] response_body 泄漏 + 全局指针无同步 **行号**: L295-297 (泄漏), L14-L16 vs L475-L477 (竞态) **问题**: (a) my_chat 在 ret!=0 时未释放 response_body(my_chat_stream 正确);(b) g_host/g_http/g_config 无原子/互斥保护,on_shutdown 与 服务函数并发可空指针解引用。 **修复**: (a) 在 L296 前加 `if (response_body) g_host->free(response_body);`;(b) g_host/g_http 用 `std::atomic` 或文档化单线程约定。 ### 发现 3 — [M] Anthropic tool_use 响应静默丢弃 **行号**: L129-195 (parse_response), L78-124 (build_request_json) **问题**: (a) build_request_json 未将 tools_json 参数写入请求体(对比 deepseek L128-130);(b) parse_response 仅提取 text block,tool_use 块被忽略,tool_calls_json 始终 nullptr。用户调用工具时得到空响应无错误提示。 --- ## 整体评级: C | 维度 | 评级 | 理由 | |------|------|------| | §8 异常安全 | **F** | 6 个函数零 try/catch——mandatory 规则完全未满足 | | §2/§3 跨 DLL 堆 | **A** | 无裸 malloc/free/strdup,全部经 host API | | §9 字符串生命周期 | **B+** | 全部 strdup 合规,一处临时+strdup 脆弱模式 | | §6 线程安全 | **C** | 全局指针无同步,与 W11.1 同根问题 | | api_key 安全 | **A** | secure_zero 生效,日志无泄漏 | | 错误处理 | **C+** | 空响应不崩溃,但 response_body 泄漏+返回值忽略 | | 资源生命周期 | **B+** | 流式路径 RAII 干净,仅 my_chat 泄漏点 | | **综合** | **C** | 堆纪律意外干净,但异常安全零覆盖 + 全局指针竞态 + response_body 泄漏 将评级拉至 C | **总评**: anthropic_plugin 在跨 DLL 堆纪律方面表现好(与 context_plugin 一致),说明编码者遵循了正确的内存管理模式。但 **§8 异常安全是强制性要求,当前零覆盖在最坏情况下直接导致进程崩溃**。response_body 泄漏和全局指针竞态进一步降低可靠性。tool_use 静默丢弃虽非安全/稳定性问题,但构成功能缺陷。评级 C,建议在下一 Wave 优先修复发现 1 和发现 2。 --- ## 审计元数据 - **审计标准**: plugin-abi.md v1.1 (含 §8 异常安全 + §9 字符串生命周期,W12.6 追加) - **参考审计风格**: agents/audits/W11.1-context-audit.md - **安全日志参考**: docs/explanation/security-logging.md (W9.3) - **对比文件**: plugins/deepseek/src/deepseek_plugin.cpp (仅参考,不审计) - **不修改文件**: anthropic_plugin.cpp (审计只读)