# 为什么相似的审批请求很危险？

只有当用户能看清一次点击究竟授权了什么时，人工审批才有安全价值。如果两个请求在屏幕上看起来一样，但正文、标头、运行身份或命令不同，那么审批就成了表面功夫。用户并没有检查这项操作，只是认出了一个熟悉的标签，然后继续点击。

在代理工具中，这种问题很容易出现，因为可见摘要通常从最不重要的字段开始：标题、主机名、方法，也许再加一条简短命令。这些字段能帮助用户快速定位，但不能定义权限。向同一端点发送 POST，既可能创建无害的草稿，也可能发布生产变更。SSH 命令既可能打印版本，也可能删除目录。即使 URL 中的每个字都没变，只要请求带着不同的租户标头，就可能越过边界。

我见过审批流程因为一些善意的设计而失去作用。有人希望减少打扰，于是把相似调用归为一组。有人希望卡片更整洁，于是折叠请求正文。有人希望分析数据使用稳定标签，于是把同一个标签用于一类操作中的每个请求。等审核者看到第十张熟悉的卡片时，系统已经让他们形成了一个习惯：细节并不重要。攻击者或存在缺陷的代理只需要藏起一个差异。

## 相同标签可能隐藏不同权限

标题和目标地址只是线索，不是用户正在授权的对象。把它们当作身份，会形成一类碰撞：许多不同操作被压缩成一个容易识别的提示。

设想一个部署服务，它只有一个端点：`POST /v1/releases`。代理先提交一个发布草稿，稍后再请求发布。如果两张卡片都写着“发布请求”，并显示 `api.example.test`，审核者就必须打开详情抽屉，阅读正文才能区分它们。实际上，重复出现的卡片会让人觉得抽屉里的内容每次都只是同样的噪音。发布请求往往就在这种习惯形成之后到来。

更常见的工作中也会出现同样的问题。源代码管理 API 可能使用同一条路径来更新拉取请求标题、修改合并设置或替换审核者。云 API 可能通过一个字段，在同一资源路径下区分试运行和实际变更。工单系统端点可能根据标头，把内容写入私有事件记录或公开状态页。目标地址只能告诉你请求送到了哪里，不能告诉你远程服务会做什么。

对于命令，虚假的相似性通常始于一条友好的显示文本。下面这些命令不应该共享审批身份：

- `find build -type f -delete` 和 `find build -type f -print`
- 从个人克隆仓库和发布检出目录运行的 `git push origin HEAD`
- 使用创建草稿的 JSON 正文和使用发送消息的 JSON 正文执行的 `curl -X POST`
- 使用只读账户和使用有权修改服务文件的账户执行的 `ssh deploy@host`

人们容易混淆的是相似与等价。相似表示两个操作有足够的共同点，可以在视觉上归为一组。等价表示一次审批确实可以覆盖另一个操作。前者是展示选择，后者是在授予权限。把两者混在一起，紧凑的界面就会在无声中扩大权限范围。

提示可以为了易读性把重复操作归为一组，但不能让这种分组决定之前的点击是否适用于后续操作。授权检查需要使用比卡片标签更严格的对象。

## 审批必须绑定实际执行的请求

获得批准的对象应是执行器将要发送的操作的规范化描述，以及发送请求时使用的身份上下文。只要任何与权限有关的字段发生变化，系统就必须再次询问，或者明确指出变化的字段并要求用户重新决定。

对于 HTTP，这种描述通常包括方法、协议和主机、规范化路径、查询参数值、正文的字节内容或明确定义的规范化正文形式、选定的标头、凭据引用，以及代理运行身份。不要为了做到这一点，就把秘密值放进显示内容或日志中。像 `payments-production` 这样的凭据引用可以标识权限，却不会暴露令牌。执行器可以将审批绑定到它实际使用的内部保险库记录。

标头需要得到比大多数界面更仔细的处理。有些标头只影响传输层装饰，另一些则会选择组织、启用管理模式、设置幂等范围、选择区域账户，或改变接收方解释正文的方式。应明确列出那些可以省略的字段，因为它们不会影响远程操作。其余字段都应保留在规范化身份中，直到有人证明省略它们不会带来风险。

对于命令，应根据执行计划构建描述，而不是根据 shell 字符串构建。执行计划包括可执行文件、参数数组、工作目录、适用时的目标主机、用户身份、凭据引用，以及会影响行为的环境变量。shell 字符串是一种有损的展示格式。引号规则、展开结果、继承的变量和变化后的工作目录，都可能让一条看起来相同的命令变成另一项操作。

这时，团队经常会提出一种宽松规则，例如“目标相同、动词相同，就算足够接近”。这种规则很受欢迎，因为它能快速减少提示，让演示看起来更顺畅。但它是错的，因为 HTTP 动词描述的是宽泛类别，不是实际后果。`POST` 不是权限类别，`ssh` 也不是。

使用请求指纹绑定决定，但不要把指纹作为唯一证据展示。摘要非常适合在审批引擎内部检查是否相等，人则需要看到导致结果的字段。两者都提供：在卡片上展示结构化、易读的差异，并在卡片背后保留精确的规范化身份。

## 重复并不能证明调用安全

一次重试可能与之前的尝试共享所有执行字段，但它与无关的重复操作仍然需要不同处理。系统应先判断重复的原因，再决定是否可以跳过提示。

最安全的重复调用情况是传输层重试：执行器知道第一次请求从未到达远程服务，或者远程服务提供了与精确操作绑定的幂等机制。即便如此，审批引擎也应将重试绑定到原先批准的操作，并记录重试关系。不能只因为最近的历史记录中某处出现了两个相同哈希，就简单放行。

网络故障结果不明时发生的重复调用则不同。连接中断之前，远程服务可能已经接受了第一次调用。再次发送可能导致第二封邮件、第二个问题单，或重复扣费。审核者需要看到明确说明：上一次尝试的结果未知。一张只写着“正在重试请求”的熟悉卡片，隐藏了他们需要做的判断。

来自另一个运行的后续相同操作又是另一种情况。它可能来自新的代理进程、新代码、复制出的终端会话，或同一台机器上的另一个人。按运行进行授权，是因为进程身份和生命周期都很重要。跨运行复用旧审批，会把一次有边界的决定变成长期授权。

在模型中使用相互独立的状态：

1. 用户批准了一项精确的计划操作。
2. 执行器尝试执行该操作，并知道请求是否已发送。
3. 远程端报告成功、失败或未知结果。
4. 后续操作声称与第一项操作存在关系。

不要把这些状态压缩成一个绿色徽章。审批记录的是意图，传输结果记录的是送达证据，远程响应记录的是结果。它们回答的是不同问题，审计记录应保留这三者。

## 正文需要语义检查和字节级绑定

请求正文可能承载一次 API 调用的全部后果，因此把它隐藏在“已附加负载”这类通用标签后面，是设计错误。应先提供易读形式供审核，再将审批绑定到执行器实际发送的精确表示。

JSON 会让这件事变得棘手，因为它的外观和含义可能不同。对象字段的顺序通常不会改变接收方的解释，但数组顺序可能完全改变结果。空白通常不重要，但包含空白的字符串则不同。服务进行宽松或自定义解析时，写成 `1` 的数字可能会与 `1.0` 得到不同处理。不要自创一个通用 JSON 规范化器，然后假定它能保持语义不变。

一种实用方法分为两层。第一，保留即将发送的正文字节，并对其计算加密摘要，用于授权绑定。第二，将已知内容类型解析成展示树，让字段易于阅读。如果解析器无法安全解释正文，就显示转义后的片段、长度和摘要；当调用具有实际后果时，还应要求用户展开正文后再批准。

使用专门设计来迷惑粗略审核的样例对进行测试。下面两份测试文件共享标题、方法和目标地址，但必须产生不同的指纹，并在审批视图中显示出差异。

```json
{
  "title": "Release request",
  "method": "POST",
  "url": "https://api.example.test/v1/releases",
  "headers": {"Content-Type": "application/json"},
  "body": {"version": "2.4.1", "state": "draft"}
}
```

```json
{
  "title": "Release request",
  "method": "POST",
  "url": "https://api.example.test/v1/releases",
  "headers": {"Content-Type": "application/json"},
  "body": {"version": "2.4.1", "state": "published"}
}
```

测试不应只断言 `fingerprintA != fingerprintB`。还应断言渲染后的卡片明确指出 `state` 发生变化，用于草稿样例的审批无法通过发布样例的验证，并且活动记录保留正文摘要和字段级展示摘要。最后一项断言可以发现一个常见的修复遗漏：工程师修好了授权检查，却让审核者和调查人员继续面对没有帮助的日志。

二进制正文和表单正文也需要同样严格。多部分上传可能保留文件名和内容类型，却换掉上传的文档。编码后的表单可能把字段中的 `role=user` 改成 `role=admin`，而紧凑卡片完全不显示该字段。只要远程操作重要，正文就重要。

## 标头和凭据会造成隐藏的范围变化

用于发送请求的凭据属于操作的一部分，即使 URL 和正文逐字节都没有变化。一张写着“更新发票”的卡片，如果隐藏了使用的是沙箱凭据还是生产凭据，就等于要求审核者盲目批准。

绝不要显示 bearer token、密码、私钥或自定义秘密标头。应为每条秘密记录分配稳定、易读的标签，并显示标签、账户类别，以及操作者主动配置的权限警告。审核者可能不需要看到令牌文本，但必须知道凭据是否从 `billing-read` 变成了 `billing-admin`，或操作是否改用另一个 SSH 账户执行。

自定义标头最容易制造意外，因为它们常常看起来只是传输细节。`X-Organization` 值可以把原本熟悉的请求重定向到另一个租户。`X-Mode: live` 标头可以让操作从模拟变成真实执行。`Idempotency-Key` 可以决定接收方把请求视为重放还是新指令。当这些字段会影响范围时，应将它们放入渲染后的比较区域。

脱敏必须保留变化检测能力。把所有敏感值替换成 `***` 只有在界面仍能指出秘密引用发生变化时才可接受。如果两个不同的秘密都显示为 `***`，你就亲手制造了一个相似请求碰撞。应显示非秘密标签或稳定的内部别名，绝不能显示秘密本身。

有用的测试样例集应一次只改变一个维度：请求不变但凭据引用不同，凭据不变但组织标头不同，标头不变但查询值不同，以及所有变化同时发生。单维度样例能找出规范化器遗漏的字段，组合样例则能发现界面代码在第一个差异之后截断其余变化。

## 会话能说明是谁再次发起请求

审批也是对调用者的判断。同一操作由新的代理进程发起时，不会因为共享工作目录或可执行文件名，就自动继承你对之前进程的信任。

代码签名权限很有用，因为进程可以声称任何友好的名称。但这仍不意味着来自该进程的所有请求都相同。受信任的进程可能运行经过修改的提示，读取被投毒的仓库文件，或遵循操作者并未打算执行的指令。进程身份回答“是谁启动了这次运行”，请求绑定回答“这次运行将做什么”。两者都需要。

在进程启动时记录会话标识，并将其附加到每项提出和执行的操作上。进程退出后，其临时授权也应随之结束。如果出现新进程，即使标题和目标地址与最近的操作相同，也要显示新的审批。不要因为两个进程拥有相同的可执行文件路径，就认定它们是同一个进程。更新器、复制的二进制文件和本地开发构建都会让这种捷径变得不可靠。

对长期运行的工具要保持谨慎。一个运行数日的进程并不会因为尚未退出，就值得获得无限期的全面审批。对于敏感凭据或具有不可逆后果的操作，应要求每次调用都获得批准。对于低风险的重复工作，可以将权限限制在明确的会话内，并在日志中显示该会话。操作人员应能直接撤销正在产生异常请求的运行，而不必在更宽泛的账户设置中寻找。

Sallyport 默认采用按会话授权，也可以要求每次使用选定保险库条目时都进行确认。相比一堆事故发生时无人能够还原的例外规则，这种明确的划分更容易理解。

## SSH 命令需要执行计划，而不是漂亮的字符串

Shell 文本特别容易伪装成它自己。审核者看到的是一条紧凑命令，而 shell 可能会解析别名、展开变量、继承目录，并使用显示内容中没有指明的凭据进行连接。

如果执行器接受直接的参数数组，就保持这种方式。将可执行文件和每个参数分别展示，再补充解析后的工作目录、主机、远程用户和凭据标签。如果必须调用 shell，就明确说明这一点，并显示精确的 shell 程序和脚本字节。美化 `sh -c` 内容却隐藏 `sh -c` 本身的卡片，恰恰遗漏了最重要的部分。

下面这些命令看起来相关，但授权的行为有实质差异：

```sh
ssh deploy@ops.example.test 'systemctl status web'
ssh deploy@ops.example.test 'systemctl restart web'
```

在这个简单样例中，动词差异是可见的。真实故障往往更难察觉：变量展开成另一个主机，远程路径来自环境变量，或者命令替换从代理先前修改过的文件中读取指令。尽可能在展开后捕获值。如果展开发生在远程端，或无法安全解析，就说明这一限制，并要求用户更谨慎地审批。

不要把读操作和写操作当作总能从命令文本中推断出的属性。`cat` 可能触发带有副作用的设备读取。看似无害的客户端也可能运行远程钩子。能使用语义明确的命令族时，应优先使用它们；面对含义不明确的 shell 执行，则要求人工审批。假装能够精确分类每条命令，只会制造虚假的信心。

## 用碰撞样例对测试边界

审批测试应从用户可能误认为同一操作的样例对开始。只验证正常流程，即请求能获批并发送出去的单元测试，几乎无法应对这类失败。

先列出所有与权限有关的维度，再为每个维度制作一组配对样例。大多数情况下固定标题和目标地址，目的是证明引擎不会错误地把这些方便的标签当成身份。

| 变化维度 | 要测试的样例对 | 预期结果 |
| --- | --- | --- |
| 正文 | 草稿与发布字段 | 新审批，并显示正文差异 |
| 标头 | 一个组织值与另一个组织值 | 新审批，并显示租户 |
| 凭据 | staging 记录与 production 记录 | 新审批，并显示凭据标签 |
| 会话 | 新进程发出的相同请求 | 新运行审批 |
| 命令上下文 | 参数相同但工作目录不同 | 新审批，并显示目录 |

在三层运行这些样例。在规范化层断言身份不同。在授权层尝试使用针对样例 A 签发的审批提交样例 B，并预期请求被拒绝。在界面层获取快照或结构化无障碍捕获结果，并断言变化字段无需打开二级视图就能显示。只有点击几次后才出现的差异，大多数审核者都会错过。

然后加入变异测试。在测试分支中逐个从规范化身份中移除字段，并确认碰撞测试会失败。有人重构请求对象后，可能悄悄停止将标头、工作目录或凭据标签传入审批适配器。这个做法听起来很琐碎，但它能让这种遗漏立即暴露。

Sallyport 的操作路径会将秘密保存在加密保险库中，并把结果返回给代理，而不是返回秘密材料。这种分离有利于测试，因为测试样例可以通过标签引用凭据记录，同时确认代理从未收到实际凭据。

## 一次看似无害的首次调用也可能开启失败

设想一个负责维护发布说明的代理。它通过一个熟悉的端点创建草稿，操作员检查正文后批准了第一张卡片。随后代理对同一草稿进行几次小幅更新。卡片标题仍是“发布请求”，主机也没有变化，操作员逐渐认为每次审批都是例行流程。

后来，一条仓库指令告诉代理“完成发布”。代理只把 `state` 从 `draft` 改成 `published`，并添加了一个选择线上组织的标头。如果界面只显示标题、主机和方法，最后一张卡片看起来就和之前完全一样。如果审批缓存按标题和目标地址对调用分组，系统甚至可能不会再次询问。

这个故事不需要模型遭到入侵，也不需要戏剧性的漏洞利用。它只需要一个遵循错误指令的代理，一个已经习惯重复提示的审核者，以及一个隐藏了两个效果变化字段的界面。这些条件在日常自动化中很常见。

修复必须覆盖整条路径。让变化后的状态和组织清晰可见。将审批绑定到这两个值以及凭据记录。即使其他部分完全匹配，也要把线上请求视为新操作。以操作员日后能够检查的形式记录提出的操作、审批决定和已发送请求的身份。只修复卡片或只修复缓存，都会给碰撞留下另一条路径。

## 摩擦应放在有意义的差异上

只要系统诚实地展示例行操作，并且只在权限变化处增加摩擦，人们就能批准许多日常操作。这需要一条清晰规则：在已知且受限的情况下，完全相同请求的重试可以免去提示；正文、标头、凭据、会话、命令上下文或目标发生变化时，都必须重新决定。

不要用更多警告颜色、更长的文字或更吓人的确认按钮，来弥补卡片信息模糊的问题。这些手段会拖慢日常工作，并让人习惯忽略视觉噪音。应将与决定有关的值放在稳定布局中，用直白语言标出变化，并保留足够细节，让用户能将当前请求与上一次批准的请求进行比较。

如果发现一组操作确实可以共享审批，就在代码和测试中记录等价性声明。例如，幂等重试可能携带一个不可变的操作标识，而远程服务保证只执行一次。这是一项范围狭窄、且有证据支持的声明。“它们在界面上看起来相似”不是证据。

先收集最近十次标题和目标地址相同的审批。比较它们的规范化请求字段、凭据标签和会话 ID。如果发现审核者在主卡片上看不到的差异，那么你已经发现了授权缺陷，即使它还没有被利用。
