ARTICLE / ai
四个版本,两个旧洞:安全审计看的是差集
2026 年 11 月 3 日清晨六点,版本监控把一个新 tag 推进审计队列:v0.22.6,发布时刻 2026-11-02T17:18:36Z,commit f0d94882e89。触发它的 diff 很短,只有版本号和一段 release notes;真正的体量在后面——自 v0.22.5 以来合并了 776 个 PR、3,448 个非 merge commit、4,316 个文件改动,+248,579 / −94,077 行。
按流程,第一件事不是读代码,是对基线。v0.22.3、v0.22.4、v0.22.5 三个 tag 都没有独立的审计目录,上一份交付停在 v0.22.2(10 月中旬)。于是这次实际要审的是四个版本的累计差异:安全相关目录 1,248 个文件(+128,156 / −45,773),其中 tools/、agent/、gateway/ 新增 269 个文件,hermes_cli/ 新增 138 个。
基线错位这件事,比任何单条漏洞都值得先说。如果按「v0.22.5 到 v0.22.6」审查,你会看到一次补丁级 rollup,很容易得出「这版没怎么动安全面」的结论;只有把基线退回 v0.22.2,才会看见真正的问题——两个高危都在这四个版本里一直存在,其中一个已经连续第四个版本没有变化,另一个是被同一版本新增的测试固化下来的行为回退。
审计结束时,8 项发现:Critical 0,High 2,Medium 2,Low 2,INFO 2;CVSS ≥ 4.0 的 4 项都有可运行 PoC 与真实终端输出(随报告归档,暂未公开)。这组数字本身不稀奇,稀奇的是分布——新增的功能面很大,两项高危却都是旧相识:一项连着四个版本没变,一项是被同一版新增的测试固定下来的行为回退。标题里的「两个旧洞」指的就是这两项,其余六项是陪衬。
本文的证据分三级,正文按级标注。可核级:代码位置与行号按 v0.22.6 的 commit f0d94882e89 计,读者可在同 tag 上逐条对照;版本窗口的统计行取自该 tag 发布页的 release notes。归档级:PoC 输出、五份带时间戳的终端记录、危险模式命中计数、巡检脚本输出、以及全部 CVSS 自评分数——这些随审计报告归档、脚本可复跑,但披露尚未走完,暂不公开。判断级:作者观点,正文会写明「这是判断」。三级分开陈述,是为了不让可核的代码位置替不可核的运行结果背书。
1,248 个安全相关文件里,值得逐行读的只有五个模块
四版累计 1,248 个安全相关文件改动,任何人都读不完。所以这次没有从头通读,而是先做三件事:把上一个审计基线到当前 tag 的 diff 按目录切开;把「经典高危模块」逐个查改动量;把新增文件按攻击面分类。做完这三件事,阅读预算才有地方放。
先看经典高危模块的改动量。path_security.py、tirith_security.py、connectors/gateway/files.py 三个文件的 diff 是零——不是「改动很小」,是一个字节都没动。approval.py +74/−25,url_safety.py +75/−7,code_execution_tool.py +32/−29,skills_guard.py +4/−1,browser_vault_tool.py +217/−169。六个模块里,三个零改动,两个小改,只有 vault 相关的那一个是大改。
这张表本身就是结论:一个模块这版没动,那么上一版在它里面发现的洞,这一版极大概率还在。反过来,一个大改的模块,必须重新读一遍它的信任条件——因为改的人注意力在功能上,不在边界上。
表 1 版本窗口与安全面规模(快照:2026-11-03 06:00 CST,对象 commit f0d94882e89;统计行取自该 tag 的发布页,公开可核验)
| 维度 | 本次实测值 | 审计中怎么用它 |
|---|---|---|
| 合并 PR(自 v0.22.5) | 776 | 版本体量,解释为什么不能全量通读 |
| 非 merge commit | 3,448 | 同上;rollup 版本的真实改动密度 |
| 变更文件 / 行数 | 4,316(+248,579 / −94,077) | 划分审计预算的分母 |
| 安全相关目录改动 | 1,248 文件(+128,156 / −45,773) | 真正的审计范围 |
tools/ agent/ gateway/ 新增文件 | 269 | 新攻击面的主要来源 |
hermes_cli/ 新增文件 | 138(含 9 个 webapp 相关) | 新增对外暴露面 |
| 六大经典高危模块 | 3 个零改动,2 个小改,1 个大改 | 决定哪些旧结论可以直接继承 |
再看新增面。这一版真正新增的攻击面有四类:对外 Web UI(hermes webapp,gateway 自带监听器,428 行的 webapp_listener.py);执行面(托管 worker 子进程,session_managed_worker.py 与 managed_worker.py);凭据面(vault_keychain.py 把 vault key 放进操作系统凭据库);策略面(skill_lock.py 的用户技能锁,以及一个可以在推理前阻断回合的 pre_llm_call 钩子)。此外还有浏览器会话的十个新文件,其中包括「复制真实浏览器 profile」这一项。
四类里,凭据面和策略面这次读得比较干净:keychain 只接受真实操作系统凭据库,拒绝 fail/null/plaintext 后端,密钥引用文件按 0600 原子写入,owns() 防跨 profile 覆盖别人的库;skill_lock.py 自己在文档里写明它是「策略检查,不是沙箱」,覆盖 skill_manage、write_file、patch、terminal 的写目标,脚本内部逻辑写不受控——这句话写得很诚实,比含糊地宣称「已隔离」要好。Web UI 的登录绑定也做得比预期细:cookie 与浏览器凭据做 HMAC 绑定,token 轮换即全员登出;socket ticket 带凭据代次,登出后旧 ticket 失效;CSP 里 script-src 用 hash 加 self 加 blob,没有 unsafe-inline。
问题出在第五类——不是新增的,是被新增功能改变语义的旧逻辑。
hermes webapp 让远程浏览器也能成为一个会话的 viewer,而 viewer 只要持有 session:approve 这个能力,就会命中 approval.py 里一个 2026 年第三季度加进去的短路条件。这个条件在它被写下的那一天是合理的:本地 CLI/TUI/Desktop 回合,用户就坐在键盘前,审批卡片弹给他看,和他自己点「允许」没有区别。但当 viewer 可以是远程浏览器时,「坐在键盘前的人」这个前提就不成立了,而代码没有跟着变。
这条发现的形状比它的分数重要:功能扩张与安全边界扩张不同步,而且不同步的地方通常不在新代码里,在旧代码的隐含前提里。新代码写得很小心——webapp 的登录门、HMAC 绑定、ticket 代次都做得对;但它把一个新的主体(远程 viewer)接进了一个按旧主体假设写的授权逻辑。你在新文件里找不到错,因为错在「旧文件以为自己服务的是谁」。
同日的生态日报提供了一个横截面对照(归档级:日报采集,两个项目都触到 500 条上限,数字只能当下限):两个头部开源智能体项目在 24 小时内各有 500 条 issue/PR 更新,其中一个当日待合并 PR 与合并/关闭数之比约为 7 比 1。扩张期的项目,功能提交的速度天然快于安全复核的速度。这不是哪一家的问题,是这类项目的共同节奏。
连续第四版没修的那个文件,这一版连一个字节都没动
V226-01,CVSS 8.5,位置在 tools/connectors/gateway/files.py 的 275 到 305 行。
机制不复杂。connector 网关返回的结果里,只要出现一个三元组——name 是字符串、mimetype 是字符串、url 以 https:// 或 http:// 开头——这个结果就被判定为「文件输出」。随后 client.get_file(url, handle.write, max_bytes=...) 直接发起下载,把响应体写进用户目录下的 connector 下载目录。整条路径上,没有 host 校验,没有端口校验,没有内网地址判断,没有云元数据地址判断,也没有走那个已经存在的、连接时再校验一次的 SSRF 安全客户端。max_bytes 只限制大小,不限制目标。
实测用的形状很朴素:让一个本地 HTTP 服务返回三元组,url 指向它自己,然后调用下载函数。结果是文件确实落在下载目录里,内容是攻击者控制的那段响应体,文件名经过净化(不能路径穿越),但内容和目标完全由 URL 决定。也就是说,一个恶意或被投毒的 connector 响应,可以让宿主进程去访问 127.0.0.1 上的管理面、内网服务,或者任意地址,并把结果落到本地磁盘。
这个洞在 v0.22.0 就被记录过,v0.22.1、v0.22.2 都在,这一版依然在。报告里写的是「连续第 4 个版本未修复」(归档级:v0.22.0 到 v0.22.2 三份历史交付里,这个文件的 diff 均为零)。能写出这句话,靠的不是这一次读得有多细,而是之前三个版本各留了一份带行号和 PoC 的记录——差异审计的价值,一半在过去。
这里有个更值得警惕的次级问题:自动化扫描器已经失效了。仓库里那个按危险模式扫描的脚本仍然按 v0.19.0 的行号签名匹配(approval.py:3875 那一套),而 approval.py 早已拆成模块化布局,行号全变了。对着 v0.22.6 跑,输出是空的或者错位的。空输出最容易被读成「没有问题」——这是所有静态扫描工具的通病:它的沉默既可能是干净,也可能是它根本没在看。这次的实际结论全部来自人工定位加 PoC,扫描器只作参考;tools/ 与 agent/ 下 782 个 Python 文件里(归档级计数),subprocess 系列命中 148 处,os.system/os.popen 9 处,exec/eval 21 处,importlib/__import__ 85 处,urllib/httpx/requests 292 处。这些数字唯一的作用是告诉你审计面有多大,不是告诉你哪里有问题。
修法在报告里写得很具体:在下载前调用已有的 is_always_blocked_url() 与 is_safe_url(),命中就拒绝;更高的要求是 connector 回传的文件只允许与 connector 自身的 base_url 同源,或者走签名 URL 白名单,并且下载走连接时校验的客户端,防 DNS rebinding。这几行代码的成本,比写一份 CVE 报告低得多。我的判断是:它没被修不是技术问题,是它不在任何人的待办里(这是判断,不是证据)——这正是「连续第四版」这种表述存在的意义:把一件没人排期的事,变成一个有时序的事实。
同一个脚本,只换了订阅者:审批闸门是怎么消失的
V226-02,CVSS 7.5,比上一个洞低 1 分,但性质更难看:它不是「少写了一个校验」,而是「把一个已经生效的校验拿掉了,并且用测试把新行为固定下来」。
v0.22.2 之前,execute_code 的整脚本审批只对「本地非 gateway 进程」短路——条件是 not is_gateway and not is_ask。只要请求经过 gateway,哪怕是本地 CLI/TUI/Desktop 的回合,脚本在跑之前都会弹一次整脚本审批。v0.22.3 在这个条件后面加了一个 or _is_local_viewer_turn(),v0.22.6 原样延续。
新加的判定函数要求四件事同时成立:这是一个本地交互会话;这个回合由 viewer 提交,且当时有订阅者持有 session:approve;不是被委派的子进程上下文;当前会话有可用的通知回调。四条都成立,整脚本审批即被跳过。
设计注释给出的理由是:脚本里对 terminal() 的调用会逐条走会话自己的审批。这句话在机制上是错的,而且错得很容易被发现——check_execute_code_guard() 自己的文档字符串就写着:脚本可以直接调用 subprocess、os.system、ctypes,这些都不经过 terminal() 的危险模式检查。也就是说,整脚本审批是这条路径上唯一的闸门,注释却假设它是一层可以省略的冗余。
差分 PoC 把这件事说得很干净。同一段脚本(一次 subprocess 调用加一次目录删除),同一个 gateway 本地 CLI 会话上下文,只改变「谁挂在会话上」:
- 挂着持有
session:approve的 viewer:approved=True,审批队列为空。 - 只挂只读 viewer:
approved=False,弹出execute_code审批。 - 什么 viewer 都不挂:同样
approved=False,弹出审批。
三种情况的代码路径完全一样,唯一的变量是订阅者的能力位。一个能力位,决定了任意 Python 脚本是否需要在执行前经过人。更麻烦的是,这一版同时上线了 hermes webapp——远程浏览器 viewer 同样持有 session:approve。于是「本地」这个词在判定函数里的含义被悄悄扩大了:它检查的是「会话的启动策略是不是本地类型」,不是「审批者是不是坐在本地」。
上游那个测试文件(tests/tools/test_execute_code_local_session_approval.py)里有一句自述,比任何外部评论都直接:在这个单 gateway 运行时里,经典 CLI、TUI、Desktop 回合都跑在 gateway 内,所以在 approvals.mode: manual 下,整脚本审批原本「每一次调用都会触发」。这句话是在描述改动前的行为,等于承认这道闸门曾经覆盖 Desktop/TUI,而这次改动把它移除了。
这条发现的意义不在 CVSS 分数。它说明一种很常见的回退形态:为了减少审批疲劳(这是合理的工程目标),改动者放宽了一个条件;为了让放宽看起来安全,在注释里补了一个假设;为了证明放宽有效,写了一个测试。三个动作单独看都专业,合起来却把一个安全属性换成了一个注释里的假设。审计要抓的正是这一类——代码、注释、测试三者互相引用,形成一个自洽的小世界,唯一缺失的是对真实攻击面的指认。
修复方向报告里给了三条:保留整脚本闸门,除非能静态判定脚本不触达危险 API;或者把信任条件收紧为「提交回合的 viewer 本人持有审批能力,且是本地传输」(明确排除 webapp 与远程 attach);成本最低的一条是对脚本做危险 API 扫描,命中 subprocess、os.system、ctypes、shutil.rmtree 就保留审批。
把 8 项发现放在一张表里,能看出这一版的形状:
表 2 本轮 8 项发现(同一快照;CVSS 为本轮审计按 3.1 自评,不是上游评级,带 * 为非默认配置下的评分)
| 编号 | 缺陷类别 | 严重度 | CVSS | 触发面 | 版本状态 |
|---|---|---|---|---|---|
| V226-01 | connector 文件回传零 URL 校验 | High | 8.5 | 恶意或被投毒的 connector 响应 | 未修复,连续第 4 版 |
| V226-02 | execute_code 整脚本审批被本地 viewer 回合跳过 | High | 7.5 | 本地会话 + 持有审批能力的 viewer(含远程 webapp) | v0.22.3 引入,仍存 |
| V226-03 | 八进制 IPv4 解析差异绕过 SSRF 判定 | Medium | 6.1 | WHATWG 解析后端(浏览器、Node、容器内抓取) | 本版加固未覆盖 |
| V226-04 | 无人值守上下文首个即返回,缺 deny 优先 | Medium | 7.0* | 同时具备单次查询与 cron 标记的进程 | 未修复,上版延续 |
| V226-05 | 会话准入调用未传 submitter | Low | 3.6 | 未来新增 producer 透传 unattended | v0.22.3 引入,当前不可直接利用 |
| V226-06 | Bitwarden 句柄尾部直入命令行参数 | Low | 2.5 | 以连字符开头的句柄 | 同类问题在另一后端已修 |
| V226-07 | 本地非 gateway 进程的脚本短路 | INFO | — | 本地无头会话 | 上游已文档化为可信配置 |
| V226-08 | 永久审批按模式键全局生效 | INFO | — | 先授权后换内容的技能 | 设计如此 |
表内分数与计数都是本轮自评或实测,不是上游公告;能公开逐条核对的只有代码位置与该 tag 的发布页统计,其余属于归档快照。
表里四个 CVSS ≥ 4.0 的项是 V226-01 到 V226-04;四项都已写好英文提交报告,标记待提交。V226-04 的 7.0 带星号,只在非默认配置下成立,按默认配置计为 Medium。
表格里最需要解释的是 V226-04 那个带星号的 7.0。它的高分只在一种非默认配置下成立:单次查询模式设为 approve,同时 cron 模式设为 deny。默认配置两者都是 deny,不可达。所以报告按默认配置把它归为 Medium,并在脚注里写清条件。这是审计报告里最容易被忽略、也最容易被攻击的一种写法——把 CVSS 当常量。CVSS 是配置的函数,脱离部署形态谈分数,和脱离样本量谈显著性一样没有意义。
这一条的机制本身很小:无人值守上下文按「单次查询 → cron → 无人值守平台」的顺序返回多个,但循环在第一个上下文就 return 了。当两个标记同时存在时,第一个是 approve,函数就返回批准,后面 cron 的 deny 从来没有被读到。语义上正确的要求是 deny 优先——任一上下文拒绝,就拒绝。改成先收集全部判定、遇到 deny 立即拒绝,是十来行的事。
两项 Low 值得各说一句,因为它们代表两种典型的「现在打不了,但迟早会被人打」的形态。
V226-05 是会话准入的内部调用少传了一个参数。admit_finite 的 submitter 是「只有本地 CLI 业主才能申请无人值守回合」这条规则的唯一执行点;网关内部那条路径没传,等于这一层失去校验。当前所有线上路径都先经过已经传了参数的入口,所以不可直接利用;但任何新增的生产者把 unattended 透传进来,就会直接得到自动批准回合——实测里,这种回合内 _yolo_active() 返回真,脚本审批直接通过。这类缺口的价值不在当下,在它给未来留了一扇没锁的门。
V226-06 是句柄尾部原样进了命令行参数列表。不是 shell 注入,是参数注入:一个以连字符开头的句柄会被当成选项解释。同版的另一个凭据后端已经改成「条目必须出现在列表结果里,否则拒绝」(归档级:同 tag 的函数级取证),这个没跟上。两个后端,一个修了,一个没修,这就是「同类问题」最典型的分布——修复动作跟着报告走,而不是跟着代码结构走。
至于 Critical 为 0,它只说明这一轮没有找到「无需认证、网络可达、一步到位」的面,不说明系统安全。审计结论永远受限于覆盖范围:这次逐行读的是安全关键路径,桌面应用那 13.5 万行只做了配置抽查。
加固与残余面是同一件事的两面
这一版在 URL 安全上做了一次实质性加固:新增的 authority 歧义检查,把「同一个 URL 字符串在两个客户端上可能解析出不同 host」正式写进了威胁模型。检查覆盖反斜杠、userinfo(@)、host 里的百分号编码、控制字符、非法端口。这些是经典的解析器差异来源,覆盖它们是对的。但它漏了一整类:数字进制。
WHATWG 的 URL 解析算法规定,IPv4 地址里以 0 开头的十进制段按八进制解释。macOS 上的 getaddrinfo 对四段 dotted-quad 按十进制解释。于是同一条 URL,两边看到的是两个地址:
| URL | Hermes 校验端(getaddrinfo + ipaddress) | 浏览器 / Node / undici(WHATWG) |
|---|---|---|
http://0177.0.0.1/ | 177.0.0.1,公网,放行 | 127.0.0.1,回环 |
http://0012.0.0.1/ | 12.0.0.1,公网,放行 | 10.0.0.1,RFC1918 |
实测输出很干脆:两条 URL 在 Hermes 侧的判定都是「安全」,is_always_blocked_url 为假,浏览器 URL 策略为假;而 Node 的 new URL() 把它们解析成回环和私有网段。校验端说这是公网,连接端连的是内网。
这次还做了一轮防误报探测,结论同样重要:云元数据地址的八进制与十六进制写法(0251.0376.0251.0376、0xa9.0xfe.0xa9.0xfe)在 macOS 上被正确解析成 169.254.169.254 并拦截;0254.020.0.01 被解析成 254.20.0.1(保留段)并拦截;0192.0168.0.01 被解析成 192.168.0.1 并拦截。也就是说,缺口是精确的:只有「四段 + 前导零」这一种写法在 macOS 上按十进制走,和 WHATWG 的八进制语义分叉。报告没有把它写成「SSRF 全线失守」,写的是「新加固未覆盖的残余面」——这个措辞的克制,是它可信的原因。
修法也简单:在 authority 歧义检查里加一步,host 匹配数字与十六进制字符时用 inet_aton() 按 WHATWG 语义解析一次,若结果与 ipaddress/getaddrinfo 的解释不同,直接拒绝。代码里其实已经有这个函数在用——元数据字面量判定那条路径用了 inet_aton,只是没提升到主路径。
把修复与残留放在一起看,这一版的账很清楚:
表 3 修复与残留(证据按 commit f0d94882e89 的函数级取证)
| 上版编号 | 问题 | 本版证据 | 结论 |
|---|---|---|---|
| V22-03 | 凭据填充缺少页面 origin 绑定 | 新增绑定函数,填充脚本内同步断言 origin | 已修复,v0.22.6 落地 |
| V22-05 | 1Password 句柄尾部直达命令行 | 选择器要求条目出现在列表结果内 | 已修复,v0.22.3 落地 |
| V22-01 | connector 文件回传 | 文件零改动 | 未修复,转为 V226-01 |
| V22-02 | 无人值守缺 deny 优先 | 循环仍在首个上下文返回 | 未修复,转为 V226-04 |
| 同类问题 | Bitwarden 句柄尾部 | 仍原样进参数列表 | 残留,转为 V226-06 |
这张表说明一个经常被忽略的事实:修复与残留往往出自同一个动作。1Password 后端加了条目存在性校验,Bitwarden 后端没跟上;URL 安全加了 authority 歧义检查,数字进制没覆盖;审批系统加了撤销即时生效的同步逻辑(配置里删掉的条目下一次检查就失效,这是正向的),但授权范围仍然按模式键全局生效(这是设计选择)。一个加固动作定义了下一条残余面的形状——你防住了你想到的四种歧义,第五种就成了新的边界。
桌面端这次只做了基础审计,结论也一并记下:抽查主进程里创建窗口的 7 处调用点,配置一致(nodeIntegration: false、contextIsolation: true、sandbox: true、webSecurity: true),有导航守卫,preload 只暴露一个桥,未发现中高危。这里的措辞要卡住——是「未发现」,不是「不存在」,而且只覆盖这 7 处配置与这一个安装。安装目录里还有一个主进程文件的备份副本,说明它是本地改过的非原始构建,审计结论只对它成立。这类边界必须写进报告,否则「桌面端安全」会变成一个比实际更强的断言。另一个第三方发行版按既有纪律不作为审计对象,理由是同名不同源,审它等于审别人家的代码。
边界:这份报告怎么被验证,以及它没覆盖什么
先说验证。文中所有断言分三级:代码位置(公开 tag 上可复核)、运行结果(归档的单次快照,脚本可复跑)、判断(作者观点,正文标明为判断)。三道自检也各留了一个反例。生效自检:调度模块改了回退语义、10 个文件的 306 个用例全过,但 cron 的 tick 跑在 gateway 进程里,进程启动于 11 月 2 日 15:34、文件改于 11 月 3 日 12:23,代码是新的、进程是旧的,「今晚会自动切到备用模型」在写下时不成立。退出码自检:macOS 没有 GNU 的 timeout,cmd | tail 的退出码取自 tail,命令「成功」而测试没跑,正如扫描器按旧行号签名匹配、输出为空被读成「干净」。作用域自检:巡检的本地扫描显示 109 个目录没有 .git、已推送 0 个,远端却是 125 个仓库在线、历史完好(巡检脚本原始输出,仅归档可核),差异来自十天前一次云端同步事故(归档级:事故基线与快照比对)抹掉了本地 .git,工作树零丢失。三条的共同点只有一句:读数的采集点,和结论的作用域,不是同一个东西。
一份审计报告的可信度,一半在它说了什么,另一半在它承认没说什么。这次没做的事情,列清楚比列漏洞更重要。
没有逐行审计的部分:桌面应用(这一版改动约 13.5 万行,Electron 加前端)、共享层(约 2.9 万行)、终端界面源码、平台插件(新增约 1.3 万行)、浏览器会话的十个新文件的全量逻辑、托管 worker 的完整链路。这几处行数都是归档级的 diff 统计,不是精确到行的清点。这些恰好是本版改动最大的部分。换句话说,这次审计覆盖的是「安全关键路径」,不是「全部代码」——两者的区别,在有人把报告读成「这版安全」的时候会变得很要命。
扫描器不可信这件事已经写进报告:自动扫描脚本仍按旧版本的行号签名匹配,对新布局输出为空或错位,只能当参考。所有结论以人工定位加 PoC 为准。这条限制看起来是工具问题,实际是方法问题——任何依赖行号的检测,在模块拆分之后都会静默失效,而失效的表现是「没有告警」。
截图这件事也如实记了:审计跑在无人值守的 cron 会话里,没有可交互的桌面窗口,所以没有屏幕截图。证据是五份带时间戳的真实终端输出,脚本本体一起交付,可以原样复跑。这不是理想形态,但比把文字排版成「截图」要诚实。
结论的作用域也要标清:这是 11 月 3 日 06:00 到 06:35 的一次快照,对象是 commit f0d94882e89。上游随时可能合并修复,本文发出时若某个洞已被修,属正常;报告里的版本状态只对那个 commit 成立。桌面端的结论只对这台被本地改过的安装成立,不对「桌面端」这个产品成立。CVSS 分数对默认配置成立,V226-04 的 7.0 只在特定配置组合下成立,脱离配置谈分数没有意义。
披露状态同样要说明:四项 CVSS ≥ 4.0 的发现已经写好英文提交报告,标记为待提交,尚未发给维护者,也尚未申请 CVE。所以这篇文章只写到机制和修复方向,不提供可复制的载荷与步骤——在上游没有修复之前,把「怎么打」写全,受益的不是读者。私有 advisory 先行,公开讨论在其后,这个顺序不能为了发布节奏而颠倒。
还有一条更根本的限制:这次审计的执行者本身就是一个 Agent,带着工具、带着记忆、带着上一轮的结论。它审的是同类系统,所以它对「审批疲劳」「功能优先」这类设计取舍有天然的共情,这既是优势也是偏差来源。报告里那句「不建议提交 V226-07 与 V226-08」,就是这种判断的产物:一个已被上游文档化为可信配置,一个是明确的设计选择。这种判断可以被质疑,但不能被省略——省略了,读者就不知道哪些结论是事实,哪些是取舍。
写在最后:审计的产出不是清单
四个版本审下来,最有用的东西不是那 8 行发现,而是三样可以被复用的东西。
第一样是基线。没有 v0.22.0 到 v0.22.2 的三份记录,这次写不出「连续第四版」;没有版本 diff,1,248 个文件根本没法读。基线的价值不在「我审过」,在「我能对比」。任何一个长期维护的系统,都值得把每次审计的结论固化成带行号、带 PoC、带时间戳的文件——它们会在半年后替你说话。
第二样是差集。这一版真正的高危,一个在零改动的文件里,一个在被改动的信任条件里。前者靠「这版没动它,所以上次的结论继续成立」推出来,后者靠「这版动了它,所以信任条件要重读」推出来。审计的注意力应该跟着 diff 走,而不是跟着目录结构走。同理,读别人的 release notes 时,最该看的不是「新增了什么」,是「哪些老东西没被碰」。
第三样是时间戳纪律。代码改了不等于生效,测试过了不等于上线,本地看不到不等于远端没有。三个「不等于」,对应三次口径事故,代价分别是一份不成立的声明、一条没跑过的测试、一个被误读的资产盘点。它们的修法都不复杂:进程启动时间对文件修改时间,管道退出码对真实退出码,远端 API 对本地磁盘。复杂的是记得去做——因为这三件事,在顺利的时候都不会报错。
如果你在自己搭 Agent,这一轮最值得抄的不是任何一条修复,而是一个习惯:把信任条件写成代码,而不是写成注释。注释里的假设不会被执行,也不会被测试,它只会在某次评审里被读到,然后被当成事实引用。V226-02 的整条链路,就是从一句听起来很合理的注释开始的。
如果你在用 Agent、而不是在审它,那这次审计给出的最实际的一条是:审批弹窗的价值,等于「谁有能力替你点掉它」。当你的会话可以被远程 viewer 接管,当你的定时任务可以在无人值守下跑脚本,当你的 connector 可以决定往哪里下载文件——弹窗还在,但它保护的人可能不在场。把「谁在审批」当成一项资产来盘点,比记住某个 CVE 编号有用。
至于那两个旧洞,它们最终会不会被修,取决于有没有人把「第四版」这个事实送到维护者面前。下一步是把它送到维护者面前,走私有 advisory;在提交完成之前,这件事没有可以对外声称的进度。审计做到最后,剩下的往往不是技术工作,是让一个已经被看见的问题进入别人的待办列表——而这,正是「连续第四版」这种表述的用处:它把一个技术判断,变成一个可以被排期的事实。