cloudflare / security-audit-skill·机制解读 + 一次完整实战

把「找漏洞」
变成一件可复核的工程

这份技能不产出漏洞清单,它产出的是举证责任:每条结论必须指名一个更低信任的主体、一条源码路径、一个有界观测结果。本页分两部分——先把这套机制讲清楚,再用它在 research-share 上的一次完整审计(53 个覆盖单元、32 个子代理、9 条结论)说明它跑起来是什么样子。

6 个阶段3 种运行画像8 种账本状态2 个机器校验器
53覆盖单元 / coverage units
32子代理目录 / agent dirs
9去重后候选 / candidates
4坐实结论 / confirmed
PART 01 机制:它凭什么说自己的结论可信 六个阶段、一本账、三档判决、一条独立性链。
01 / 问题

它到底在解决什么问题

普通的「扫一遍」失败,通常不是漏了某个函数,而是三件更基础的事没做。

Failure 01

清单式体检

照着 CWE 一条条勾:有没有 eval、有没有字符串拼 SQL。可是真实漏洞长在两条各自正确的代码接缝处——授权在 A 层做,数据在 B 层取,中间没有人负责对齐。

Failure 02

替部署环境猜答案

「应该前面有 WAF」「JWT 应该会被网关解析成 authenticated」。这些假设没有一条在源码里,可结论全押在上面——猜错一次,结论就反了。

Failure 03

自证

找漏洞的人写报告,写报告的人评严重度。于是「严重」变成了自我感觉的量词,而报告里没有任何一处能被别人独立复算。

这套技能的反向定义非常硬——它把「什么不算 finding」写在了第一步:

一个候选,如果没有具体的受影响主体、具体的资源、具体的安全后果,它就不是一条 confirmed finding。 A candidate without a concrete affected principal, resource, or security outcome is not a confirmed finding. —— SKILL.md

顺着这句话,整套设计都是它的推论。既然要指名主体和资源,就必须先把信任边界画出来;既然要有具体后果,就必须在有界范围内真的跑一次;既然结论要能被别人复算,就必须有一本记录每个格子审到哪一步的账。

它要防的对应的机制机器可查的落点
漏审,却宣称审完了确定性覆盖账本,一个单元一格coverage-ledger.json + 校验器
把猜测写成结论三档判决互斥,缺证据只能进 needs_validationfindings.json 分支 schema
自己给自己打分验证者必须是没参与过的人账本里每个 check 记 agent_id
在真实环境里动手缺一个沙箱控制就不执行run-metadata.json 的执行边界字段
02 / 模式

两种模式,两种代价

加载这个技能不等于授权跑完整审计。它默认只提供方法论。

Guidance mode · 默认

只借方法,不动文件

回答「这段代码安全吗」「这条告警怎么判」「帮我 triage 一下」。只取相关章节,不建输出目录、不写审计产物、不自动跑六阶段。

代价:几轮对话。

Full audit mode · 显式触发

六阶段,七份产物

用户明确说「审计 / 渗透 / 完整 / 端到端 / 出报告」才启动。要跑完六个阶段,产出七个共享文件,并通过两个校验器。

代价:这次在 research-share 上花了 32 次子代理委派。

如何选

如果一句话能被两种模式解释,技能要求先问一句再动手。这条看似琐碎,实际很关键:完整审计会在磁盘上留下七份产物、几十个 agent 目录,跑错模式不是多花几分钟,而是多花一次完整预算。

选进完整模式后,还要定一个运行画像。画像改变的是广度与冗余,不改变证据门槛——不能因为选了快档就把候选门、执行边界、schema 校验或独立验证砍掉。

画像覆盖粒度猎杀 / 批评门验证
quicksurface × boundary × 攻击类(子系统合并为一个固定标识)恰好 1 波;恰好 1 次终局批评门1 个验证者同时干 Phase 3 与 Phase 5
standard四元组完整多波;每波后 1 次 + 单独保留 1 次 final-cleanPhase 3 与 Phase 5 两道上,人不能复用
deep再按子系统与生命周期模式拆细批评门跑到「干净」为止同上,且同源旧覆盖也要独立第二遍

另有一种「限定范围运行」:只审指定路径或两个 commit 之间的 diff。它必须把自己呈现为部分覆盖,范围外一律记 out_of_scope,绝不能记成「已覆盖」。

03 / 流程

六个阶段,和藏在第二阶段的闭环

阶段是单向的,但「猎杀」内部不是:每一波之后都有一道批评门,直到它连续交出空白。

  1. 1

    侦察 · Reconnaissance

    并行四个只读 agent,各管一件事:产品与本地可运行性、主体与权限、入口与汇点、本地执行与部署可见性。产出的不是散文,是一份初始账本。本阶段不执行任何目标代码。

    architecture.md · coverage-ledger.json 初版 · run-metadata.json
  2. 2

    覆盖化猎杀 · Hunting waves

    按账本单元派猎手,一个猎手只碰自己那几个单元。返回值是一个结构化 JSON:每个单元给 covered / candidate / blocked 之一,附上它读过的路径、做过的检查、以及发现的候选。纯散文的结果没法去重、没法复核,直接不收。

    各单元 checks · candidate 列表 · hardening 备注 · uncovered 提案
  3. 每波之后 · 不是阶段,是门

    批评门 · Coverage critic

    每一波猎杀之后,一个全新的、只读的批评者拿账本问四个问题:有没有整块入口没被映射?有没有并行路径没人走?我选进来的攻击类有没有漏配单元?有没有单元在没留下路径和检查的情况下被关掉?它只提覆盖,不提漏洞。

    missing_units · reassign_ids · stop
  4. 3

    候选验证 · Candidate validation

    每条去重后的候选交给一个没有猎过它的验证者,任务是「试图反驳它」。可以用猎手的产物作参考,但必须重读每一处被引用的源码,并独立复现任何它能安全复现的决定性检查。

    每条候选 → confirmed / needs_validation / rejected
  5. 4

    结构化输出 · Structured output

    父进程把所有判决写进一个 JSON,按指纹排序。三档判决是三个互斥的 schema 分支,字段不许串味——比如 needs_validation 里出现 severity 就是硬错。

    findings.json · 两个校验器 PASS
  6. 5

    记录复核 · Independent record verification

    每一条留下来的结论,再交给一个全新的、只读的验证者。它检查的不是猎手的叙述,而是结构化记录本身:路径、行号、条件、观测结果、严重度与影响是否匹配、修复是否只是把信任挪了个位置。

    verified / replace(替换必须再验一次)
  7. 6

    中立报告 · Target-neutral report

    只从最终记录派生三份 Markdown。报告里不能出现任何「去打这个端点试试」的指引;needs_validation 不得带严重度;被判 rejected 的候选不得被描述成发现。

    REPORT.md · FINDINGS-DETAIL.md · NEEDS-VALIDATION.md
为什么批评门要跑到「干净」为止

因为一次猎杀永远不完整,而「没找到」和「没审」在报告里长得一模一样。批评门是唯一能把这两者区分开的机制:它不产出发现,只产出「还欠哪一格」。这次实战里,前三次批评门分别抓出一块整文件没单元、一条浏览器直写路径没单元、一个每页都加载的渲染函数没单元——三次都是真缺口。

04 / 隔离

写权限隔离:为什么连一条证据都不让 agent 自己保存

核心假设很朴素:一个读过不可信代码的 agent,它的输出就是被污染的输入。

所以整套目录设计不是「整洁」,而是权限边界。父进程独占全部共享产物;每个子代理只有一个自己的 scratch/ 可写;artifacts/ 虽然挂在 agent 名下,但agent 和目标代码都不许写——只有父进程侧的受信代码能把文件「提升」进去。

PARENT · 唯一写者 七个共享产物,别人只读 run-metadata.json architecture.md coverage-ledger.json findings.json REPORT.md FINDINGS-DETAIL.md NEEDS-VALIDATION.md 仅此一处可写共享状态 agents/<id>/scratch/ agent 与目标代码唯一可写区域 退出后即视为「受控内容」 agents/<id>/artifacts/ 父进程持有目录句柄,agent 不可见 本地证据只能从这里被引用 逐文件提升 · 11 步检查 no-follow · nlink=1 · 尺寸稳定 只读引用 目标仓库 全程只读
三方写权限。共享产物只有父进程能写;agent 只能在 scratch/ 里落下东西;能当证据的 artifacts/ 必须由父进程在沙箱与全部子进程退出之后,逐文件走完提升流程。

「逐字节检查」听起来像洁癖,其实每一条都对着一种具体的伪造手法:

为什么是 no-follow

否则 agent 可以在 scratch 里放一个符号链接指向 /etc/passwd 或别的 agent 的目录,提升时就把不该读的东西搬进了证据。

为什么查 nlink === 1

硬链接可以让「你提升的这个文件」和「另一个正在被改写的地方」是同一份 inode。

为什么读完要再 fstat 一次

CHECK 与 USE 之间有时间窗(TOCTOU)。只比对内容哈希与尺寸,才能确认复制过程中文件没被换过。

为什么有 256 KiB / 1 MiB 上限

证据只需要最小可判定片段。没有上限,一个 agent 就能用超大文件把父进程的读路径拖死。

最容易被忽略的一层

沙箱里没有「可信」这回事——目标代码一旦跑起来,它写下的任何字节都要当作攻击者可控。所以技能的措辞是「沙箱与全部进程退出之后」,提升动作发生在进程树之外,用的是父进程事先保留的目录句柄,而不是路径。路径可以被替换,句柄不会。

05 / 账本

覆盖账本:把「我审过了」变成一串可以复算的 ID

这是整套技能里最不像安全、却最决定成败的部分。

账本的基本单位不是文件、不是函数,而是一个四元组合:入口面 × 信任边界 × 子系统 × 攻击类。每个组合被编码成一个稳定的 coverage_id,规则是「百分号编码后拼起来」——不用 slug、不用小写化、不用行号,因为它必须跨运行时保持同一个身份。

四个维度,各取一个「源码派生的规范引用」 SURFACE BOUNDARY SUBSYSTEM ATTACK_CLASS lib/track.js#handleTrack lib/track.js#clientIp lib/track.js ATTACK-CLASSES.md#Business logic RFC 3986 百分号编码(只保留 A-Z a-z 0-9 - . _ ~),再用 :: 拼接 lib%2Ftrack.js%23handleTrack::lib%2Ftrack.js%23clientIp::lib%2Ftrack.js::ATTACK-CLASSES.md%23Business%20logic
身份即坐标。最后一行的字符串是这次真实账本里的一条:它不描述结论,只描述「这一格是哪一格」。所以同一格在下一轮、换个人跑,还是同一个 ID,可以直接对比。

八种状态,一个有意的漏洞

账本里每个单元只允许处在八种状态之一。看起来平平无奇,但它藏了一条很关键的设计:只有 candidate 能携带指纹和未决事实,covered 必须两者都为空。校验器会强制这一点。

planned 已播种,未派工 in_progress 已派给某个 owner covered 有路径、有检查、无遗留 candidate 唯一可挂指纹的状态 blocked 审了,但有决定性未知 deferred / out_of_scope 未派工,必须写明原因 候选被全部推翻后回收 预算 / 画像不允许派工
状态即证据等级。虚线那一步是真的发生过的:这次有 1 个单元先成了 candidate,候选被验证者推翻之后,它才被回收成 covered。如果账本允许「candidate 同时算 covered」,这一步就会被悄悄抹掉。
这条不变式挡住了什么

如果允许 covered 带着未决事实,报告就可以同时说「这一格审完了」和「这一格还有个问题没弄清」。听起来无害,但它把完整性的定义稀释了:53 个格子全都「covered」,其中 7 个其实悬着。现在不行——要么把未决事实挪进 hardening_notes 当加固记录,要么这一格只能是 candidate 或 blocked。

改派:旧证据必须归档,不能继承

批评门可以要求重派一个已经关掉的单元。此时旧证据不是删掉,而是整体归档进 attempts:旧的波次、状态、owner、路径、检查、指纹、未决事实,连同批评者给出的理由一起封存。新的尝试从空证据开始,owner 必须是新人。这条规矩针对的是一种很常见的退化:用上一轮的结论给这一轮背书。

状态owner已读路径 / 检查指纹未决事实
planned空空空空
in_progress有空空空
covered有两者都非空必须为空必须为空
candidate有两者都非空非空可选
blocked有两者都非空(部分证据)空非空
deferred 等空空空非空原因
06 / 判决

三档判决:互斥,不许骑墙

每一档要什么、不许有什么,都写在 schema 里,additionalProperties: false。

判决必须提供绝对不许有严重度
confirmed 根因、应有行为、成立条件、目标原生接口的有界执行与真实观测结果、修复、严重度、置信度 blockers、validation_plan、reason 有
needs_validation 声称的根因、完整 trace、证据、逐条决定性 blocker、至少一个可执行的本地或 owner 观测计划 severity、execution、remediation、已确认根因 无
rejected 声称的根因、trace、证据、推翻它的理由 severity、execution、remediation、blockers、计划 无

最容易搞错的是中间那一档。技能专门写了一句话来钉死它:

needs_validation 意味着一件具体、有源码依据的边界假设被卡住了,而不是一个「置信度较低的已确认漏洞」。 needs_validation means a specific source-grounded boundary hypothesis is blocked, not a low-confidence confirmed vulnerability. —— SKILL.md

所以它不给严重度。不是「不方便给」,是逻辑上不能给:影响还没被证明,任何数字都是编的。它也不能是「暂时想不明白先放这儿」的停车场——每条 blocker 必须是决定性的,且必须已经在源码与本地能力内试过、确实取不到。

严重度锚点

严重度只在 confirmed 上出现,且整体严重度不得超过已演示的影响。判断 high 与 medium 的分界线只有一句:

档锚点
critical未认证者拿到代码执行、整库访问或任意账号接管
high完整击穿一个明示的安全控制且有真实后果:认证绕过、跨租户读写、存储型脚本影响他人、认证后代码执行
medium真实的边界越界,但爆炸半径有限、前置条件不常见,或后果被限制在很窄的资源集合内
low非机密内部信息泄露,或需要持续投入才换来极小收益
info已确认但影响极小,主要作为更大发现的前置条件

分界线可以压缩成一句问话:你演示出来的结果,是完全击穿了这个控制,还是只是削弱了它?说不清具体损害,就该往下调一档。

修复只要一个「最小有效改动」

每条 confirmed 都要指出「代码必须维持的那条不变式」,以及在最后一个受信决策点上强制它的最窄改动。这里有一条隐含的否决线:如果建议的修复只是把信任挪了个位置(比如把检查从服务端挪到界面、从这个函数挪到那个函数),那它反过来说明——原来提的可能只是纵深防御缺口,不是边界失效。这次实战里有两条候选就是因此被判 rejected。

07 / 独立性

独立性:谁不能审谁

这是整套技能里最反人性、也最值钱的一条:结论的可信度不取决于审得多细,而取决于审的人有没有可能是同一个人。

Stage 2猎手提出候选,不评严重度
→
Stage 3候选验证者没猎过这条候选的人
→
Stage 5记录复核者又换一个人,只读记录
→
例外第 4 位验证者只在物质性替换时启用

规则不是「尽量换人」,而是三条硬约束:

  • 猎手不能验证自己的候选。验证者可以读猎手的产物,但必须自己重读每一处被引用的源码——因为「引用的行号对吗」本身就是最常见的第一类错误。
  • Phase 3 与 Phase 5 不能合并(除 quick 画像)。一个检查「这条路径成立吗」,一个检查「这条记录写得对吗」。同一双眼睛看两遍,第二遍会继承第一遍的盲区。
  • 物质性替换必须再过一手。如果 Phase 5 的复核者不只是改措辞,而是升级判决、改根因、改 trace、改执行输入或观测结果、改影响或严重度,那么这个替换不许直接应用——要交给第四位,没猎过、没做过 Phase 3、也没提出这个替换的人,重读源码并独立复现。
为什么「换个人」比「审得细」更重要

因为审计里最贵的错误不是漏看,而是共识性错误:一个错误的前提被写进 trace,后面所有环节都在这个前提上做得很认真。换人只能挡住一部分,但它是唯一能挡住的那部分——前提错了,只有没参与建立前提的人才会去查它。

本轮实战里,这条规则真的触发了一次,也是整场最值得记录的一段(见 下篇第 8 节):Phase 3 把「SDK 附令牌的行为源码不可见」写成了 blocker;Phase 5 的复核者去读源码,发现那份 SDK 就躺在仓库的 node_modules 里,行为完全可离线判定。于是它返回 replace;按规矩,这个替换不能当场生效,必须交给第四位独立验证者再确认一遍。

08 / 边界

沙箱纪律:缺一个控制,就不执行

这条规则让报告变少,但也让它变成真的。

要跑目标代码,必须同时具备四个控制。注意是「同时」——不是「尽量」:

  • 01无外网:需要本地客户端/服务端对话时,只给一个隔离的回环命名空间。
  • 02空环境:从白名单显式构造,scratch 内自带 HOME、临时目录与缓存,绝不继承宿主环境变量。
  • 03只读目标与工具链:目标受控进程只能写自己那份 scratch/。
  • 04显式低资源与墙钟上限:CPU、内存、进程数、文件大小、磁盘、时间。

任何一项无法强制,结论都一样:不要执行,把缺失的能力写成 blocker,走 needs_validation。这条不是纸面规则——这次在 macOS 上实测,四项里三项直接落空:

控制本机实测后果
OS 强制沙箱sandbox-exec 存在,但每次调用都 sandbox_apply: Operation not permitted无法建立隔离根
网络隔离不可强制不能跑需要起服务的检查
只读挂载不可强制目标代码理论上可写宿主路径
磁盘 / 内存上限无 ulimit -v;可用 env -i、ulimit -t/-f/-c、父进程墙钟只能做到部分限制
于是本轮的能力边界被这样收缩

目标受控执行只剩两类:仓库自带的离线 node --test 套件,以及无 IO、无服务进程、无监听 socket 的纯函数 harness。凡是需要「本地已应用迁移的 Postgres」「真实浏览器」「真实电子表格求值」的检查,一律不执行,改记 needs_validation 并把缺失能力逐条写明。

这里有个容易被误解的推论:沙箱缺件不等于放弃候选。源码路径依然成立,只是「结果」还证明不了——所以它不是被删掉,而是从 confirmed 降级成 needs_validation,并附上一份不会给目标环境添麻烦的验证方案。这次三条待验证线索,就是这么来的。

09 / 收尾

机器收尾:两个校验器,与它们不能证明的事

审计不能靠「我读了一遍觉得没问题」结束。

因为结论是 JSON,所以它可以被机器检查。技能自带两个校验器,在 Phase 4 之后、Phase 6 之前各跑一次,报错必须清零才能继续。

validate-findings.cjs

记录的结构与语义

三档分支是否互斥、必填字段是否齐、trace 是否恰好一个 entrypoint、一个 sink、中间只能是 propagation、路径是否仓库相对、指纹是否合规、严重度是否越界。超 5 MiB / 1000 条 / 64 层直接拒。

validate-coverage-ledger.cjs

覆盖声明与记录是否对得上

ID 是否重复、状态与字段组合是否合法、covered 是否真的清空了指纹与未决事实、local 检查的 artifact 是否恰好落在该检查自己 owner 的 artifacts 下、候选指纹是否都能在 findings 里找到归属。

但要有清醒的认识,技能自己就说了:

校验器通过,只证明格式与账本一致性。 Validator success proves format and ledger consistency only. —— VALIDATION-AND-REPORTING.md

它不证明代码安全,不证明没有遗漏,也不证明报告好读。它只保证一件事:结论和覆盖声明之间没有自相矛盾。剩下的事,靠批评门、独立验证和人来判断。

顺便说,这也是为什么整套流程有两个互斥的终态——要么报告三件套写完且两个校验器都过,要么明确写 run_status: incomplete 加一条精确原因。不允许「差不多跑完了就停」。中途停下不算收尾,只能算中断。

PART 02 实战:在 research-share 上跑一遍 53 个单元、32 个代理、9 条结论,以及一次被当场推翻的错误前提。
10 / 对象

被审对象的形状:两条写路径,两种授权强度

在看结论之前,先看这个系统把信任边界画在了哪。

research-share 是一个 Node ESM 项目:用 node:http 自己做 SSR,数据放在 Postgres,授权靠 RLS 加 SECURITY DEFINER 函数。它的架构基线写得很清楚——db/03 的文件头直白地说:应用层可以被绕过,数据库层不能。

这句话决定了整个审计的重心。因为一旦「授权只在库侧」,那就必须回答一个问题:是不是所有写路径都真的走到了库侧的判定?

浏览器 匿名访客 · 登录用户 管理员 Agent 调用方 持有一把 Bearer token 文档支持多把 token 并存 Node ESM · node:http · SSR GET /item/:id → SSR 详情页 POST /api/track → 匿名埋点 POST/PATCH/DELETE /api/items* → Agent RPC GET /admin → 管理看板 埋点是唯一的匿名写入口 其余写操作靠令牌或库侧策略 Postgres RLS 策略:research_items / admins / analytics_exclude SECURITY DEFINER:is_admin / is_founder / agent_* GRANT 层:anon 对 research_items 只有 SELECT 两条写路径走到这里,授权强度不一样 —— 这是本轮所有结论的舞台 读页面 / 触发 SSR / 埋点 POST /api/items · Bearer token 浏览器直连数据面 RLS 是唯一强制点 RPC 写函数 SECURITY DEFINER RLS 不生效 全仓无 FORCE RLS SSR 读取 / 埋点写入
一句话看懂这个系统的风险面。浏览器写路径的授权强度是「属主或管理员」(RLS 双向同判);而 Agent 的写函数是 SECURITY DEFINER,以表 owner 身份执行,RLS 对它整个不生效——浏览器路径的强度没有被带到 RPC 路径上。
为什么先画图而不是先读代码

因为「两条路径对同一个资源做同一件事,但强度不同」这类缺陷,只在把两条路径并排画出来的时候才会显形。逐函数读代码永远读不出来:每个函数单看都是对的。

11 / 开销

这次运行的开销:钱花在哪

说「完整审计很贵」没有意义,把它拆开看。

run id
research-share-run-2-20261024
画像 / 预算
standard(完整 6 阶段)· 未设预算门 · 无因预算被迫中断的项
source ref
90deace3 @ main,工作树脏(5 个未提交文件,按当前形态纳入范围)
执行边界
sandboxed-source-and-local-only:只读仓库 + 受限本地执行,禁止触任何已部署端点
前序 run
独立全量重跑。run-1 停在 Phase 2 之前(30 个单元全 planned、无 findings.json),因此它的 18 个候选没有被当作锚定样例传下去
终态
run_status: complete · coverage_complete: true
32 个子代理目录,每格 = 1 个 agent 猎手 11 批评门 4 Phase 3 验证 9 Phase 5 复核 8 每个角色都是独立实例:同一条记录不会被同一个 agent 碰两次。 Phase 5 的 8 个里,有 1 个是专为复核「物质性替换」而临时增加的第四位验证者。 Phase 1 的侦察 agent 只回传事实、不写文件,因此没有目录,也不计入这个数字。
消耗结构。真正花时间的是「重复」:11 个猎手只读一遍代码,剩下的 21 个 agent 几乎都在复查别人已经读过的代码。这就是这类审计贵的原因,也是它敢下结论的原因。
12 / 覆盖

覆盖账本实况:53 格分布在哪些攻击类上

账本不是统计表,它是「这次到底审了什么」的唯一凭证。

0 4 8 12 访问控制 注入 业务逻辑 功能滥用与数据泄漏 资源与文件处理 显见疏漏 通配 / 兜底 链式与信任边界 密码学与密钥 13 11 6 5 5 4 3 3 3
九类、53 格。中文为本文译名,对应 ATTACK-CLASSES.md 的九个普通攻击类。访问控制和注入占了近一半,符合一个「有登录、有管理后台、有富文本展示」的 SSR 应用的形状。

格子不是凭空播种的。侦察阶段按「入口面 × 边界 × 子系统 × 攻击类」穷举,之后每一波都要过批评门。这次四道批评门抓到的缺口,全都是真的会漏东西的那种:

批评门抓到了什么为什么必须补
第 1 道assets/analytics.js 整文件没有单元37 个 innerHTML 汇点,且由 admin.html 加载——整整一个渲染面没被审
第 1 道assets/admin.js#rowHtml 被重派它的检查断言超出了自己声明的已读路径:证据撑不住结论
第 2 道assets/data.js#excludeVisitor / includeVisitor 没有单元这是浏览器直写 analytics_exclude 的路径,整条没人走
第 3 道assets/auth-chip.js#render 没有单元它经 lib/render.js:102 出现在每个非 demo 公开页面上
final-clean无缺失单元独立入口清单无未映射组合;37 个 covered 单元的 333 项检查逐条与已读路径比对

也有一次偏差值得记下来。第 1 道批评门提出一个标识符 drawF,父进程查证时没找到,判为工具假阴性并换成了真实导出名 window.AnDash(assets/analytics.js:622)。到 final-clean 时才发现:drawF 确实存在(:417),只是被同一文件里已经接受的 AnDash 覆盖住了,所以覆盖没有丢。

这条小插曲的意义

它说明两层检查的价值不在「都对」,而在「错的那一层会被另一层兜住」。如果只有批评门,一个不存在的标识符就会被写进账本;如果只有父进程,drawF 就永远不会被补上(虽然这次它恰好被覆盖了)。

146

账本里记录在案的检查项,每一项都带自己的 owner 与已读路径。

47

去重后被实际读过的仓库相对路径。

11

不同的单元归属猎手——没有一个 agent 独占某个子系统。

13 / 漏斗

九条候选的去向:4 / 3 / 2

从 53 个格子收窄到 9 张指纹,再分叉成三种命运。

53 个覆盖单元 账簿里的全部格子,每格都有 owner、路径与检查 14 个单元挂上了候选 其余 38 格 covered、1 格 blocked 9 条去重候选 同一根因合并为同一枚指纹 4 confirmed 有界复现 + 观测结果 带严重度 · 给修复方案 3 needs_validation 卡在决定性未知 无严重度 · 给观测方案 2 rejected 被源码推翻 不是发现 · 但保留记录 同一批候选,三种命运。被推翻也是一种产出。
收窄是逐级的。53 → 14 是「哪些格子发现了问题」,14 → 9 是「哪些问题其实是同一个根因」,9 → 4 是「哪些真的能演示出来」。每一级都会掉东西,每一级都必须留下记录。
14 / 结论

四条坐实结论

全部来自源码 + 纯函数 harness + 仓库自带离线测试。没有一条依赖对部署环境的探测。

high

Agent token 授权无资源范围绑定

agent-api:token-unbound-resource-scope

一把有效 token 只能证明「你是谁」,完全不能约束「你能碰谁的资源」。四个写 RPC 都是 SECURITY DEFINER,以表 owner 身份执行,而全仓没有 FORCE ROW LEVEL SECURITY——于是浏览器路径那条「属主或管理员」的强度,在 RPC 路径上根本不存在。

同一条 socket、同一把 token(alpha):
GET /api/items 无凭据也能 200,列出全部 id/source_key/source_agent
upsert → 200 updated id=102(agent-beta 的行被改写,来源归属被改)
batch → 200 updated=1, created=1  ·  PATCH → 200 改写浏览器用户的行
DELETE → 200 硬删除(无回收站)
负面对照:无效 token → 401 invalid_token;缺 token → 401 missing_token
边界
lib/agent-api.js 的四个写 RPC → db/02-agent-api.sql#agent_check_token / db/06-doc-items.sql
定级依据
完整击穿了一个明示的控制(按主体隔离数据),后果是全表可写、删除不可逆。没有评 critical,因为没有证明管理员越权、代码执行,也没有超出已公开的读范围
最小修复
把 token 绑定到资源范围,并在 SQL 函数谓词里强制(三处 code_changes)。只改 lib/agent-api.js 等于把信任挪了个位置;存量行要按 source_agent 回填 owner_id 后才能启用谓词
medium

匿名埋点配额以可伪造的 X-Forwarded-For 为键

track/anon-write-quota-keyed-on-spoofable-xff

匿名写入唯一的量控是 ipOverLimit,它的键来自 clientIp(),而 clientIp() 信任请求头里的 XFF。攻击者不需要任何凭据,只要每次请求换一个 XFF,就等于每次都换一个「新配额」。

对照组:200 请求 / 单 socket / 无 XFF → rpc 写 300 次(仅 60 个请求穿过,TRACK_RATE_MAX=60)
攻击组:同样 200 请求 / 同 socket / 200 个不同 XFF → rpc 写 1000 次,全部穿过
两组真实 socket 与请求数完全相同,唯一差别是 XFF;每请求无论超限与否都返回 204
边界
lib/track.js#clientIp / ipOverLimit → server.js 的匿名路由
定级依据
完全击穿了唯一的写量控制,但单请求仍有 20 事件 / 16KB 的上界,所以是「无上界地持续注入」而不是单点放大;对真实 Postgres 的库耗竭只是合理推断,未本地演示
最小修复
把代理信任显式化:默认取直连 socket 对端,确认位于受信代理之后才取该代理从右侧追加的那一跳,而不是攻击者能 prepend 的最左段
medium

埋点 ip_hash 使用仓库里公开的默认盐

track/ip-hash-reversible-default-salt

访客 IP 经过 sha256(salt + ip) 存成 ip_hash。可是默认盐是源码里的一个字面量 'research-share/v1/ip',.env.example 里没有 ANALYTICS_SALT,而 README 又教人 cp .env.example .env——于是「默认部署」几乎必然使用公开盐。

203.0.113.42 → 71e9cf913030c907371df85b6a78ce6e(与猎手观测逐位一致)
/24 内枚举 43 个候选即命中;同一张表反演第二行成功;错盐对照无命中
实测 300,000 次哈希 0.151s ≈ 1,990,101 sha256/s
算术外推:全量可路由 IPv4 ≈ 0.52 核小时(未运行全量扫描)
边界
server.js 的默认盐 → lib/track.js#hashIp → research_events.ip_hash
定级依据
读取前提把它封顶在「已经能读原始事件表」的角色(运营方 / service_role / 备份)。不等于提权,但它把匿名哈希升级为 IPv4 明文,并直接违反代码注释与文档里的法定性陈述
最小修复
盐改为强制显式配置的每部署秘密(缺失即拒绝启动)+ 输出 32 → 64 hex + 同步修正三处失实陈述。记录里也写明:同时掌握 .env 的部署者仍可反演,若隐私承诺要对运营方成立,唯一彻底方案是停存可逆变换
low

SSR 兜底 page_view 不读退出统计开关

research-share/ssr-pageview-ignores-optout-cookie

客户端埋点老老实实检查 rs_optout,服务端则完全没接线:trackServerPageView 上来就写,不看 cookie。于是「退出统计」这个明示承诺,只在浏览器路径上成立。

三组 dummy cookie(未退出 / 已退出带 visitor id / 已退出无 visitor id)
服务端均恰好发起 1 次 track_event:p_event=page_view、p_source=server
已退出两组与对照组字段完全一致;同一开关下客户端路径 0 次发送
harness 25/25 断言通过;仓库自带 39 项埋点测试本环境全绿
边界
lib/render.js 写 rs_optout cookie → assets/track.js 客户端守卫生效 → 服务端 trackServerPageView 未接同一开关 → db/03#track_event
定级依据
受害主体是已行使退出权的访客本人,写入内容仍限于站方已披露的匿名统计口径(无原始 IP、无 UA 原文、不出域),读取方只有管理员看板——没有扩大数据类别,也没有引入新的读取方
最小修复
在 trackServerPageView 入口按同一键名跳过写库,键名继续从 lib/constants.js 单一来源 import(与客户端同源),避免两处字面量漂移
15 / 待验证

三条待验证线索

这一栏不是「低置信度的漏洞」,也没有严重度。它们是:源码路径成立、但决定性事实在本地取不到。

线索决定了什么卡在哪
N1 SSR 详情页的 url 未中和 scheme
render.js-detailFoot-href-scheme-unvalidated
存储型 javascript: 链接是否会落进 href 并在点击时执行 ① 本宿主禁用浏览器验证,无法观测点击行为;② 部署侧边缘 WAF 是否会 403 掉含 javascript: 的写入,属部署事实;③ 边缘是否另注入 CSP 未知
N2 管理端 CSV 导出的公式注入
assets/admin.js:exportCsv/csvCell-formula-injection
是「完整链可靠复现」「弱化版」还是「仅纵深防御缺口」 缺真实电子表格程序的求值行为观测(Protected View / DDE / Mark-of-the-Web 的默认态),源码与本地沙箱都判不了
N3 浏览器四条写路径的应用层零授权
research-share/browser-write-authorization-solely-rls-needs-live-attachment
这条路径上 RLS 是不是真的唯一且有效的强制点 ① 没有可执行且已应用迁移的本地 Postgres;② 仓内实测只覆盖 anon 侧 GRANT 与非管理员 SELECT,从没有以「已认证非特权主体」打过这四条写路径;③ 服务端边缘的 JWT→角色映射不可观测
N3 里的一句话被改过

这条记录最初把「SDK 附令牌的行为源码不可见」列为决定性 blocker。这句话是错的——那份 SDK 就在仓库的 node_modules 里。它是怎么被发现的、以及为什么不能当场改,正是下一节要讲的事。

16 / 推翻

两条被推翻的候选:推翻也是产出

按纪律,它们不得被描述成发现。但它们必须留在记录里,否则下一轮会重新提一遍同样不成立的指控。

Rejected · 理由:没有实际影响

null session 可以绕过库层配额

db/03-analytics.sql:track_event:null-session-…

漏洞路径是真的:省略 session 字段时 p_session = null,库层的 session_cap 与 debounce 整体跳过。但攻击者本来就可以自己铸一个格式合法的 session_id 并逐请求轮换,效果完全一样——null session 没有给出任何额外能力,它是更弱、等价的冗余面。

所以它按「无实际影响」判 rejected,而不是 needs_validation:它的三条 blocker 一个都不决定性。

Rejected · 理由:没有跨主体受害者

updateItem 的 doc_html 没有列白名单

research-share/browser-updateItem-patch-…

trace 是准确的(唯一更正是 ADR 文件名)。但能改写这一行的只有该行 owner 或管理员——受影响的正是行为者本人。而「托管文档里的 HTML 可变」这件事,ADR-0004 已经明言接受,下游用 CSP: sandbox allow-scripts(不给 allow-same-origin)把执行隔离在不透明源里。

建议的修复(数据面加字段白名单)只是把信任从 UI 挪到数据面,属于纵深防御。

这两条被推翻,说明上游对它们的具体反驳是有效的。而下面这一段,是反过来发生的:上游写下的一个前提被推翻了。

17 / 一课

最值得记住的一课:连 blocker 本身也要被证伪

这是这套流程最不像「自动化」、也最接近真实工程的一段。

N3 这条线索的结论是「结论待定」,如果到此为止,它只是一条普通的待验证记录。真正有意思的是它被返工的过程。

  1. 1

    Phase 3 的验证者写下 blocker 3

    「workbuddy-cloud-sdk 的令牌附着行为源码不可见」,因此在本地无法判定浏览器写请求到底带不带用户身份。

  2. 2

    Phase 5 的复核者不同意,并给出了行号

    它去读了 node_modules/@tencent-ai/workbuddy-cloud-sdk/lib/index.js:81-96:createCloudFetch 恒设发布键头,且当请求没有 Authorization 时会取 access token 并以 Bearer 附上;:8249-8251 把 database 装配成带同一取令牌函数的 fetch。浏览器侧还有一份同源孪生 assets/vendor/workbuddy-cloud-sdk.global.js,由 admin.html 引入,语义逐字等价。于是它返回 replace。

  3. 3

    父进程独立复核同一处源码

    不带立场地把那段打印出来对照,确认 Phase 5 说得对:这份 SDK 就在仓库里,行为可以离线判定。

  4. 4

    但按规则,不能直接应用

    这不是改措辞——它改变了一条决定性 blocker 的成立与否,属于物质性改动。规则要求:交给第四位,既没猎过、没做过 Phase 3、也没提出这个替换的人。

  5. 5

    第四位验证者重读源码,返回 verified

    它独立复现了那条结论,没有引用前两位的结论作为依据。

  6. 6

    应用 → 重跑两个校验器 → 收窄 blocker

    blocker 3 从「SDK 行为不可见」收窄为「服务端边缘如何把 JWT 声明映射成 authenticated / anon 角色」。这才是真正本地取不到的那件事。

为什么这一段值得单独讲

因为整套技能的默认怀疑方向是「猎手的结论可能夸大」,而这个案例里出错的是过度保守的那一侧:验证者把一个本可离线判定的问题,写成了「不可观测」。如果没人去读那份 node_modules,这条线索会以一个虚假的 blocker 交到 owner 手上——owner 会去查一个根本不需要查的东西。

而「换人复核」的价值也在这里显形:同一个人不会推翻自己写下的前提。

同一轮还顺手改掉了 4 处非物质错误,它们不改变判决、根因、trace 或影响,所以允许直接应用:批量请求的载荷补上漏掉的第二条 item;两处测试文件名写错(test/track.test.js → test/track-handler.test.js,后者才是那条 seam 所在,两个文件都存在所以证据本身不变);CSV 载荷长度从含混的「约 50 字符」精确成 49(evil.example)与 52(example.invalid)。每次改完都重跑两个校验器,全部 PASS。

18 / 边界

这一轮证明了什么,没证明什么

证明了

四条边界失效真的存在,且能在源码 + 纯函数 harness 里复现;账本的 53 格确实都被指派过 owner 并留下了检查;报告的每一条结论都能被另一台机器用同样的源码复算。

没证明

这个项目「没有别的漏洞」。一次运行永远不等于穷尽;三档判决里的 needs_validation 恰恰是承认还欠着事实。

  1. confirmed 全部来自离线证据。源码 + 纯函数 harness + 仓库自带测试;没有任何一条依赖对部署环境的探测。这既是优点(可复算)也是限制(库侧真实行为未验证)。
  2. 需要本地 Postgres 的判断没做。N3 与那个 blocked 单元的写放大量化,都卡在这里,已按 needs_validation / blocked 记录并给出 owner 侧只读观测方案。
  3. 需要真实浏览器、真实电子表格的判断没做。N1 与 N2 的观测方案都设计成 owner 在自己机器上用 dummy 数据完成,不含任何发往部署环境的审计流量。
  4. 全量 IPv4 反演没跑。实测外推 0.52 核小时后按纪律停止,只用 /24 内的最小枚举证明「可反演」这件事本身。
执行边界的一处自查

元数据最初只记了 2 个脏文件,final-clean 批评门独立测得 5 个。父进程重跑 git status --porcelain 确认,并对三个新增文件逐一 diff 复核:一个是 SQL 重载切换(安全相关,已纳入单元)、一个是新增的健康检查路由(安全相关)、三个是忽略规则 / 文档措辞 / CSS 特异性修复(无可执行面)。审计报告自己的元数据也要被审。

附录

一页速查

你的处境该用哪种模式预期代价
「这段代码有没有安全问题?」guidance,只取相关章节几轮对话,不落盘
「帮我把这个项目完整审一遍,要出报告」full audit + standard几十次委派,七份产物
「只审这次 diff / 只审这个模块」full audit + scoped约为完整的一小部分;报告必须自称部分覆盖
「先快速看一眼值不值得深挖」full audit + quick1 波猎杀 + 1 次终局批评门
「高风险系统,别怕花时间」full audit + deep按子系统与生命周期拆细,批评门跑到干净

最常见的四种误用

把 needs_validation 当成「低危漏洞」

它没有严重度不是疏忽,是逻辑上不能给:影响尚未证明。把它写进漏洞清单会让 owner 按错误的优先级排期。

没有沙箱却照样跑目标代码

技能要求四项控制同时具备。缺一项就跑,等于在一个可能写宿主、可能连外网的环境里执行不可信代码——这本身就是安全事故。

用「审了多少文件」当覆盖率

覆盖的单位是四元组,不是文件。一个文件读完不等于它的每个边界 × 每个攻击类都被走过。

让同一个人验证自己的发现

这是最省事、也最毁掉整份报告可信度的一步。报告的价值等于「有多少结论是别人独立确认过的」。

产物与文件索引

技能本体
~/.workbuddy/skills/security-audit/(SKILL.md + 11 个域伴读文件 + 2 个校验器)
本次运行
~/security-audit-skill/research-share/run-2/
结论记录
findings.json — 9 条,双校验器 PASS
覆盖声明
coverage-ledger.json — 53 个单元,PASS
报告三件套
REPORT.md · FINDINGS-DETAIL.md · NEEDS-VALIDATION.md
架构摘要
architecture.md — 供每个猎手逐字带进上下文

本页是上述产物与运行过程的二次叙述,不是审计结论本身。任何判决、严重度、blocker 的权威来源都是 findings.json;如果两者不一致,以 JSON 为准。页面为单文件、零外部依赖,可离线打开。