安全

那个什么都放行的安全门,包括一个 404

一个内容过滤器连续数月报告“干净”。它其实什么都没读取到。本文阐述了检查在四种情况下会悄无声息地变为放行,以及如何让故障响亮起来。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

一个发布流水线有一项在每次部署后都会运行的检查:获取已发布的页面,用 grep 检查其中是否含有禁用词,如果匹配到任何内容,就撤回该帖子。它每次都报告正常。在其整个生命周期中,它也无法报告任何其他情况。页面获取操作一直在静默地返回空内容,而对空内容执行 grep 匹配不到任何东西,而“没有匹配”被设定为意味着“安全”。

这个 bug 不在于获取操作本身。这个 bug 在于,“未发现问题”和“无法检查”产生了相同的输出,而这两者中只有一个是事实。

反转门的形态

一次检查有三种可能的结果,但大多数实现只编码了两种。它可以发现问题、未发现问题,或者未能检查。第三种情况是安全漏洞所在,因为最简单的检查编写方式会使“未能检查”与“未发现问题”无法区分。

考虑一下 shell 版本,这类问题通常就是从这里开始的:

curl -s "$URL" | grep -iEf patterns.txt && echo "BLOCKED"

该行命令中的每个故障都会导致静默。一个 404 错误会使 curl 得到一个空响应体和为 0 的退出码,因为 -s 只会静默进度信息。DNS 解析失败会得到一个空响应体。一个空的模式文件会使 grep 匹配不到任何内容。管道的退出状态只反映最后一条命令的状态,因此即使 curl 完全失败,grep 仍然会报告“没有匹配项”。四种不同的故障,一个无法区分的结果,而这个结果恰恰是让帖子通过的那个。

多数门遗忘的第三种结果 匹配 不匹配 无应答 找到了 清洁 无应答 阻止 发布 阻止 // 大多数门 // 将此行上报

这种模式并非内容过滤器所特有。一个将连接错误视为“未报告错误”的健康检查。一个在不匹配任何文件的 glob 模式上调用的 linter。一个其规则集下载失败的安全扫描器。在每种情况下,机制本身都运转正常,报告显示为绿色,但这个绿色所代表的含义与仪表盘所暗示的恰恰相反。

同一项检查失败的四种方式

所讨论的检查以四种独立的方式失败了。这一点值得坦白说明,因为其中任何一种方式本身就足以导致失败,而且任何一种方式本身都难以察觉。

检查读取的是另一个文档

最初的检查通过一个辅助工具获取页面,该工具在返回页面前会将其从 HTML 转换为 Markdown。这样做便于阅读,但对于匹配却是致命的,因为匹配模式是针对标记语言编写的。属性、元标签和结构化数据块在转换过程中会丢失。检查运行后,什么也没找到,并且从未看到那些最可能携带意外标识符的字段。

这个教训不仅限于这一个辅助工具。过滤器是针对一种表示形式编写的。如果在源和过滤器之间有任何东西重写了该表示形式,那么过滤器检查的就是一个在生产环境中根本不存在的文档。获取你的用户所得到的字节。

噪音太大,无人读取信号

同一项检查在每个页面上都会报告七八个匹配项,而且总是相同的几个:嵌入在广告脚本中的发布商 ID,该 ID 根据设计出现在网站的每个页面上。

一个每次运行时都“狼来了”的检查,最终将无人问津。更糟糕的是,它会训练操作员添加一揽子抑制规则,而一揽子抑制规则正是让真正匹配项消亡的地方。

本能的做法是将搜索范围缩小到文章正文。经过衡量,发现这种做法是错误的,而且错得更危险。广告也会在文章元素内部渲染,因此缩小范围只减少了一半的误报,同时却新排除了标题、元描述和结构化数据,这些内容源自作者本人的文字,也恰恰是标识符可能出现的地方。

解决方法是消除噪音,而不是缩小范围。剥离 script、style、iframe 和 noscript 块,展开广告容器标签而不删除其文本,并通过在移除脚本前提取结构化数据、移除后再追加回去的方式来保留它。在相同页面上测量的结果是:之前有 7 个匹配项,缩小范围后有 4 个,而在消除噪音并完全覆盖后为 0 个。

模式文件被允许为空

如果拒绝列表加载失败,模式集就为空,而空的模式集不会匹配任何内容。这与一个真正干净的页面一样,结果都是“干净”。

这个问题修复成本低廉,但几乎从未有人这样做:

PAT="$(grep -vE '^\s*#|^\s*$' denylist.txt)"
[ -n "$PAT" ] || { echo "empty denylist, cannot verify"; exit 1; }

检查所依赖的任何输入都应得到同等对待。如果用于定义“坏”的那个东西可能会丢失,那么它的缺失必须被视为一个错误,而不是一个结论。

从来没有人测试过门禁会失败的情况

最深层的问题在于,这一切都不是假设。将检查指向一个不存在的 URL,并观察到它报告“干净”,大约一分钟就能发现这个问题。

门禁总是在“通过”的方向上得到测试。有人写了一篇文章,运行检查,看到它通过了,然后就发布了。而“失败”的方向却很少被演练,因为制造一个真正的违规行为感觉像是在干活,而且还有点危险。因此,那个至关重要的分支,那个必须阻止发布的分支,恰恰是唯一一个从未运行过的分支。

这种情况之所以是系统性的而非疏忽,是有原因的。为了测试而制造一个真正的违规,意味着要编写包含真实机密的文本,而没有人愿意提交这种内容。将检查指向一个损坏的目标,意味着要故意破坏某些东西。这两种做法都感觉像是在制造问题,而检查本身又似乎工作正常,所以这项工作就被无限期地推迟了。

两个习惯可以永久性地解决这个问题,而且都能避免那种不适感。

在 CI 中用一个已知的“坏”样本来测试门禁。这个样本是一个小文件,其中包含你拒绝列表中的一个术语,外加一个断言,确保检查在遇到它时以非零状态退出。该样本从不发布,从不接触真实页面,并且如果有人破坏了模式加载或退出码,它会明确地报错。如果提交一个真实的术语让你感到困扰,可以使用一个专门为此目的而存在于拒绝列表中的哨兵条目。

用一个已知的不可达目标来测试门禁。将它指向一个保证会返回 404 的 URL,并断言它报告的是失败,而不是“干净”。仅这一个断言就能在第一天捕获到最初的那个 bug,而且只需要一行代码。

这两种测试有一个值得一提的共同属性:它们在没有任何真实内容的情况下演练了门禁。“失败”方向的测试之所以被跳过,通常是因为人们以为需要一个真实的违规场景,而一个样本外加一个坏的 URL 则完全消除了这一要求。

让失败更响亮

重写检查以分离这三种结果,主要在于拒绝让管道将它们合并:

BAD=0
for L in "${LANGS[@]}"; do
  HTML="$(curl -fsSL "$BASE/$L/$SLUG")" \
    || { echo "fetch failed: $L" >&2; BAD=1; continue; }
  [ -n "$HTML" ] || { echo "empty body: $L" >&2; BAD=1; continue; }
  CLEANED="$(printf '%s' "$HTML" | strip_noise)" \
    || { echo "cleanup failed: $L" >&2; BAD=1; continue; }
  if printf '%s\n' "$CLEANED" | grep -qiEf <(printf '%s\n' "$PAT"); then
    echo "match: $L" >&2; BAD=1
  fi
done
[ "$BAD" -eq 0 ] || retract

每个可能失败的步骤都会设置与真实匹配相同的失败变量。curl -f 会将 HTTP 错误转换成退出码,而不是返回一个空响应体。每个阶段都在其自己的命令替换中运行,因此失败是可捕获的,而不会被管道的退出状态所吞没。不存在从“出错了”到“干净”的路径。

由此得出的通用规则是:检查应该返回“已验证安全”,而绝不是“未发现有害证据”。这两者听起来相似,但在机制失灵时,其行为方式却截然相反。前者要求检查确实已经运行,查阅了正确的文档,并且有可供比较的“坏”的定义。后者是一个空字符串就能免费且永久地给你的结果。

关于“故障时关闭”(fail-closed),有一个需要注意的地方,因为它不是没有代价的。一个在每次瞬时错误时都会阻塞的门控,也会在网络抖动时阻塞,而如果阻塞的代价很高,团队就会设法绕过它。有两件事可以使其变得可控。在宣布失败前,重试瞬时部分(例如抓取操作),这样一次连接中断就不会阻止一次发布。并且,通过保存工作成果并准确报告哪个阶段失败了,来降低从阻塞状态中恢复的成本。当一次错误阻塞的代价只是一分钟的困惑时,“故障时关闭”是可持续的;而当代价是一小时的重建工作时,它就是不可持续的。

今天就可以进行的一项简短审计

选取你拥有的任何一个自动化关卡,然后问四个问题。

如果它检查的对象缺失或无法访问,它会怎么做?如果答案是“通过”,那么你就存在这个 bug。

它检查的是你的用户收到的相同表示,还是某个辅助程序在此过程中重新格式化的东西?

如果其配置、拒绝列表或规则集加载失败会怎样?空的规则集必须是错误。

它上一次有意地失败是在什么时候?如果答案是“从不”,你就不知道它是否能够失败。

这个故事中的流水线现在会在所有四个方面都朝安全的方向失败,而修复它的成本不到一个下午。昂贵的部分不是修复本身,而是那几个月毫无意义的绿色对勾。