它到底在解决什么问题
普通的「扫一遍」失败,通常不是漏了某个函数,而是三件更基础的事没做。
清单式体检
照着 CWE 一条条勾:有没有 eval、有没有字符串拼 SQL。可是真实漏洞长在两条各自正确的代码接缝处——授权在 A 层做,数据在 B 层取,中间没有人负责对齐。
替部署环境猜答案
「应该前面有 WAF」「JWT 应该会被网关解析成 authenticated」。这些假设没有一条在源码里,可结论全押在上面——猜错一次,结论就反了。
自证
找漏洞的人写报告,写报告的人评严重度。于是「严重」变成了自我感觉的量词,而报告里没有任何一处能被别人独立复算。
这套技能的反向定义非常硬——它把「什么不算 finding」写在了第一步:
顺着这句话,整套设计都是它的推论。既然要指名主体和资源,就必须先把信任边界画出来;既然要有具体后果,就必须在有界范围内真的跑一次;既然结论要能被别人复算,就必须有一本记录每个格子审到哪一步的账。
| 它要防的 | 对应的机制 | 机器可查的落点 |
|---|---|---|
| 漏审,却宣称审完了 | 确定性覆盖账本,一个单元一格 | coverage-ledger.json + 校验器 |
| 把猜测写成结论 | 三档判决互斥,缺证据只能进 needs_validation | findings.json 分支 schema |
| 自己给自己打分 | 验证者必须是没参与过的人 | 账本里每个 check 记 agent_id |
| 在真实环境里动手 | 缺一个沙箱控制就不执行 | run-metadata.json 的执行边界字段 |
两种模式,两种代价
加载这个技能不等于授权跑完整审计。它默认只提供方法论。
只借方法,不动文件
回答「这段代码安全吗」「这条告警怎么判」「帮我 triage 一下」。只取相关章节,不建输出目录、不写审计产物、不自动跑六阶段。
代价:几轮对话。
六阶段,七份产物
用户明确说「审计 / 渗透 / 完整 / 端到端 / 出报告」才启动。要跑完六个阶段,产出七个共享文件,并通过两个校验器。
代价:这次在 research-share 上花了 32 次子代理委派。
如果一句话能被两种模式解释,技能要求先问一句再动手。这条看似琐碎,实际很关键:完整审计会在磁盘上留下七份产物、几十个 agent 目录,跑错模式不是多花几分钟,而是多花一次完整预算。
选进完整模式后,还要定一个运行画像。画像改变的是广度与冗余,不改变证据门槛——不能因为选了快档就把候选门、执行边界、schema 校验或独立验证砍掉。
| 画像 | 覆盖粒度 | 猎杀 / 批评门 | 验证 |
|---|---|---|---|
quick | surface × boundary × 攻击类(子系统合并为一个固定标识) | 恰好 1 波;恰好 1 次终局批评门 | 1 个验证者同时干 Phase 3 与 Phase 5 |
standard | 四元组完整 | 多波;每波后 1 次 + 单独保留 1 次 final-clean | Phase 3 与 Phase 5 两道上,人不能复用 |
deep | 再按子系统与生命周期模式拆细 | 批评门跑到「干净」为止 | 同上,且同源旧覆盖也要独立第二遍 |
另有一种「限定范围运行」:只审指定路径或两个 commit 之间的 diff。它必须把自己呈现为部分覆盖,范围外一律记 out_of_scope,绝不能记成「已覆盖」。
六个阶段,和藏在第二阶段的闭环
阶段是单向的,但「猎杀」内部不是:每一波之后都有一道批评门,直到它连续交出空白。
- 1
侦察 · Reconnaissance
并行四个只读 agent,各管一件事:产品与本地可运行性、主体与权限、入口与汇点、本地执行与部署可见性。产出的不是散文,是一份初始账本。本阶段不执行任何目标代码。
architecture.md · coverage-ledger.json 初版 · run-metadata.json - 2
覆盖化猎杀 · Hunting waves
按账本单元派猎手,一个猎手只碰自己那几个单元。返回值是一个结构化 JSON:每个单元给
各单元 checks · candidate 列表 · hardening 备注 · uncovered 提案covered / candidate / blocked之一,附上它读过的路径、做过的检查、以及发现的候选。纯散文的结果没法去重、没法复核,直接不收。 -
每波之后 · 不是阶段,是门
批评门 · Coverage critic
每一波猎杀之后,一个全新的、只读的批评者拿账本问四个问题:有没有整块入口没被映射?有没有并行路径没人走?我选进来的攻击类有没有漏配单元?有没有单元在没留下路径和检查的情况下被关掉?它只提覆盖,不提漏洞。
missing_units · reassign_ids · stop - 3
候选验证 · Candidate validation
每条去重后的候选交给一个没有猎过它的验证者,任务是「试图反驳它」。可以用猎手的产物作参考,但必须重读每一处被引用的源码,并独立复现任何它能安全复现的决定性检查。
每条候选 → confirmed / needs_validation / rejected - 4
结构化输出 · Structured output
父进程把所有判决写进一个 JSON,按指纹排序。三档判决是三个互斥的 schema 分支,字段不许串味——比如 needs_validation 里出现
findings.json · 两个校验器 PASSseverity就是硬错。 - 5
记录复核 · Independent record verification
每一条留下来的结论,再交给一个全新的、只读的验证者。它检查的不是猎手的叙述,而是结构化记录本身:路径、行号、条件、观测结果、严重度与影响是否匹配、修复是否只是把信任挪了个位置。
verified / replace(替换必须再验一次) - 6
中立报告 · Target-neutral report
只从最终记录派生三份 Markdown。报告里不能出现任何「去打这个端点试试」的指引;needs_validation 不得带严重度;被判 rejected 的候选不得被描述成发现。
REPORT.md · FINDINGS-DETAIL.md · NEEDS-VALIDATION.md
因为一次猎杀永远不完整,而「没找到」和「没审」在报告里长得一模一样。批评门是唯一能把这两者区分开的机制:它不产出发现,只产出「还欠哪一格」。这次实战里,前三次批评门分别抓出一块整文件没单元、一条浏览器直写路径没单元、一个每页都加载的渲染函数没单元——三次都是真缺口。
写权限隔离:为什么连一条证据都不让 agent 自己保存
核心假设很朴素:一个读过不可信代码的 agent,它的输出就是被污染的输入。
所以整套目录设计不是「整洁」,而是权限边界。父进程独占全部共享产物;每个子代理只有一个自己的 scratch/ 可写;artifacts/ 虽然挂在 agent 名下,但agent 和目标代码都不许写——只有父进程侧的受信代码能把文件「提升」进去。
scratch/ 里落下东西;能当证据的 artifacts/ 必须由父进程在沙箱与全部子进程退出之后,逐文件走完提升流程。「逐字节检查」听起来像洁癖,其实每一条都对着一种具体的伪造手法:
为什么是 no-follow
否则 agent 可以在 scratch 里放一个符号链接指向 /etc/passwd 或别的 agent 的目录,提升时就把不该读的东西搬进了证据。
为什么查 nlink === 1
硬链接可以让「你提升的这个文件」和「另一个正在被改写的地方」是同一份 inode。
为什么读完要再 fstat 一次
CHECK 与 USE 之间有时间窗(TOCTOU)。只比对内容哈希与尺寸,才能确认复制过程中文件没被换过。
为什么有 256 KiB / 1 MiB 上限
证据只需要最小可判定片段。没有上限,一个 agent 就能用超大文件把父进程的读路径拖死。
沙箱里没有「可信」这回事——目标代码一旦跑起来,它写下的任何字节都要当作攻击者可控。所以技能的措辞是「沙箱与全部进程退出之后」,提升动作发生在进程树之外,用的是父进程事先保留的目录句柄,而不是路径。路径可以被替换,句柄不会。
覆盖账本:把「我审过了」变成一串可以复算的 ID
这是整套技能里最不像安全、却最决定成败的部分。
账本的基本单位不是文件、不是函数,而是一个四元组合:入口面 × 信任边界 × 子系统 × 攻击类。每个组合被编码成一个稳定的 coverage_id,规则是「百分号编码后拼起来」——不用 slug、不用小写化、不用行号,因为它必须跨运行时保持同一个身份。
八种状态,一个有意的漏洞
账本里每个单元只允许处在八种状态之一。看起来平平无奇,但它藏了一条很关键的设计:只有 candidate 能携带指纹和未决事实,covered 必须两者都为空。校验器会强制这一点。
如果允许 covered 带着未决事实,报告就可以同时说「这一格审完了」和「这一格还有个问题没弄清」。听起来无害,但它把完整性的定义稀释了:53 个格子全都「covered」,其中 7 个其实悬着。现在不行——要么把未决事实挪进 hardening_notes 当加固记录,要么这一格只能是 candidate 或 blocked。
改派:旧证据必须归档,不能继承
批评门可以要求重派一个已经关掉的单元。此时旧证据不是删掉,而是整体归档进 attempts:旧的波次、状态、owner、路径、检查、指纹、未决事实,连同批评者给出的理由一起封存。新的尝试从空证据开始,owner 必须是新人。这条规矩针对的是一种很常见的退化:用上一轮的结论给这一轮背书。
| 状态 | owner | 已读路径 / 检查 | 指纹 | 未决事实 |
|---|---|---|---|---|
planned | 空 | 空 | 空 | 空 |
in_progress | 有 | 空 | 空 | 空 |
covered | 有 | 两者都非空 | 必须为空 | 必须为空 |
candidate | 有 | 两者都非空 | 非空 | 可选 |
blocked | 有 | 两者都非空(部分证据) | 空 | 非空 |
deferred 等 | 空 | 空 | 空 | 非空原因 |
三档判决:互斥,不许骑墙
每一档要什么、不许有什么,都写在 schema 里,additionalProperties: false。
| 判决 | 必须提供 | 绝对不许有 | 严重度 |
|---|---|---|---|
| confirmed | 根因、应有行为、成立条件、目标原生接口的有界执行与真实观测结果、修复、严重度、置信度 | blockers、validation_plan、reason | 有 |
| needs_validation | 声称的根因、完整 trace、证据、逐条决定性 blocker、至少一个可执行的本地或 owner 观测计划 | severity、execution、remediation、已确认根因 | 无 |
| rejected | 声称的根因、trace、证据、推翻它的理由 | severity、execution、remediation、blockers、计划 | 无 |
最容易搞错的是中间那一档。技能专门写了一句话来钉死它:
所以它不给严重度。不是「不方便给」,是逻辑上不能给:影响还没被证明,任何数字都是编的。它也不能是「暂时想不明白先放这儿」的停车场——每条 blocker 必须是决定性的,且必须已经在源码与本地能力内试过、确实取不到。
严重度锚点
严重度只在 confirmed 上出现,且整体严重度不得超过已演示的影响。判断 high 与 medium 的分界线只有一句:
| 档 | 锚点 |
|---|---|
| critical | 未认证者拿到代码执行、整库访问或任意账号接管 |
| high | 完整击穿一个明示的安全控制且有真实后果:认证绕过、跨租户读写、存储型脚本影响他人、认证后代码执行 |
| medium | 真实的边界越界,但爆炸半径有限、前置条件不常见,或后果被限制在很窄的资源集合内 |
| low | 非机密内部信息泄露,或需要持续投入才换来极小收益 |
| info | 已确认但影响极小,主要作为更大发现的前置条件 |
分界线可以压缩成一句问话:你演示出来的结果,是完全击穿了这个控制,还是只是削弱了它?说不清具体损害,就该往下调一档。
修复只要一个「最小有效改动」
每条 confirmed 都要指出「代码必须维持的那条不变式」,以及在最后一个受信决策点上强制它的最窄改动。这里有一条隐含的否决线:如果建议的修复只是把信任挪了个位置(比如把检查从服务端挪到界面、从这个函数挪到那个函数),那它反过来说明——原来提的可能只是纵深防御缺口,不是边界失效。这次实战里有两条候选就是因此被判 rejected。
独立性:谁不能审谁
这是整套技能里最反人性、也最值钱的一条:结论的可信度不取决于审得多细,而取决于审的人有没有可能是同一个人。
规则不是「尽量换人」,而是三条硬约束:
- 猎手不能验证自己的候选。验证者可以读猎手的产物,但必须自己重读每一处被引用的源码——因为「引用的行号对吗」本身就是最常见的第一类错误。
- Phase 3 与 Phase 5 不能合并(除
quick画像)。一个检查「这条路径成立吗」,一个检查「这条记录写得对吗」。同一双眼睛看两遍,第二遍会继承第一遍的盲区。 - 物质性替换必须再过一手。如果 Phase 5 的复核者不只是改措辞,而是升级判决、改根因、改 trace、改执行输入或观测结果、改影响或严重度,那么这个替换不许直接应用——要交给第四位,没猎过、没做过 Phase 3、也没提出这个替换的人,重读源码并独立复现。
因为审计里最贵的错误不是漏看,而是共识性错误:一个错误的前提被写进 trace,后面所有环节都在这个前提上做得很认真。换人只能挡住一部分,但它是唯一能挡住的那部分——前提错了,只有没参与建立前提的人才会去查它。
本轮实战里,这条规则真的触发了一次,也是整场最值得记录的一段(见 下篇第 8 节):Phase 3 把「SDK 附令牌的行为源码不可见」写成了 blocker;Phase 5 的复核者去读源码,发现那份 SDK 就躺在仓库的 node_modules 里,行为完全可离线判定。于是它返回 replace;按规矩,这个替换不能当场生效,必须交给第四位独立验证者再确认一遍。
沙箱纪律:缺一个控制,就不执行
这条规则让报告变少,但也让它变成真的。
要跑目标代码,必须同时具备四个控制。注意是「同时」——不是「尽量」:
- 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,并附上一份不会给目标环境添麻烦的验证方案。这次三条待验证线索,就是这么来的。
机器收尾:两个校验器,与它们不能证明的事
审计不能靠「我读了一遍觉得没问题」结束。
因为结论是 JSON,所以它可以被机器检查。技能自带两个校验器,在 Phase 4 之后、Phase 6 之前各跑一次,报错必须清零才能继续。
记录的结构与语义
三档分支是否互斥、必填字段是否齐、trace 是否恰好一个 entrypoint、一个 sink、中间只能是 propagation、路径是否仓库相对、指纹是否合规、严重度是否越界。超 5 MiB / 1000 条 / 64 层直接拒。
覆盖声明与记录是否对得上
ID 是否重复、状态与字段组合是否合法、covered 是否真的清空了指纹与未决事实、local 检查的 artifact 是否恰好落在该检查自己 owner 的 artifacts 下、候选指纹是否都能在 findings 里找到归属。
但要有清醒的认识,技能自己就说了:
它不证明代码安全,不证明没有遗漏,也不证明报告好读。它只保证一件事:结论和覆盖声明之间没有自相矛盾。剩下的事,靠批评门、独立验证和人来判断。
顺便说,这也是为什么整套流程有两个互斥的终态——要么报告三件套写完且两个校验器都过,要么明确写 run_status: incomplete 加一条精确原因。不允许「差不多跑完了就停」。中途停下不算收尾,只能算中断。
被审对象的形状:两条写路径,两种授权强度
在看结论之前,先看这个系统把信任边界画在了哪。
research-share 是一个 Node ESM 项目:用 node:http 自己做 SSR,数据放在 Postgres,授权靠 RLS 加 SECURITY DEFINER 函数。它的架构基线写得很清楚——db/03 的文件头直白地说:应用层可以被绕过,数据库层不能。
这句话决定了整个审计的重心。因为一旦「授权只在库侧」,那就必须回答一个问题:是不是所有写路径都真的走到了库侧的判定?
SECURITY DEFINER,以表 owner 身份执行,RLS 对它整个不生效——浏览器路径的强度没有被带到 RPC 路径上。因为「两条路径对同一个资源做同一件事,但强度不同」这类缺陷,只在把两条路径并排画出来的时候才会显形。逐函数读代码永远读不出来:每个函数单看都是对的。
这次运行的开销:钱花在哪
说「完整审计很贵」没有意义,把它拆开看。
research-share-run-2-20261024standard(完整 6 阶段)· 未设预算门 · 无因预算被迫中断的项90deace3 @ main,工作树脏(5 个未提交文件,按当前形态纳入范围)sandboxed-source-and-local-only:只读仓库 + 受限本地执行,禁止触任何已部署端点planned、无 findings.json),因此它的 18 个候选没有被当作锚定样例传下去run_status: complete · coverage_complete: true覆盖账本实况: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 独占某个子系统。
九条候选的去向:4 / 3 / 2
从 53 个格子收窄到 9 张指纹,再分叉成三种命运。
四条坐实结论
全部来自源码 + 纯函数 harness + 仓库自带离线测试。没有一条依赖对部署环境的探测。
Agent token 授权无资源范围绑定
agent-api:token-unbound-resource-scope
一把有效 token 只能证明「你是谁」,完全不能约束「你能碰谁的资源」。四个写 RPC 都是 SECURITY DEFINER,以表 owner 身份执行,而全仓没有 FORCE ROW LEVEL SECURITY——于是浏览器路径那条「属主或管理员」的强度,在 RPC 路径上根本不存在。
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后才能启用谓词
匿名埋点配额以可伪造的 X-Forwarded-For 为键
track/anon-write-quota-keyed-on-spoofable-xff
匿名写入唯一的量控是 ipOverLimit,它的键来自 clientIp(),而 clientIp() 信任请求头里的 XFF。攻击者不需要任何凭据,只要每次请求换一个 XFF,就等于每次都换一个「新配额」。
攻击组:同样 200 请求 / 同 socket / 200 个不同 XFF → rpc 写 1000 次,全部穿过
两组真实 socket 与请求数完全相同,唯一差别是 XFF;每请求无论超限与否都返回 204
- 边界
lib/track.js#clientIp/ipOverLimit→server.js的匿名路由- 定级依据
- 完全击穿了唯一的写量控制,但单请求仍有 20 事件 / 16KB 的上界,所以是「无上界地持续注入」而不是单点放大;对真实 Postgres 的库耗竭只是合理推断,未本地演示
- 最小修复
- 把代理信任显式化:默认取直连 socket 对端,确认位于受信代理之后才取该代理从右侧追加的那一跳,而不是攻击者能 prepend 的最左段
埋点 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——于是「默认部署」几乎必然使用公开盐。
/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的部署者仍可反演,若隐私承诺要对运营方成立,唯一彻底方案是停存可逆变换
SSR 兜底 page_view 不读退出统计开关
research-share/ssr-pageview-ignores-optout-cookie
客户端埋点老老实实检查 rs_optout,服务端则完全没接线:trackServerPageView 上来就写,不看 cookie。于是「退出统计」这个明示承诺,只在浏览器路径上成立。
服务端均恰好发起 1 次 track_event:p_event=page_view、p_source=server
已退出两组与对照组字段完全一致;同一开关下客户端路径 0 次发送
harness 25/25 断言通过;仓库自带 39 项埋点测试本环境全绿
- 边界
lib/render.js写rs_optoutcookie →assets/track.js客户端守卫生效 → 服务端trackServerPageView未接同一开关 →db/03#track_event- 定级依据
- 受害主体是已行使退出权的访客本人,写入内容仍限于站方已披露的匿名统计口径(无原始 IP、无 UA 原文、不出域),读取方只有管理员看板——没有扩大数据类别,也没有引入新的读取方
- 最小修复
- 在
trackServerPageView入口按同一键名跳过写库,键名继续从lib/constants.js单一来源 import(与客户端同源),避免两处字面量漂移
三条待验证线索
这一栏不是「低置信度的漏洞」,也没有严重度。它们是:源码路径成立、但决定性事实在本地取不到。
| 线索 | 决定了什么 | 卡在哪 |
|---|---|---|
N1 SSR 详情页的 url 未中和 schemerender.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→角色映射不可观测 |
这条记录最初把「SDK 附令牌的行为源码不可见」列为决定性 blocker。这句话是错的——那份 SDK 就在仓库的 node_modules 里。它是怎么被发现的、以及为什么不能当场改,正是下一节要讲的事。
两条被推翻的候选:推翻也是产出
按纪律,它们不得被描述成发现。但它们必须留在记录里,否则下一轮会重新提一遍同样不成立的指控。
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 一个都不决定性。
updateItem 的 doc_html 没有列白名单
research-share/browser-updateItem-patch-…
trace 是准确的(唯一更正是 ADR 文件名)。但能改写这一行的只有该行 owner 或管理员——受影响的正是行为者本人。而「托管文档里的 HTML 可变」这件事,ADR-0004 已经明言接受,下游用 CSP: sandbox allow-scripts(不给 allow-same-origin)把执行隔离在不透明源里。
建议的修复(数据面加字段白名单)只是把信任从 UI 挪到数据面,属于纵深防御。
这两条被推翻,说明上游对它们的具体反驳是有效的。而下面这一段,是反过来发生的:上游写下的一个前提被推翻了。
最值得记住的一课:连 blocker 本身也要被证伪
这是这套流程最不像「自动化」、也最接近真实工程的一段。
N3 这条线索的结论是「结论待定」,如果到此为止,它只是一条普通的待验证记录。真正有意思的是它被返工的过程。
- 1
Phase 3 的验证者写下 blocker 3
「
workbuddy-cloud-sdk的令牌附着行为源码不可见」,因此在本地无法判定浏览器写请求到底带不带用户身份。 - 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
父进程独立复核同一处源码
不带立场地把那段打印出来对照,确认 Phase 5 说得对:这份 SDK 就在仓库里,行为可以离线判定。
- 4
但按规则,不能直接应用
这不是改措辞——它改变了一条决定性 blocker 的成立与否,属于物质性改动。规则要求:交给第四位,既没猎过、没做过 Phase 3、也没提出这个替换的人。
- 5
第四位验证者重读源码,返回 verified
它独立复现了那条结论,没有引用前两位的结论作为依据。
- 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。
这一轮证明了什么,没证明什么
证明了
四条边界失效真的存在,且能在源码 + 纯函数 harness 里复现;账本的 53 格确实都被指派过 owner 并留下了检查;报告的每一条结论都能被另一台机器用同样的源码复算。
没证明
这个项目「没有别的漏洞」。一次运行永远不等于穷尽;三档判决里的 needs_validation 恰恰是承认还欠着事实。
- confirmed 全部来自离线证据。源码 + 纯函数 harness + 仓库自带测试;没有任何一条依赖对部署环境的探测。这既是优点(可复算)也是限制(库侧真实行为未验证)。
- 需要本地 Postgres 的判断没做。N3 与那个
blocked单元的写放大量化,都卡在这里,已按 needs_validation / blocked 记录并给出 owner 侧只读观测方案。 - 需要真实浏览器、真实电子表格的判断没做。N1 与 N2 的观测方案都设计成 owner 在自己机器上用 dummy 数据完成,不含任何发往部署环境的审计流量。
- 全量 IPv4 反演没跑。实测外推 0.52 核小时后按纪律停止,只用 /24 内的最小枚举证明「可反演」这件事本身。
元数据最初只记了 2 个脏文件,final-clean 批评门独立测得 5 个。父进程重跑 git status --porcelain 确认,并对三个新增文件逐一 diff 复核:一个是 SQL 重载切换(安全相关,已纳入单元)、一个是新增的健康检查路由(安全相关)、三个是忽略规则 / 文档措辞 / CSS 特异性修复(无可执行面)。审计报告自己的元数据也要被审。
一页速查
| 你的处境 | 该用哪种模式 | 预期代价 |
|---|---|---|
| 「这段代码有没有安全问题?」 | guidance,只取相关章节 | 几轮对话,不落盘 |
| 「帮我把这个项目完整审一遍,要出报告」 | full audit + standard | 几十次委派,七份产物 |
| 「只审这次 diff / 只审这个模块」 | full audit + scoped | 约为完整的一小部分;报告必须自称部分覆盖 |
| 「先快速看一眼值不值得深挖」 | full audit + quick | 1 波猎杀 + 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 条,双校验器 PASScoverage-ledger.json — 53 个单元,PASSREPORT.md · FINDINGS-DETAIL.md · NEEDS-VALIDATION.mdarchitecture.md — 供每个猎手逐字带进上下文本页是上述产物与运行过程的二次叙述,不是审计结论本身。任何判决、严重度、blocker 的权威来源都是 findings.json;如果两者不一致,以 JSON 为准。页面为单文件、零外部依赖,可离线打开。