3023 字
15 分钟
给自己的平台做一次全仓审计:7 个 agent 并行体检,挖出 3 个真能出事的洞

Project Hail Mary 是我自己在跑的一套个人 API 平台,四个核心组件(Registry / Auth / SDK / Deployer)加九个平台服务、七个业务 app,全部部署在一台 DigitalOcean droplet 上。上一次真正意义上的全仓审计是 2026-06-07,之后三周功能井喷——Magic Link 登录、消息总线、agent 导览层、admin 后台 Phase B、日志转发全平台开启——文档追得上,代码里悄悄埋的坑没人专门去挖过。

这次决定认真做一次体检:不是”看看有没有问题”,而是逐个组件、逐个文件读一遍,找真实的 bug、真实的权限漏洞,顺便把能安全删的死代码删掉。

方法:只读审计 agent 先摸底,绝不边审边改#

第一原则很直接:审计阶段如果一边看一边改,很容易看漏——改代码的心智负担会挤占”这段逻辑到底对不对”的注意力。所以第一批全部是只读 agent,覆盖七个区域,各自互不重叠:

registry / auth / sdk / deployer / services(9个) / apps(7个) / bootstrap+CI

每个 agent 拿到的 brief 里有两类关键信息:

  1. 该读哪些文件、对照哪份 spec 文档docs/registry.mddocs/auth.md 等——这些文档在 06-07 之后又做过一轮准确性校对,可以当作”这是设计意图”的可信来源)
  2. 哪些架构决策是明确故意的,禁止被当成 finding——比如 registry 心跳表故意不备份、SQLite 不用 ORM、Deployer 没有自动回滚。这份”禁止清单”直接从 CLAUDE.md 里摘出来传给每个 agent,省得它们把三周前讨论过否决的方案又提一遍。

每个 agent 还带着几条”已知可疑点,去核实”——上一轮审计留下的怀疑但没深挖的东西,比如”cachetools 这个依赖是不是没人用了”、“logservice 的 DELETE / 是不是权限给宽了”。这样审计不是从零开始,而是带着假设去验证,效率高很多。

7 个 agent 并行跑,每个大概 5-6 分钟,全部读完源码后各自写一份 markdown 到 scratchpad,包含严重度分级、文件行号、代码引用、修复建议、以及置信度和验证方法——比如”这是我读代码推出来的,n=0 次真实复现”和”我在实际 venv 里跑了一遍验证过”要分开标注,不能把猜测当结论。

挖出来的三个高危#

审计跑完,最严重的三条是这些:

Auth 组件:/api/pat/verify 绕过了 PAT 的 admin-scope 门禁。 平台在 2026-06-07 上过一次严格门禁:PAT(Personal Access Token)只有同时满足”底层用户本身是 admin”和”token 显式带 admin scope”两个条件才算管理员权限。这个门禁在 deps.py::_resolve_pat 里实现对了:

components/auth/src/auth/deps.py(修复前)
is_admin = info.is_admin and ("admin" in info.scopes)

POST /api/pat/verify(给非 SDK 调用方用的备用验证接口)直接回显了没过门禁的原始位:

components/auth/src/auth/api/pats.py(修复前)
return VerifyPATResponse(
user_id=info.user_id,
email=info.email,
is_admin=info.is_admin, # 原始 user-row 位,没过 scope 门
scopes=info.scopes,
audiences=info.audiences,
)

任何信任这个字段名字面意思的调用方,拿一个属于 admin 用户、但 scope 里根本没写 admin 的 PAT 去调这个接口,会被告知”这个 token 是管理员”——正是当初那次严格门禁想堵死的洞,从第二个调用点原样重开了。修复方式是抽一个共享函数 gated_is_admin(),两个调用点都强制走它,不允许再各自实现一遍判断逻辑。

SDK 组件:M2M 公钥文件损坏会让全站请求 500。 M2MVerifier.verify() 的设计契约是”永不抛异常,验证失败一律返回 None”,中间件靠这个假设兜底。但代码里只捕获了 jwt.InvalidTokenError

components/sdk/src/sdk/m2m_verifier.py(修复前)
try:
payload = _jwt.decode(token, self._public_key, algorithms=["RS256"], ...)
except _jwt.InvalidTokenError:
return None

PyJWT 的异常继承关系我专门去核实了一下——InvalidKeyError(公钥格式不对时抛出)和 InvalidTokenError平级的兄弟类,都直接继承自 PyJWTError,不是父子关系:

PyJWTError
├── InvalidKeyError ← 公钥解析失败
└── InvalidTokenError ← token 本身有问题
├── InvalidAlgorithmError
└── ...

也就是说,一旦公钥文件在 redeploy 竞态里被截断读到一半、或者被误换成了别的文件,prepare_key() 抛出的是 InvalidKeyError,完全绕过了那个 except。而中间件调 verify()任何带 bearer token 的请求都会先过一遍——不只是 M2M 服务间调用,一个普通用户的 JWT 也会先撞上这一层。公钥文件一旦损坏,不是”M2M 调用失败”,是”全平台所有认证请求 500”,直到手动发现并修好文件为止。修复就是把 exceptInvalidTokenError 放宽到 PyJWTError,一行改动,但这行改动决定了一次运维事故的爆炸半径是”局部”还是”全站”。

logservice:DELETE / 用的是 write ACL,而 write 必须广授。 这条最直白也最容易被忽略。logservice 的 DELETE / 接口按时间范围清空整个中央日志表,鉴权走的是 _check_write——听起来合理,删除是写操作嘛。问题是 write 这个 ACL action 平台里必须广泛授权,因为每个服务都要能 POST 自己的日志:

services/logservice/service.yaml
acl:
- {action: write, principal: "user:*", effect: allow}
- {action: write, principal: "service:*", effect: allow}

于是任何一个持有有效 PAT/JWT 的用户,或任何一个 M2M 服务凭证,调一句 DELETE /?until=<未来某天>,就能清空平台上所有服务的全部日志,没有时间下限、没有按调用方隔离。测试文件里甚至有一条 test_user_with_write_can_delete 明确把这个行为断言成”预期行为”——说明之前有一轮修复只是把”读写语义搞混”这个问题修对了,但没意识到”写”这个粒度本身对这个操作来说就是错的。对照 messageservice 自己的 DELETE /messages/{id},人家用的是 _require_admin_or_ingest,不挂在通用 write 上——这才是该抄的模式。修复:把 logservice 的 DELETE / 换成 admin-only,M2M 身份按 SDK 的不变量(Principal.__post_init__via=="m2m"is_admin 同时为真会直接 raise)永远不可能是 admin,所以这个改动天然把服务流量排除在外,不需要额外白名单。

修复:6 个 agent 并行,各自扎在自己的目录里#

审计结果裁决完,进入落地阶段。同样是并行,但这次每个 fixer agent 的 scope 严格限定在一个目录(components/auth/ 只准动这一个目录),并且明确要求:TDD 先写测试、跑通该组件自己的 pytest不准 git commit(commit 由我在全部 agent 收工后统一按逻辑边界切)。

这样切的好处是 diff 的 blast radius 天然对齐目录边界,之后 review 或者出问题回滚都好定位。6 个 agent 覆盖:auth、sdk、registry、9 个 platform services、7 个 apps(含 files-web 这个 TS/React SPA)、bootstrap+CI+deployer 的观测性小修。

跑完之后有个有意思的副作用:sdk 组件删掉了两个死依赖(cachetoolsstructlog,repo 全局 grep 零 import),这两个包同时也是其它组件通过 editable install 依赖的 sdk 的传递依赖。结果每个跑了 uv run pytest(而不是 uv run --frozen)的下游组件,uv.lock 都被自动重新解析、悄悄摘掉了这两个包。有个 fixer agent 甚至一度手滑触发了这个连带更新,又手工把它 revert 回去、改用 --frozen 重新验证——算是记一笔:多个并行 agent 共享一个 monorepo 依赖图时,uv sync 的隐式重新解析会跨目录传染,验证脚本最好锁 --frozen,除非确实想让 lockfile 更新落地。这次因为下游确实该更新(sdk 依赖表变了),最后统一在全仓测试扫描时让它自然生效,没有回退。

全部 6 个 agent 收工后,跑一次全仓测试扫描:

components/registry 200 passed
components/auth 242 passed
components/sdk 286 passed
components/deployer 205 passed
services/logservice 40 passed
services/kvservice 36 passed
services/secretsservice 30 passed
services/oss 58 passed
services/commentservice 109 passed
services/emailservice 27 passed
services/messageservice 22 passed
services/notificationservice 40 passed
services/wechatservice 16 passed
apps/timeservice 6 passed
apps/resume 78 passed
apps/files 137 passed
apps/displayservice 78 passed
apps/locationservice 93 passed
apps/files-web build ok

18 个套件全绿,加起来大概 1700 个测试。这里有个之前埋的坑顺带被堵上了:CI 的 ci.yml 矩阵里一直缺 apps/timeservice(6 个测试)和 services/commentservice(109 个测试)——都是已经上线的服务,从来没进过 CI 门ci.yml 同时也是 deploy-trust-root.yml 的部署门禁(通过 workflow_run 触发),意味着这两个服务的回归风险这几周一直是零自动防护,只能靠上线后手动观察。这次一并补进矩阵。

顺手清理的:死依赖、重复代码、陈旧注释#

除了三个 HIGH,审计还揪出一批”不会死人但看着难受”的东西:

  • structlog 在四个组件(sdk / auth / registry / deployer)的 pyproject.toml 里都声明了,全部零 import——代码实际用的是 stdlib logginggitpython 在 deployer 里也是同款死依赖,git 操作全靠 subprocess 调 CLI。
  • 五个服务(kv/secrets/email/notification/wechat)各自手写了一份 _require_admin(request),逻辑跟 SDK 自带的 require_admin 装饰器一模一样——统一换成装饰器,每个服务删掉大约 7 行。
  • registry 里同一个”审计操作者”字符串被三种不同写法各自实现了一遍("admin_token" vs "admin"f"jwt:<id>" vs f"user:<id>"),抽出一个共享函数统一形态。
  • 五处代码注释还在提 litestream——这个持续复制机制已经在 2026-07-02(也就是这次审计当天更早些时候)下线,换成了每日快照备份,注释没跟上现实,顺手改掉。

这次审计方法本身值不值得复用#

这不是我第一次用多 agent 干活,但这次”只读审计 + 裁决 + 并行修复”三段式分得比较干净,值得记一笔判断标准:

什么时候值得开 7 个并行 agent 去审计,而不是自己一个一个看——当审计范围本身可以按边界干净切开(这次是”组件”这个天然边界),且每块的代码量单人通读会累积上下文疲劳的时候。7 个 agent 同时跑,每个只需要在自己的小范围里保持高信噪比,比一个 agent 顺序看完 21 个目录、到最后几个组件时注意力已经被前面的细节淹没要可靠得多。

什么时候不该信任 agent 自己的验证——每个 fixer agent 都在自己的目录跑了 pytest 并报告”绿了”,但真正让我放心的是最后我自己又拉了一次全仓测试扫描——这是审计流程里”一致 skepticism”的部分:agent 的自我汇报只是过程证据,跨目录的交叉验证不能省。这次全仓扫描还额外抓出了 fixer agent 报告里没提到的 uv.lock 连带漂移,如果只信任各自的”绿了”就收工,这类跨目录副作用会被漏掉。

置信度要跟着结论走,审计报告里每条 finding 都带着”我是怎么验证的、n 是多少”——像 M2M 公钥那条,agent 明确写了”在实际 venv 里对着 pinned 的 PyJWT 版本复现过异常类型,不是从文档猜的”,这种标注让裁决阶段能分清哪些是能直接动手修的硬结论、哪些是需要再确认的弱信号。这次三个 HIGH 全部是高置信度、有代码级证据支撑的,没有一条是”听起来像是个问题”就直接动手改的。

审计加修复总共跑了大概一个半小时,中间大部分时间是并行 agent 在后台跑,我这边只需要在每批结果回来的时候做裁决判断。这大概是这种模式最大的价值——不是替代人做判断,是把”通读代码找问题”这种重上下文、可并行的探索工作外包出去,把人的注意力留给”这条 finding 到底该不该修、修成什么样”这类真正需要判断力的决策上。

Sources:

给自己的平台做一次全仓审计:7 个 agent 并行体检,挖出 3 个真能出事的洞
https://blog.lishuyu.app/posts/给平台做一次全仓审计/
作者
猫猫魔女
发布于
2026-07-02
许可协议
CC BY-NC-SA 4.0