代码强制
概述
守卫的好坏取决于当有人试图绕过它时它拒绝什么,而不是它是否通过了快乐路径。能通过审查的两个失败模式是:一个控制检查的是危险值的表示形式而非值本身,以及一个控制因无法运行而返回成功。这两者在基于作者自身假设编写的每一个测试中看起来都是绿色的。
用法
在编写或修改钩子、守卫、验证器、权限检查、lint 规则或 CI 关卡时,请遵循以下步骤 — 在打开文件之前,因为步骤1通常会改变代码所属的位置。
典型场景:“在仓库之外阻止 rm -rf”(步骤1将决策从命令字符串上移开),“当迁移没有回滚时使 CI 失败”(步骤3和4阻止关卡在迁移列表为空时通过),“拒绝没有签名提交的推送”(步骤5枚举了别名、--no-verify 和第二个远程仓库)。
步骤
- 命名被保护值(Sink)。 写下实际到达你要保护的对象的确切值:展开的 argv、请求体、SQL 参数、解析后的路径。验证该值。
- 检查你能否真正看到它。 如果代码只有该值的表示形式 — 一个 shell 命令字符串、一个 diff、一行日志、一条提交消息 — 那么该决策是一个启发式判断,而不是一个检查。在注释和交付说明中说明这一点,并将该层标记为 advisory(建议性)。不要让一个启发式判断成为你无法撤销的后果的唯一屏障。
- 失败时关闭(Fail closed)。 缺少运行时、输入不可读、解析器异常、未解析的变量、空结果:拒绝,并指出缺少了什么。一个因无法运行而退出0的守卫就是一个被禁用的守卫,并且它会静默地在几个月内保持禁用状态。
- 保持探测的失败可见。 在决定守卫是否能够运行的命令上不要使用
2>/dev/null,也不要让一个将空结果变成成功的分支。如果允许探测静默失败,步骤3将无法强制执行。 - 在编写修复之前枚举绕过途径。 列出控制如何被满足或跳过:复合命令、重复的标志、第二次调用、别名或函数、缺少解释器、另一台主机、子 shell、重命名的文件。将列表保留在提交正文或测试文件中。
- 将该列表中的每一项转换为一个测试用例,并接入项目的自动化检查。手动自测不算数:它会随着运行它的会话一起消失。
- 当绕过代价低廉且后果严重时,进行对抗性测试。 派遣一个拥有全新上下文的子代理,其任务是打破守卫,而不是审查它;其输出是尝试列表及其结果。对于最坏情况只是警告的控制,跳过此步骤。
陷阱
- 解析 shell 命令来做决策。 引号、
$()、&&、别名和env前缀都会改变最终执行的内容。这样的层只是一个减速带;真正的检查应放在命令效果产生的地方。 command -v守卫。if command -v tool > /dev/null; then check; fi在任何缺少该工具的机器上都会静默跳过检查 — 恰恰是最需要它的机器。反转它:当工具缺失时拒绝。- 发现步骤找不到东西。
git grep -E不支持\b,因此这样的模式会匹配零个文件而不报错,并且关卡在读取了空结果后通过。用一个诱饵文件保护该步骤:一个你知道必须出现在其输出中的文件。 - 仅测试你实现的拒绝情况。 有趣的测试是你没想到的输入;这就是步骤5产生的。
- 仅通过手工测试的守卫。 它在作者的机器上通过,然后就会腐烂。如果项目没有地方接入测试,那么缺少的测试夹具本身就是工作的一部分。
- 编写守卫的上下文无法打破它。 它共享了产生漏洞的盲点,并且会确认自己的设计。只有全新的上下文才具有对抗性。
约束
- 当受保护的值本身可以到达时,永远不要对其代理做决策。
- 在守卫无法完成其工作的路径上,永远不要退出0;拒绝并指出原因。
- 永远不要压制确定守卫能否运行的探测的错误。
- 在交付之前,必须为每一个枚举出的绕过途径编写自动化测试。
- 明确说明哪一层是 advisory(建议性)的,哪一层是 enforcing(强制性)的;一个伪装成保证的启发式判断比没有守卫更糟糕。