ARTICLE / ai

监控说全绿,审计说四个旧洞还在

11 月 5 日 08:02,一份定时采集报告给出结论:连续第 35 轮零增量。173 个仓库名与基线逐名比对,added 为空、removed 为空;110 个本地目录的九项计数与上一轮全等;没有新增异常。

11 月 4 日 22:06,另一份报告给出结论:9 项发现,3 项 High、2 项 Medium、2 项 Low、2 项 INFO;4 个旧问题全部可复现;本该重点覆盖的六个高危模块,在两个相邻 tag 之间一行代码都没动。

一份说没变,一份说洞还在。它们并不矛盾,因为问的根本不是同一件事。前一份问的是「名单是否等于上一版」,后一份问的是「名单里的东西能不能被打穿」。可一旦有人把这两句话并排读,就会得到一个两边都不成立的印象:系统一切正常。

这篇文章不讨论那 9 项发现本身,也不讨论分数怎么算——那两件事已经写过了。它要拆的是更靠下的一层:当你说「什么都没变」的时候,你拿什么来证明;当你说「有洞」的时候,你说的又是哪一层的东西。两份报告在同一天给出两个「正常」,正好构成一次难得的对照。

「零增量」是结论,不是证据

先把几个数字摆平。

同一份快照里,「已推 95/110」「未推 15」「PUSHED 0」三个数一起出现。我第一次读的时候把它们当成了互相矛盾的三句话:既然有 95 个已经推上去了,为什么还有一个叫 PUSHED 的计数是 0?

答案在口径。95/110 讲的是累计状态:110 个本地目录里,按名字能在远端对上仓库的有 95 个。PUSHED 0 讲的是本轮增量:这一轮采集里,新发生推送动作的目录是 0 个。一个存量指标,一个流量指标,挤在同一张表里,看上去像打架,实际上各说各的。

这类误读的代价不小。把流量当存量,你会以为项目进度归零;把存量当流量,你会以为这一轮干了 95 件事。而快照不会替你区分,它只老老实实把两个数并排放着。

真正值得追问的是「零增量」这四个字的判定过程。它不是「这一轮没有提交」这种主观印象,而是一条可以被复算的结论:先取一份基线,再把本轮的名单逐项与基线比对,两边相减,得到两个空集。所谓零增量,是「差集为空」的另一种说法。

这个定义带出一个不太好受的推论:零增量的强度,完全取决于你把什么放进了「项」里。

如果项是数量,那么 173 等于 173 就是全等;可两个成员完全不同的集合,元素个数照样可以相等。如果项是名字,那么名字对上了才算全等;但名字对上之前,你得先确认自己把名单取全了——分页没有取到底,逐名比对就是在比对半份名单。如果项是名字加上每个名字背后的一组状态字段,那么「全等」这三个字才有资格接近直觉里的那个意思。

三层比对,成本差一个量级,结论的可信度也差一个量级。日常里最容易发生的错位是:用第一层的成本,去支撑第三层的口气。

采集脚本自己似乎也知道这一点。它在工作目录里留下了两类很不起眼的文件。

一类是 added 和 removed 两个文本文件,都是 0 字节。另一类是三页仓库列表:第一页 100 行、第二页 73 行、第三页 0 行。第三页空着,恰恰是它最有用的时候——它证明了分页已经走到底,173 这个数字是「取完了」的结果,而不是「取到这里停了」的结果。

这是负向断言最尴尬的地方:你没有办法展示「没有」。变了的证据是一个 diff,没变的证据是一片空白,而空白本身没有信息量,除非你能同时证明「我确实看完了全部,并且全部为空」。

所以一份可信的「零增量」报告,至少要能回答三个问题:比对的是哪一层,名单是怎么取全的,空结论存在哪里可以事后复核。缺了第三个,这条结论就只有当天有效,因为没人能回到现场。

同一个空 diff,两副面孔

版本审计里的对应物,是 git diff <旧 tag>..<新 tag> --stat 的空输出。

触发审计的条件用的是版本差集,不是日历。日历驱动的审计会被问一个很难回答的问题:为什么是这个月?版本驱动不需要理由——上游动了,我就得看。可版本驱动同时带来一个陷阱:如果某个模块在两个版本之间零改动,一次只看 diff 的审计会在它身上花零分钟。

六个高危模块在 v0.22.6 到 v0.22.7 之间就是零改动。连接器、URL 校验、路径安全、技能守卫、审批检查、凭据库——目录过滤后的 --stat 输出为空。按「只看改动」的直觉,这里应该直接跳过。

实际处置是反过来的:零改动意味着必须回归旧 PoC,而且优先级高于改动模块。

理由不复杂。一个刚被改过的模块,至少有作者注意力落在上面——有 changelog、有 review、有 CI。零改动的模块什么都没有,只有上一轮审计留下的记忆;而记忆是最先失效的东西。更要紧的是,不改不等于安全:一个从 v0.22.3 引入、连续 5 个 tag 都没被碰过的审批短路,它的危险程度随着版本号推进在往上走,而不是往下走。代码没动,暴露窗口在动。

四项旧发现全部复现,其中一项的编号旁写着一句很容易被跳过的注记:连续第 5 版未修复。

这句话看起来像抱怨,其实是全文里最硬的一个事实。它由两部分组成,都可以被第三方复核:引入的版本(v0.22.3)和它此后经过的 tag 序列。任何人拿到这几个 tag,都可以指着同一段代码说,是的,五版之前是这样,现在还是这样。它不依赖谁的心情,也不依赖谁的判断,它只是一个时序陈述。

时序陈述之所以重要,是因为它把「要不要修」这个模糊问题,变成了「修、接受、还是写进文档」这个可以排期的选择。

一个刚刚引入的 High,合理的默认动作是排期修。一个已经连续 5 版无人处理的 High,合理的默认动作是有人明确决定它不修——因为「连续 5 版没人管」本身就说明它不在任何人的待办里。最坏的状态是第四种:没人记得它存在,而每一版审计都在报告里把它重新发现一遍。

顺带说清一个容易混淆的边界:本轮判为「设计如此」的项不算此类。桌面壳的 webview 标签开着,是预览面板的刻意设计,guest 侧有可信来源校验;永久审批按 key 全局生效,是官方文档写明的行为。这些记 INFO,不往 High 里凑。把设计意图当漏洞,会让分数失去区分度;反过来,把设计意图当免罪符,会让真正该修的项混进 INFO 里。这两件事都要人来做,工具做不了。

不过这里还有一个自查项,本轮没遇到,但值得写下来:路径过滤的空输出,成立的前提是模块还在原来的路径上。目录被移动或重命名,过滤条件就匹配不到东西,输出同样为空。所以「零改动」这条结论,最好配上一条独立的「模块仍在原位」的检查。这是推断,不是本轮观察到的现象,但它属于同一类错误:把工具的空输出直接当成事实。

「什么都没变」需要三层证据

把两份报告的判定层摊开对比,会发现它们其实在做同一件事的两个方向。

审计在证明「有」:这里有一个可执行的输入,它让程序走到了一条不该走的分支,输出贴在这里,时间戳在这里。监控在证明「无」:名单里没有多出来的东西,也没有少掉的东西。

证明「有」的难度是线性增长的,多一个发现就多一份 PoC。证明「无」的难度是随覆盖面指数增长的,因为你要否定的是一个全集。所以负向断言有一套自己的证据阶梯,台阶的高度由「别人能不能推翻你」决定。

层级断言形式什么能推翻它能支撑的口气
计数173 等于 173换掉一个成员,数量不变「数量没动」
逐名两份名单相减为空一次改名、一次分页漏取「成员没动」
指纹加存档九项计数、两份名单、快照文件同时留存采集口径本身变了「在我声明的口径内没动」

第一层最便宜,也最容易被当成结论。它唯一的用处是当报警器:数量对不上,一定有事;数量对上了,什么都说明不了。

第二层开始有内容。逐名相减得到两个空集,意味着「没有新增」和「没有消失」同时成立。这一层的强度取决于一件事:名单是不是完整的。分页没取到底,逐名比对就是在比对半份名单,而半份名单和另一份半份名单相减,同样可以得到两个空集。

这就回到了那三页文件。第一页 100 行,第二页 73 行,第三页 0 行。100 加 73 等于 173,和总数对得上;但真正让人放心的是那个空页——它说明请求发出去了、返回了、没有更多了。如果只留下 173 这个数字,事后没有人能区分「一共 173 个」和「我看到 173 个」。

第三层是给未来的自己留证据。快照文件按轮次命名存档,工作目录里留着当轮抓下来的原始名单。这两样东西的价值不在当天——当天所有数字都在内存里,谁都不需要翻文件。它们的价值在三个月后,当有人问「这个异常是一直存在还是新出现的」,你可以把两轮的快照并排打开,用眼睛看,而不是用记忆答。

added 和 removed 那两个 0 字节文件属于同一类设计。空文件看起来什么都没干,可它有修改时间。一个 0 字节、时间戳落在本轮采集窗口内的 removed 文件,是一句可以被检验的话:这一轮我比对了,结果为空。反过来,如果这个文件根本不存在,你无法区分「比对结果是空」和「这一轮没跑比对」。

这也是自动化和人工检查最容易分岔的地方。人会记得自己检查过,机器不会,所以机器必须留下痕迹;而人留下的痕迹,往往只是一句「我看过了」。

九元组:把一个仓库群的「没变」压成九个数字

这套采集最后收敛成九个计数,我把它叫做形状。

目录总数 110,散落文件 10,顶层条目 120,没有 .git 的目录 109,有 .git 的目录 1,本轮新增推送 0,无远端 0,无上游 0,异常 1。

九个数字的好处是便宜。一轮采集只要把九个数字算出来,与上一轮逐个对比,全等就收工。它把一个几百个对象的集合压缩成一行,让人一眼看出「有没有大动作」。

坏处也在这里:压得越狠,丢掉的东西越多。

字段本轮值它管什么单看会漏掉什么
目录总数110有多少个目录换一个进来、换一个出去,还是 110
散落文件10顶层散文件的个数文件换名,个数不变
顶层条目120只覆盖第一层第二层以下完全不看
无 .git 目录109有多少目录没版本库不知道是哪 109 个
有 .git 目录1只剩一个不知道是哪一个
本轮新增推送0这一轮推了几个和累计 95/110 不是一个东西
无远端0没有配置远端的数量远端地址改了照样是 0
无上游0没有跟踪分支的数量上游换了照样是 0
异常桶1出错的对象数新旧异常混在同一个 1 里

这张表里最值得盯的是最后一行。

异常桶恒等于 1,连续几十轮都是 1。这个 1 的来源很明确:某次云端同步事故在本地留下了一个空的版本库目录,指向它的操作会报一个文件锁错误。它在归档结论里被标成「非新异常」,来源日期是 10 月 24 日,所以后续每一轮都算已知项。

已知项的好处是不用重复排查;代价是它从此和「新异常」共享同一个计数器。当 ERROR 从 1 变成 2,你知道多了一个;但当你只看这一行,你永远不知道多出来的那个是不是又一个已知项。九元组把「有没有多」这个问题回答得很好,把「多的是什么」这个问题完全丢掉了。

所以九个数字的定位应该更保守一点:它是报警器,不是证据。报警器响了要去看名单,报警器不响不代表名单没变。真正撑住结论的是那两份逐名清单,加上一份可以回放的快照。

采集脚本自己也是这么用的——每一轮都会在结论里同时写两句话,一句是九项计数全等,一句是逐名比对 added 与 removed 皆为空。前一句省事,后一句才是结论。只写前一句的报告,读起来像是全等;实际上它只是没看。

06:00 和 08:02 之间,两个字段换了说法

同一套采集在 06:05 和 08:02 各留了一份快照,相隔不到两小时,九项计数完全一致,结论都写着全等。我把两份快照逐字段对了一遍,找到两处变化,都不在九项计数里。

第一处是一个字段的类型。06:05 的快照里,表示无远端目录的字段是一个只含一项的列表,里面装着一个目录名;08:02 的快照里,同一个字段变成了整数 0。同一个名字,一份是列表,一份是计数。

第二处是信息量。06:05 的快照在 Agent 状态里写着版本串:一个 tag 号、一个加号、一个提交计数,后面跟着本地提交短哈希,再后面是「在线,采集脚本自证」。08:02 的同一条只剩「在线,采集脚本自证」,版本串和哈希都不见了。

而两份快照的结论段落,一字不差地都写着九项计数全等。

这不是谁的错,这是聚合的必然。九个计数里没有「字段类型」这一项,也没有「字段里还剩多少信息」这一项。所以这两处变化发生了,而且永远不会被这套比对发现——除非有人像我这样,把两份原始 JSON 逐字段翻一遍。

我更愿意把它当成一个正面样本,而不是一次失败。因为它精确地展示了「全等」这个词的边界:全等永远相对于某一层而言。九项计数全等是真话,字段类型变了也是真话,两句真话可以同时成立,只要你记得它们说的不是同一层。

同一件事在审计里有一个几乎同构的版本。git diff --stat 输出为空是第一层,逐文件读 diff 是第二层,按危险调用模式扫一遍改动文件是第三层。第一层最便宜,第三层最贵。前两层都做完,也可能漏掉一个把 shell=True 从常量改成变量的改动——因为 stat 只说这个文件动了 3 行,不说这 3 行动了什么。

更值得警惕的是第二处变化那种形态:信息量退化。

一个字段从「带着版本号」退化成「只说在线」,它的值仍然非空,状态仍然是绿的,任何基于「非空即正常」的检查都不会响。它不制造差异,它只制造沉默。这类退化极难被自动发现,因为自动检查天生对「有东西」敏感,对「东西变少了」迟钝。

所以采集类系统有一条不太直觉的设计要求:格式变更要和值变更分开记录。值变了,可能是世界变了;格式变了,一定是测量工具变了。后者比前者更危险,因为世界变了你会去查,工具变了你会以为世界没变。

v0.22.5+809 是什么

第一份快照里那个消失的版本串,值得单独拆开看。

它的形状是「v 主版本.次版本.修订号 + 提交数」,后面跟本地提交短哈希。加号那部分的意思,通常是在这个 tag 之后又叠了多少个提交。也就是说,这个字符串描述的是「以某个发布点为起点、往前走了 N 步」,它不是一个可以直接排序的版本号。

这个区别在两种场景下会致命。

第一种是判断「我跑的是不是被审的那个版本」。审计整理的源码副本来自两个 tag:v0.22.6 和 v0.22.7。运行中的实例报告的版本串是 v0.22.5 加 809 个提交。这两个东西能不能直接比大小?不能。809 个提交可能远超 v0.22.7,也可能只是把 v0.22.5 之后的一小段开发线走完了,字符串本身不告诉你答案。

要回答这个问题,需要的是提交图上的祖先关系判断:本地那个提交是不是 v0.22.7 的后代。这是一个 git merge-base --is-ancestor 就能回答的问题,它有明确的是非,不依赖解读。

第二种是任何跨系统对账。当两个工具各自报告版本,而两者的版本串格式不同,把它们放进同一张表里比较,得到的结论没有意义。

我不认为监控应该去做祖先关系判断。它的职责边界很清楚:进程还在不在、命令还能不能跑、采集有没有按时发生。越界去做版本比对,反而会把一个可靠的工具变成一个不可靠的判官。

问题出在读者这一侧。一份标题叫「状态报告」的文件,通篇绿色,最后一行写着「工具链全绿」,读起来像是「安全」。它实际上回答的是「工具链还在」。「还在」和「安全」之间隔着一整套审计,而审计的结论是四个旧洞仍在,其中一个已经连续五个版本没人碰。

两份报告放在一起看,得出的判断是「活着的、有已知未修问题的系统」。只看其中任何一份,都会得到一个更舒服也更错的结论。这就是我坚持把它们分开写、并且都写清楚判定层的原因:舒服的结论通常来自口径的混用。

已知项和习惯了,中间隔着三条

异常计数恒等于 1 这件事,从 r476 到 r510 已经持续了 35 轮。

35 轮是什么概念?如果按两小时一轮算,这是将近三天的连续采集;如果按每轮都要重算九项计数、重抓两页仓库列表、重列本地目录算,这是 35 次完整的重复劳动。每一次的结论都是同一句话:还是那个,没新的。

一个异常能在报表上活 35 轮,说明它在某个时刻被识别出来、被记进结论、并被后续轮次承认为「已知」。这个过程完全合理,甚至是运维成熟的标志——每次都把同一个已知问题重新当新问题查一遍,才是浪费。

但「已知项」和「习惯了」之间,其实隔着三条判据。

第一条,它什么时候来的。那个空壳目录对应一次具体的云端同步事故,有日期,有当时的处置记录。这让它成为一个有起点的事件。

第二条,谁确认过它无害。这里的「无害」有具体含义:它只影响指向它的操作,不影响其他目录的采集,也不会扩散。这个判断需要有人做过,而不是被默认。

第三条,什么条件下它会消失。这一条最容易被跳过。一个异常如果没有任何消除路径,它就不是待办,而是永久设施。

三条里缺任何一条,它就从「已知项」滑进了「习惯了」。滑进去之后,最典型的症状是:没人能说清它为什么还在,但所有人都默认它应该还在。

我更喜欢另一种处理方式:把已知项从主计数器里拿出来,单独列一张豁免清单。清单上每一条带三个字段——来源事故、影响范围、复核日期。主指标只统计清单之外的异常。这样 ERROR 从 1 变成 2 才有意义,因为那个 2 里没有混着历史。

清单还需要一个到期日。到期不是说要修,到期是说必须有人重新看一眼:还豁免吗?理由变了吗?不看也行,那就把日期往后推,但推这个动作本身要有人做。一份没有到期日的豁免清单,三年后就是一份没人敢动的祖传配置。

这套做法不只适用于仓库采集。任何长期运行的检查——依赖漏洞扫描、配置基线核对、日志关键字告警——都会遇到同一个拐点:告警从「需要处理」变成「需要忽略」。区别只在于,有的系统把忽略写进了配置,有的系统把忽略写进了人的脑子。前者可审计,后者不可。

两种节奏:高频的看变化,低频的看性质

监控和审计的价格差了两个量级。

一轮采集的成本是几分钟:列目录、调两次接口、做几次集合运算、写一份快照。一轮审计的成本是几天:拉源码副本、读 diff、写可执行验证、跑证据、算分数、写报告。价格差决定节奏差,节奏差决定它们能回答的问题不是一类。

高频工具擅长回答「有没有变化」。它的采样密度足够高,可以在变化发生后两小时内发现它。低频工具擅长回答「这个东西是什么性质」。它采样稀疏,但每一份样本都挖得很深。

错配通常发生在两个方向。

用高频工具回答性质问题,会得到一个廉价结论。比如「工具链全绿」这句话,它的真实含义是「四个命令都能跑出版本号」。它离「这些工具的行为符合预期」还有很长的距离,离「用它们搭的系统是安全的」更远。

用低频工具回答变化问题,会得到一个过期结论。审计报告写在 11 月 4 日 22:06,它的适用区间是 v0.22.6 到 v0.22.7。到了第二天早上,上游可能已经又走了几十个提交;报告里的每一句话都仍然忠于它声明的那两个 tag,但它对「现在这个版本」什么都没说。

这两类错配都不需要谁犯错,只需要读者少看一眼时间戳。

所以我把两份报告拼在一起读的时候,会先做一件事:把各自的时间标签对齐。审计这边要的是「适用版本区间」和「最后确认时点」,监控这边要的是「采集时点」和「基线轮次」。四个标签都在,两份报告才能拼成一句话;缺一个,拼接处就会出现一段没人负责的空白。

这段空白最典型的形状是:审计说某个模块零改动所以旧洞仍在,监控说这一轮没有变化所以一切正常。两句话都对,可它们没告诉你一件最要紧的事——那个零改动,是从哪个版本到哪个版本。如果这个区间已经跨了五个 tag,那么「零改动」在审计语境里的意思是「欠了五版」,在监控语境里的意思是「很稳定」。同一个词,两种读法,都成立。

同一个词,两份报告里不是一个意思

两份报告放在一起读,最容易出错的不是数字,是形容词。同样五个词,在两套采集里指的不是一件事。

词监控里指审计里指混读会怎样
变化名单成员有没有增删攻击面有没有新增或位移把「名单没变」读成「没有新攻击面」
正常命令能跑、进程在没达到报告门槛的发现把「还能跑」读成「没问题」
全等九项计数与上一轮相同逐文件 diff 为空前者漏字段类型,后者漏路径改名
在线采集脚本自己跑成功了不在审计范围内把「监控活着」读成「被监控对象健康」
已修不适用补丁进了新 tag把「有补丁」读成「已经部署」

这一列里最值得盯的是「在线」。

一份定时报告能在 08:02 准时产出结论,这本身就证明了采集链路是通的:定时器活着、脚本能执行、依赖能加载、结论能落盘。这个自证很硬,硬到几乎无法伪造。但它证明的对象只有一个——采集器自己。

被采集的那个东西是否健康,是另一个问题,而且往往需要另一套完全不同的探测。进程在、端口通、命令有响应,这三件事合起来仍然只是「它还在」。离「它的状态符合预期」还差一次真正的功能性检查。

「已修」那一行是同一个结构的另一个位置。审计能回答的是:新 tag 里存在一段代码,它消除了某个可复现的输入路径。审计不能回答的是:运行实例里跑的是不是那个 tag。这两个问题之间隔着部署、配置和灰度,每一层都能让一条已经修好的补丁不生效。

所以一句「已修」在报告里最安全的写法,是把它的边界写出来:修在哪个版本、在哪个提交、验证到哪一层。写到第三层的人很少,因为第三层通常不在审计的职责里——可读报告的人不知道职责边界在哪。

写在最后

回到开头那两份报告。

如果只看监控,你会得到一个舒服的结论:35 轮零增量,173 个仓库名一个没多一个没少,工具链全绿。这个结论真实,但它回答的问题只有「东西还在不在」。

如果只看审计,你会得到一个紧张的结论:9 项发现,3 项 High,4 个旧洞全部可复现,最长的一个连续 5 个 tag 没修。这个结论也真实,但它回答的问题只有「这些东西能不能被打穿」。

两份合起来读,才会得到那句真正有用的话:系统活着,带着四个已知的、有明确版本的、可以被复现的问题,其中一个已经欠了五个版本。

这句话不好看,但它是可执行的。前两句都不可执行——一句让你什么都不用做,一句让你不知道从哪下手。

关于证据,需要说清分级。这篇文章里出现的仓库总数、目录数、九项计数、分页行数、两份快照的字段差异,来自一套跑在自己环境里的采集,原始文件带时间戳存档,可以复算,但读者无法独立复核具体数值。审计那边的九项发现、四个旧洞、连续版本数,来自一份私有代码库的自评,方法论可以照搬,结论无法外包。至于「聚合层全等与原始层全等是两个命题」「信息量退化比数值变化更难发现」这类判断,是我的观点,不是观测结果。三类证据混在一起写会很好看,但那是一种欺骗。

边界也要划清。这是一次单点快照,不是趋势研究;两套采集由不同工具产出,字段定义不同,除了时间和对象之外没有共享口径;「零增量」只在已声明的九项计数与逐名清单上成立,不覆盖字段类型和字段信息量;审计覆盖的是两个相邻 tag 的源码副本,不覆盖运行实例的实际配置与部署差异;CVSS 分数是自评向量,未经第三方复核。任何一条被当成普适结论,都会失真。

如果你手上也有一套跑了很久的自动检查,有三件小事可以今天就做。

给核心对象算一个集合指纹——不是数量,是逐名清单的排序后拼接再加一个哈希。指纹变了就一定有变化,指纹没变至少说明成员没动。它比计数贵一次排序,比人工比对便宜几个数量级。

在每次比对之后留一个空文件。名字可以叫 removed,内容可以为空,但时间戳要落在本轮窗口内。三个月后你唯一能依赖的,就是这类没有内容却有时间的痕迹。

还有就是把已知问题请出主计数器。单独一张清单,三个字段,一个到期日。清单本身要能被审计,而不是被记忆。

这三件事都不解决漏洞。它们解决的是另一个问题:当你说「什么都没变」的时候,别人有没有办法相信你。