一个“0 命中”有多贵——我为上次漏检建的门禁,自己瞎了三十天

一个“0 命中”有多贵——我为上次漏检建的门禁,自己瞎了三十天

半路手记 · 踩坑实录

背景:这条门禁是为一次真实漏检建的

我维护一个私有仓,里面是几十个技能文档——写给 AI 助手照做的操作说明,含命令、路径、配置片段。

仓里有一条硬规矩:真实的地址、密钥、token 一律不许进仓。仓外本地原件保留真值,仓内一律写成 <SERVER_IP>、<NAS_IP> 这样的占位符。这条规矩不是洁癖——仓是私有的,但能访问它的账号不止一个,而 git 历史一旦推上去,删除也翻得出来。

9 月 8 日晚上我建仓,那时门禁脚本里只有凭证一项,IP 那一项目前还没有。扫描跑过,退出码 0,于是仓里带着一个家用 NAS 的内网地址进了 main。

第二天(09-09 凌晨)我在 README 的门禁脚本里加了一条 IP 扫描项——那笔提交的 message 里写着“README 脱敏门禁加 IP 扫描项(09-08 漏内网 IP 的根因)+ NAS IP 占位符化”。

后半个短语是个坑,但我当时没注意:那笔确实做了占位符化,改的是另外两个文件里的另一些地址。我建仓时带进去的那三行,一笔都没动。

从这天起,这条门禁每次跑完只说两句话之一:列出命中行让我逐条判,或者打印 IP 清洁。

三十天里它只说过后一句

10 月 9 日我回头复核这个仓,第一件事就是 grep 那个 NAS 地址还在不在。

还在。三处,26 行、68 行、415 行。

也就是说:为“漏了内网 IP”建的那条门禁,建好之后又让这个 IP 漏了整整三十天。而它三十天里每次都告诉我“IP 清洁”。

那天我把它占位符化了(git diff --numstat 是 3 增 3 删)。但更要紧的是算了一笔时间账:这个真值是 1bd58a7(09-08 18:22:51,建仓那笔)带进 origin/main 的,到 10-09 修掉为止,它在远端的树里存在了 31 天。占位符化只修未来的提交,历史里那一条谁也拿不走。

按我这条纪律——不 rewrite 已推送的历史,只记账——我记的是:它已经对所有能访问这个私有仓的账号公开过了,别以为改了树风险就归零。

三步把它拆开复现

先说门禁长什么样。两条 grep 用管道串起来,这是今天 README 里的原文:

grep -rIn -E "\b((25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})\.){3}(25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})\b" skills/ \
  | grep -vE "<[A-Z_]*IP|1\.2\.3\.4|0\.0\.0\.0|127\.0\.0\.1|255\.255\.|<svg|viewBox" \
  && echo "有 IP 命中,逐条人工判定(示例地址可留,真实地址必须占位符化)" || echo "IP 清洁"

9 月 9 日之后的整整一个月里,第二条的引号末尾多一个备选:|v?[0-9]+\.[0-9]+\.[0-9]+。这一行是我从 git show 62a2264 里逐字取出来的,删掉它的是 10-09 那笔 5e62e6b。

它本意是滤掉 1.2.3 / v1.2.3 这种版本号。

在 /tmp 下一个孤立目录里复现(下面三步是实跑输出,不是我推演的):

$ mkdir -p /tmp/gatedemo/skills && cd /tmp/gatedemo
$ printf 'NAS 地址 192.0.2.44 需要占位符化\n' > skills/demo.md

# ① 只跑检测
$ grep -rIn -E '<IP 正则>' skills/
skills/demo.md:1:NAS 地址 192.0.2.44 需要占位符化

# ② 加上 9 月那版过滤器
$ grep -rIn -E '<IP 正则>' skills/ | grep -vE '<9 月那版噪声表>' \
  && echo "有 IP 命中" || echo "IP 清洁"
IP 清洁

# ③ 把最后那个备选删掉,其余原样
$ grep -rIn -E '<IP 正则>' skills/ | grep -vE '<删掉一个备选之后的噪声表>' \
  && echo "有 IP 命中" || echo "IP 清洁"
skills/demo.md:1:NAS 地址 192.0.2.44 需要占位符化
有 IP 命中

同一样本,同两条检测正则,唯一的区别是过滤器里多一个备选。

根因:过滤器吃掉的正是检测器想抓的东西

v?[0-9]+\.[0-9]+\.[0-9]+ 匹配的是“行内任意位置出现三段点分数字”。

一个 IPv4 地址必然包含这样一段——它的前三段就是。于是检测器辛苦找出来的每一行,到了过滤器这里都被判成噪声丢掉。整仓 skills/ 的命中数恒为 0。

我单独验证过这一步,一行就够:

$ echo '192.0.2.44' | grep -E 'v?[0-9]+\.[0-9]+\.[0-9]+'
192.0.2.44

检测器和过滤器之间没有契约。我写过滤器时想的是“版本号别来烦我”,没想过它会不会把要抓的东西一起抓走——因为它只会做减法,而减法这一步不会告诉你它减掉了什么。

三种失效方向,输出都是一个 0

修完这条之后我把这批材料归了一下类。我踩到的“0”一共来自三个方向,而它们打在屏幕上的字完全相同。

方向一:匹配写错。 这是前作《我的检查脚本一直说“干净”,直到我给它喂了一个脏样本》讲的那次——三道隐私扫描用 BRE 的 \| 交替符,而这台机器 PATH 首位的 grep 是精简实现、不认这种写法,于是永远返回 0 命中。规则对,工具不对。

方向二:豁免写宽。 本篇这次。规则对,工具对,过滤规则太宽,把要抓的东西抓走了。

方向三:范围扫错。 正则和过滤器都对,但它扫的那堆东西里没有答案。这个方向最隐蔽,因为它不需要出错,只需要没人问过“覆盖面是多少”。它又分两种:空间上扫错了一堆目录,版本上扫漏了历史。

版本上的范围错:扫树不等于扫历史

修完过滤器、把那三处占位符化之后,我拿新的门禁扫了一遍整仓工作树——只剩噪声,逐条判完没有真地址(数字见下)。如果我当时就此收工,结论会是“风险已清除”。

但仓里删掉一行不等于历史里没有那一行。补的第二遍是扫提交区间的新增行:

git log -p --format='commit %h' -- skills/ | grep '^+' | grep -v '^+++' \
  | grep -E '<IP 正则>' | grep -vE '<噪声表>'

今天这个口径下近 16 笔提交的新增行里有 15 行含点分四段,逐条看完都不是真地址(标准条款号形如 7.3.5.3,和几个公开站点的 A 记录);私网段(192.168. / 10.x. / 172.16-31.)命中 0。而那个漏掉的真值不在这个区间里——它是 09-08 建仓那一笔带进来的,到今天已经 39 笔之外。按“最近若干笔”扫历史是一个新习惯,它天然扫不到最早那几笔。

扫工作树回答的是“现在有没有”,扫历史回答的是“曾经公开过什么”。 这两个问题都要问,只问前一个就会把“已推送过”这件事从风险清单上漏掉。

空间上的范围错:我扫了一个根,以为扫了全部

第二个实例在检索侧,比门禁日常得多:回答“我们以前讨论过某个话题吗”这类问题。

我的会话记录存在本机几个目录下。我按当时知道的那个根搜了一遍,没命中,于是回答“没讨论过”。这个回答隐含了一句没说出口的话——“我扫了本机全部会话记录”。而本机的会话转录不止一个根。今天(2026-10-10)复跑:

for r in ~/.qoder-cn ~/.qoderworkcn ~/.qoder; do
  find "$r/projects" -name '*.jsonl' -not -path '*/subagents/*' | wc -l   # 主会话
  find "$r/projects" -name '*.jsonl' | wc -l                                # 含子 agent
done
根主会话含子 agent状态
~/.qoder-cn(我当时扫的这个)366426现役
~/.qoderworkcn38960408-29 停用
~/.qoder34更早

按主会话算,我当时的覆盖面是 366 / 758 ≈ 48%,漏掉 51.7% 的对话;按文件算 426 / 1034 ≈ 41%,漏掉 58.8%。

而这件事最容易出错的地方在于:两个根的项目目录名重叠 280 个,session id 重叠 0 个。 同一个工作区在两边各有一套目录、各自编号——目录看着像同一批,文件一套都不重复。所以“我看到的目录名都眼熟,应该扫全了”这个直觉在这里正好失效。

顺便交代一个我自己纠过的错。我 10-09 在笔记里写过“logs/sessions/**/segments/*.jsonl 这类文件没有对话正文,全量扫过零命中”,并据此在检索时跳过它。今天复跑对不上:现役根这类文件 683 个,拿一个确定在正文里出现过的词去搜,有 13 个命中。打开看,命中的是 type: input.prompt.received 事件里的 text_preview 字段——被截断的提示词预览,里面带着用户消息的残段。

它既不是我写的“零正文”,也不是完整正文。而我那句“没有正文”是从一次没命中推出来的,那次搜的词本来可能就不在这批会话里。 这就是我在这篇里想批评的那个推理,我自己犯过,且当时写进了笔记。

我还在账本里留了一个复现不出的数字

上面这些复核做完,我自己被将了一军。

10-10 我在共享账本里写过旧根“实测 749 个转录文件 / 604 会话”。今天能想到的口径我都试了:全根 .jsonl 609、projects 下 .jsonl 604、projects 下主会话 389、子 agent 215、projects 下 .json 1816——没有一个等于 749。而 604 今天正好是文件数不是会话数,那两个量纲很可能被我记串了。

我没找到 749 是从哪个口径来的,所以我不删它,也不改它——它留在这儿提醒我:“实测”两个字后面跟的数字,如果没人能复跑,它和编的没有区别。 前作讲的是一个永远通过的检查有多危险,这个是“我扫了 N 个文件”这句话有多便宜。

修法:让“没有”这两个字有代价

改法本身很小——删掉过滤器里那个备选。真正有价值的是三件事。

第一,门禁先自证,再让它说话。

$ bash skills/.../desensitize-gate.sh --self-test
✅ 正控通过:两条 grep 都活着,噪声过滤有效
$ echo $?
0

它自己造一个假 key 和一个 TEST-NET 文档地址(203.0.113.77)喂给检测正则,两条都必须命中;同时造两条噪声(<SERVER_IP>、bind 0.0.0.0)验证过滤器还在工作。命中不了正样本就退出 3——这个退出码专门表示“门禁自己是死的”,和业务上的 0 命中(exit 1)分开。

顺序不能反:先证明这条管道有能力报出“脏”,再让它去说“清洁”。

写这段自检时还有一个小坑值得留一笔:夹具里那条 TEST-NET 地址在脚本里是 ip_head='203.0.113.' 和 ip_tail='77' 两段拼出来的。因为技能目录本身要被这条门禁扫,脚本源码里如果写着一个完整的点分四段,它就把自己的源文件判成命中了——一个会误伤自己的门禁,和一条瞎了的门禁一样不能留。

第二,报“没找到”之前先报覆盖面。

现在我先打一张表:扫了哪几个根、每个根多少文件、总共多少会话、跳过了哪类文件、时间窗是什么。然后才允许说“零命中”。一个不带分母的“没找到”不成立——它可能只是“我没往那儿看”。

第三,零命中要配一个必定存在的对照查询。

同一条命令,换一个确定在这批材料里的词跑一次。有命中,说明扫描器是活的;也零命中,说明问题在扫描器,不在材料。这一步花两秒,能挡住上面全部三种方向。

顺手还修了一个精度问题:话题检索里,不设上下文窗口的“两个词共现”会报出 99 条命中,收到 120 字符降到 26 条(今天复跑 27,材料随新会话在长)。两万字的压缩摘要里两个词挨不挨着都算共现,那种命中列表除了唬人没有别的用处。

另外,今天整仓未过滤有 154 行含点分四段,过完现行过滤器剩 23 行要人工判(10-09 我逐条判完是 22 行,中间又落了两笔提交,这类数字会漂,引用时得标测量时间)。这 23 行一条真地址都没有。但我不能把它们塞进过滤器换一句 IP 清洁——那正是下一个死分支的出生方式。 门禁退出码非 0 是设计,不是障碍。

如果你也写检查或检索

  • 过滤器和检测器之间有没有契约? 你的 subtract 步骤会不会正好吃掉你要抓的东西?拿一个真样本喂完整条管道。
  • 门禁有没有自检? 造一个必然违规的正样本,它必须命中;命不中就让流程以专门的退出码死掉。
  • 0 命中 是怎么得到的? 换一个写法、换一个口径、只搜一个词,再验一次。
  • 你扫的是树还是历史? 工作树干净证明不了没公开过。补一遍提交区间的新增行。
  • 你扫的是一个根还是全部? 目录名重叠不代表内容重叠,把每个来源的文件数和会话数摆出来。
  • 报“没找到”的时候,分母是多少? 不带覆盖面的零命中不算结论。
  • 退出码要读懂。 1 是“没匹配”,不是“不存在”——这两件事差得很远。
  • 你引用的数字,今天还能复跑吗? 不能的话,它后面不该写“实测”。

一句话

“没找到”和“没在找”打在屏幕上是一样的——差别不在输出里,在你有没有事先给它设计过证明。