# 审批卡遮盖必须保留操作决定所需的信息

审批卡只有一个任务：帮助人判断某个具体操作是否可以离开自己的机器。如果卡片为了保护凭据而隐藏了足够多的细节，连请求将做什么也看不出来，那么它就没有完成任务。

常见错误是把遮盖当成字符串替换。工程师遮住 `Authorization`，清空 JSON 请求体，然后认为结果安全了。操作员看到的却是 `POST https://api.example.com/...` 和一个“批准”按钮。这不叫知情同意，而是一场空洞的仪式。它会训练人们在本该由人工控制的关键时刻直接点击通过。

好的卡片会保留操作的含义，同时移除那些可能让读卡、截图、记录日志或旁观屏幕的人重新利用秘密的内容。这需要按字段渲染。标头、查询字符串、请求体和目标标识符都需要不同的处理方式。

## 审批卡必须解释操作

操作员应该能在几秒内回答四个问题：谁在请求、请求要去哪里、会做什么，以及会影响哪个对象或范围。只要缺少其中一个答案，卡片就是不完整的，即使所有秘密都隐藏得很完美。

先用协议方法和人类可读的动词一起写出操作行：

```text
POST  api.billing.example  /v1/invoices/inv_7KD2/refund
Action: issue a refund
```

方法很重要，因为 `GET`、`POST`、`PATCH` 和 `DELETE` 带来的预期不同。人类可读的动词也很重要，因为单看方法，操作员无法知道 `POST /v1/invoices/inv_7KD2/refund` 是创建草稿、提交付款，还是触发退款。调用方既然知道预期操作，就不要让操作员根据路由名称猜业务含义。

然后显示真实的目标地址，而不是账户昵称。“Production billing”可以作为辅助信息，但不能替代 `api.billing.example`。请求发错地方时，也可能使用一个熟悉的标签。主机名才是边界，它告诉你哪个服务会收到数据和凭据。

RFC 3986 将 URI 分为 authority、path、query 和 fragment 等组成部分。这个划分很适合审批渲染，因为每个部分传递的决策信号不同。不要把它压缩成一个漂亮的 URL 字符串，再希望之后的遮盖仍能保留正确信息。

卡片还应说明该操作是创建、修改、删除、发布、转移，还是仅仅读取。“PATCH customer record”不如“修改客户的收款地址”清楚。如果请求构建器无法提供这样一句话，就应该修复请求构建器。卡片渲染器无法可靠地从任意 JSON 反推出业务意图。

## 渲染请求清单，而不是美化后的请求

安全的工作单元是一个类型化的请求清单。它在注入凭据和生成审批视图之前，记录代理打算做什么。

一个最小清单可以这样表示：

```json
{
  "channel": "http",
  "method": "POST",
  "destination": {
    "scheme": "https",
    "host": "api.billing.example",
    "port": 443,
    "path_template": "/v1/invoices/{invoice}/refund"
  },
  "action": "issue refund",
  "targets": [
    {"role": "invoice", "display": "inv_7KD2", "sensitivity": "internal"}
  ],
  "query": [],
  "headers": [],
  "body": {
    "media_type": "application/json",
    "fields": []
  },
  "effect": "financial"
}
```

这不是 HTTP 在线传输格式，而是审批渲染器应该读取的对象。区别很重要。在线请求包含注入的凭据、编码后的值和传输细节。清单则带有 `action`、`target role` 和 `effect` 等语义标签，而原始请求并不具备这些信息。

应根据操作员做决定所需的信息来分类每个可显示值，而不是根据它碰巧出现的位置分类。标头中的 bearer token 是秘密。查询字符串中的签名 webhook URL 也是秘密。JSON 请求体中的电子邮件地址可能是个人数据。代码仓库名称可能是目标标识符，为了安全决定必须显示它。

使用一套小而一致的分类词汇：

- `public`：可以原样显示。
- `internal`：用于标识受影响对象时可以显示，但不要复制到范围很广的日志中。
- `personal`：只显示最少的有用形式，通常是标签加部分值。
- `secret`：卡片、日志、剪贴板和错误文本中都不能显示该值。
- `opaque`：只有在有助于区分目标时，才显示经过批准的别名或稳定的非秘密引用。

不要给调用方留下不受限制的 `safe_to_display: true` 逃生口。有人会为了调试方便使用它，随后这段路径就会处理生产凭据。代理构造操作时，必须在边界处要求提供具体分类。

## 标头需要名称、用途，几乎从不需要值

标头名称通常比标头值更能帮助操作员判断。标头值往往正是不能放在人类界面上的内容。

显示每个与安全有关的标头名称，并附上简短用途。例如：

```text
Headers
Authorization: bearer credential from vault
Idempotency-Key: generated request identifier
X-Request-Reason: "refund requested by finance"
Content-Type: application/json
```

第一行告诉操作员请求会进行身份验证，凭据来源则告诉他们系统是否使用了预期的已存储秘密。显示 `Bearer eyJ...` 不会增加审批价值，却会制造秘密泄露路径，还会诱使人们比较毫无意义的令牌片段。

OAuth 的 bearer token 规范将 Authorization 请求标头定义为首选传输方式，同时其安全指导将 bearer token 视为需要在传输和存储过程中保护的凭据。审批界面也应采用同样的思路：用户需要知道系统会应用 bearer 凭据，而不是检查凭据本身。

遵循以下标头规则：

1. 如果 `Content-Type`、`Accept` 和 `If-Match` 等安全的协议值会影响行为，就显示它们。
2. 对 `Authorization`、`Proxy-Authorization`、`Cookie`、`Set-Cookie`、签名标头、API 密钥标头，以及被分类为 secret 的自定义标头，只显示名称，不显示值。
3. 对已声明为非秘密的业务上下文，例如 `X-Request-Reason`，只有在值很短且不可能包含个人或秘密内容时，才显示经过限制和转义的值。
4. 如果某个标头缺失会改变决定，就说明它缺失。在覆盖操作中，缺少 `If-Match` 可能很重要。
5. 默认不要渲染所有标头。请求库会添加噪声，而噪声会掩盖真正改变操作的那个标头。

一种常见的错误设计是显示秘密值的前四位和后四位，例如 `sk_live_...9a31`。这种做法看起来谨慎，但对于短值、结构化值、测试密钥以及已经在其他地方泄露的值都不安全。它还会让人误以为自己应该通过秘密片段来识别凭据。应将值替换为“已存储的 API 凭据”或“请求签名”这样的类型说明。

标头也可能伪装成目标标识符。租户路由标头、模拟身份标头或 `X-Account-ID` 都可能改变谁会受到影响。不要因为它是标头就将其隐藏。显示它的作用和安全目标标签，例如 `X-Account-ID: account "Northwind production"`。如果无法将不透明标识符映射为安全标签，就说明将使用不透明账户标识符，并要求对敏感操作进行更谨慎的审批。

## 查询字符串需要比现在更多的警惕

查询字符串会出现在 URL 中，被复制到终端，嵌入错误报告，也经常被看不到请求体的基础设施记录下来。正因为它方便，审批卡才必须谨慎处理它。

RFC 9110 提醒，URI 中的信息可能通过引用、日志和其他渠道泄露，并建议发送方避免在 HTTP 目标 URI 中放入敏感信息。这不是抽象的标准问题。渲染完整查询字符串的卡片，可能会成为额外的泄露渠道，而其中的值本来就不该放在 URI 中。

不要因为请求是 `GET`，就认为所有查询值都安全。应结合名称、声明的类型和操作上下文判断。

```text
GET  api.crm.example  /v2/contacts
Query
status = "active"
owner = "sales-west"
include = "notes"
access_token = [secret, hidden]
search = [private text, hidden]
```

`status` 和 `include` 往往是有用的决策信息。`search` 可能包含姓名、电子邮件地址、医疗术语，或者代理从本地文件中提取的任何内容。`access_token` 显然是秘密，但设计不能依赖明显的名称。有些 API 使用 `sig`、`token`、`key`、`code`、`state`、`assertion`，或使用名称中没有任何提示的供应商专用参数。

除非清单明确将查询值分类，否则默认把它们视为秘密。这比许多 API 调试工具更严格，但审批界面不是调试控制台。读卡的人需要足够的信息来授权请求，而不是逐字节重建请求。

如果重复参数及其顺序会影响语义，就必须保留它们。将查询字符串转换成字典的渲染器，可能悄悄丢失 `tag=urgent&tag=finance`，改变重复值，或者掩盖签名错误。应将其显示为条目列表，而不是映射表：

```text
Query
label = "finance"
label = "urgent"
expand = "line_items"
```

如果被遮盖的查询字段会改变请求路由或授权，就要明确说明。`signature = [signed request value, hidden]` 比空白行更能帮助操作员判断。如果查询中包含不透明的共享链接，不要暴露令牌。已知时显示资源标签，例如 `shared report: Q2 forecast`，否则显示 `shared-resource token present`。

## 遮盖后仍应保留请求体结构

请求体变成 `[redacted]` 后，操作员几乎什么也看不出来。逐字显示每个字段，又迟早会泄露本不该出现在审批界面上的内容。正确做法是结构化遮盖。

将请求体渲染成类型化树。保留对象键、数组数量、数据类型、安全枚举值和选定的目标标签。对不安全的叶节点使用带解释的标记替换。

```json
{
  "invoice": "inv_7KD2",
  "amount": {"currency": "USD", "minor_units": 12500},
  "reason": "duplicate charge",
  "customer_note": "[private text, 84 characters]",
  "payment_method": {
    "id": "[opaque payment method]",
    "token": "[secret, hidden]"
  }
}
```

这样，操作员可以看到该操作会因重复收费退还 125.00 USD，并会让一条私密备注离开机器。这些信息足以判断请求是否符合预期任务，同时不会暴露备注或令牌。

当数字就是操作效果时，应保留数字。隐藏付款金额、席位数量、保留期限、速率限制、权限级别和删除数量，会让审批失去意义。要把这些值当成操作参数，而不是无关紧要的数据。包含 `{\"purge\": true}` 的 `DELETE` 请求体必须显示 `purge: true`，否则卡片就隐藏了不可逆的部分。

文本需要单独处理。自由文本可能包含源代码、客户数据、粘贴的秘密，或会改变操作的指令。显示任意预览很有诱惑力，因为它能帮助操作员发现荒谬内容，但也会把审批窗口变成数据外泄面。对于未分类的自由文本，显示字段名称、字符数和目标作用。只有调用方将字段标记为 public 或 internal，且渲染器会转义控制字符时，才显示有限摘录。

数组需要数量和摘要。下面这样不好：

```text
recipients: [redacted]
```

下面这样更好：

```text
recipients: 37 email addresses [personal values hidden]
```

对于破坏性操作，数量会改变决定。对于访问权限变更，应显示角色和数量：`add 4 members to role: billing-admin`。如果审批者需要通过内部标识符区分成员，就显示经过批准的显示名称或别名，不要显示原始 ID。

绝不能只根据字段名称推断敏感度。`password`、`token` 和 `secret` 应进入硬性拒绝列表，但 `content`、`message`、`value`、`data` 和 `metadata` 同样可能包含这些内容。分类必须来自 schema、操作构建器或明确的字段注解。基于名称的过滤只能作为最后一道防线，不能成为主要设计。

## 目标标识符应清楚可读，但不要完全暴露

目标是赋予请求实际后果的对象。它可能位于路径片段、标头、查询参数、JSON 字段或 SSH 命令参数中。即使不能安全显示原始标识符，卡片也必须让目标可见。

将目标的机器引用和人类可读形式分开：

```json
{
  "role": "repository",
  "raw_reference": "repo_01HZX8M9...",
  "display": "payments-service",
  "scope": "production",
  "sensitivity": "internal"
}
```

执行时可能需要原始引用，但卡片中应该显示的是人类可读形式。如果操作会改变权限，应使用说明关系的句子：`Grant deploy permission on payments-service production to the release automation account.`卡片不应要求审批者记住不透明的 ID。

有时原始值是唯一可用的标识符。不要因此显示全部内容。选择稳定且不可逆的引用，例如本地别名，或根据受保护值生成的短审批引用。除非它确实是密码学摘要，并且你了解碰撞和关联风险，否则不要把截短的标识符称为哈希。在很多情况下，`customer record [opaque reference 4F8C]`比假装操作员能一眼验证 `cus_Qa8J7kW2m9` 更诚实。

不要过度遮盖决定影响范围的标识符。隐藏项目名称的 `DELETE /projects/{project}/members` 是一张危险的卡片，即使每个成员的个人标识符都已被遮住。应显示项目名称、环境以及受影响成员的数量，把敏感的个人值留在视线之外。

这里有一个必须区分的地方：隐藏秘密是为了保护机密性，隐藏目标则会削弱授权。团队经常把两者都归为“遮盖”，但它们是不同的任务，卡片也需要不同的规则。

## 审批范围必须与卡片上的信息相匹配

完整的卡片不能授权超出描述范围的操作。如果某人批准的是读取一个代码仓库，那么之后修改代码仓库设置的请求不能因为来自同一个代理进程就悄悄获得授权。

按会话审批和按调用审批回答的是不同问题。按会话审批回答的是：在本次运行期间，这个经过签名的进程是否可以通过网关操作。按调用审批回答的是：这个具体的出站操作，使用这个目标并产生这个效果，是否可以执行。把两者合成一个巨大的权限，会让第一张卡片承担无法承受的重量。

应根据后果制定升级规则。从已知服务读取数据，可能适合纳入会话授权。使用特殊保护凭据、改变访问权限、发送消息、建立财务承诺或删除数据的调用，则需要绑定到具体请求清单的卡片。

审批结果应绑定到规范操作摘要，而不是绑定到可见卡片文本。摘要必须包括方法、规范化目标地址、目标引用、已分类的非秘密参数，以及受保护字段的表示。还应包含足够的元数据，用来检测渲染后请求是否发生变化。不要只绑定到适合截图的摘要。

例如，下面两个调用需要不同的审批，尽管粗糙的渲染器可能让它们看起来一样：

```text
POST /v1/roles/grant
body: role = "viewer", subject = "build-bot"

POST /v1/roles/grant
body: role = "owner", subject = "build-bot"
```

角色不是应该藏在折叠 JSON 视图中的细节，它就是操作本身。如果工程师说卡片变得太拥挤，应先删除装饰性的协议数据，不要删除决定代理是否能接管账户的字段。

Sallyport 正是出于这个原因，将会话授权与逐次调用密钥分开。会话决定可以识别并接纳新的代理进程，而标记为每次使用都需审批的凭据，仍会在每个具体操作前再次询问。

## 遮盖失败通常在渲染前就开始了

假设代理被要求发送一份合同供签署。它构造了以下请求：

```http
POST /v1/envelopes?template=msa&signature=QmFzZTY0U2lnbmVkVmFsdWU HTTP/1.1
Host: api.signing.example
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json

{
  "recipients": [
    {"name": "Maya Chen", "email": "maya@example.com"}
  ],
  "subject": "MSA for Northwind",
  "message": "Please sign the attached agreement.",
  "document": "JVBERi0xLjQK..."
}
```

浅层实现会格式化原始请求，替换 Authorization 值，再截断过长的行。卡片于是暴露了查询字符串中的签名、收件人的电子邮件地址，甚至可能暴露文档文本编码后的开头。截断不是遮盖，只是让泄露变得不那么容易预测。

正确的清单应先分离各个部分：

```text
POST api.signing.example /v1/envelopes
Action: send contract for signature
Target: template "msa"
Recipients: 1 email address [personal value hidden]
Subject: "MSA for Northwind"
Message: public text, 39 characters
Document: 1 PDF attachment [content hidden]
Credential: bearer credential from vault
Request signature: present, hidden
```

这张卡片可以让操作员发现错误的主机、错误的模板、意外的收件人数量或误发操作，同时不会暴露凭据、签名、电子邮件地址或文档字节。

危险版本失败，并不是因为遮盖模式漏掉了 `signature`，而是因为系统把 HTTP 请求当成了可以直接显示的文本。渲染器收到的是一个包含秘密的字符串，没有字段类型、目标作用，也不知道哪些值承载了操作含义。

## 构建默认拒绝的渲染器

渲染器应只接受结构化输入，应用允许列表中的显示规则，并拒绝渲染包含未分类出站字段的操作。这听起来严格，因为它确实严格。未分类字段意味着有人推迟了决定，而审批时刻不是猜测的时候。

一个实用的渲染契约分为三个阶段：

1. 在注入凭据和进行传输编码之前，将计划中的操作规范化为清单。
2. 验证每个字段都有类型、敏感度标签和显示规则。除非调用方明确将未知标头、查询值和请求体叶节点路由到安全的隐藏表示，否则拒绝它们。
3. 渲染固定布局的卡片，为目标地址、操作、目标对象、效果和受保护字段提示保留显眼位置。

不要允许可见值包含 HTML、终端控制字符、Markdown 或任意 Unicode 方向控制符。布局前必须转义它们。恶意值不应能把 `recipient: alice@example.com` 变成误导性行、创建假按钮，或在视觉上重新排列目标标识符。

还要设置显示预算。即使是 public 字符串，也可能长达 50,000 个字符，让卡片无法使用。应按字段类型限制可见文本，说明内容已缩短，并且只为清单声明安全的内容保留受控的详细视图。不要把“显示完整请求”做成通用逃生口。

测试渲染器时，不要只使用正常 API 调用，也要加入恶意案例。测试每个可能位置上的 bearer token、重复的查询名称、包含嵌套数组的 JSON、带有百分号编码分隔符的路径片段、空秘密、短秘密、超长文本字段和包含换行符的值。还要测试有害含义来自布尔值、数量、角色或目标主机的请求。

最后，记录审批覆盖了什么，但不要把明文秘密复制到证据链中。Sallyport 的活动记录和会话记录来自一份加密且带哈希链的审计日志，其离线验证命令无需保险库密钥即可检查链条。这是应追求的标准：审计能力应能说明发生了什么，但不能变成第二个装满可复用凭据的保险库。

卡片应该让错误的操作看起来就是错误的。如果一个人可以批准一项带凭据的请求，却看不到它的目标地址、效果和目标对象，那么系统隐藏的正是他们需要保护的事实。
