# 保留旧证据的审计格式升级

只有当调查人员能够使用昨天适用的规则验证昨天导出的证据时，审计格式升级才算安全。新的解析器显示出看似合理的界面、成功完成的数据库迁移，以及绿色的部署结果，都不能证明这一点。它们经常掩盖真正重要的破坏：记录的字节仍然完整，但它的含义、哈希输入或验证器行为已经改变。

把审计格式当作一个拥有漫长尾部的协议。应用可以每周变化，证据不能。一旦有人依靠某条记录来说明谁批准了某个操作、使用了哪项凭据，或代理向外部服务发送了什么，这条记录就需要在写入它的代码消失很久之后，仍然保持稳定的解释方式。

## 先保留字节，再考虑便利

权威工件是原始记录的字节序列，以及验证它所需的上下文。当前数据库中的解析行只是工作副本。控制面板中显示的 JSON 对象只是视图。两者都不能替代参与签名、哈希链或认证封装的证据字节。

在升级重写字段之前，这个区别似乎有些吹毛求疵。假设版本 1 将 SSH 目标保存为用户提供的字符串：

```json
{"schema_version":1,"event":"ssh.execute","target":"build@prod.example:22","command":"uptime"}
```

版本 2 希望拆分字段，以便按主机和端口筛选：

```json
{"schema_version":2,"event":"ssh.execute","user":"build","host":"prod.example","port":22,"command":"uptime"}
```

这两条记录描述的可能是同一个操作，但它们不是可以互换的证据。v2 编码器可能会规范化主机名、插入默认端口，或拒绝 v1 接受的目标。如果用第二条记录覆盖第一条，你就对旧事件提出了一个新的主张。这个主张可能是正确的，但仅仅指向重写后的数据，无法证明这一点。

请将以下三类内容分开：

- **原始证据**：被接受进入审计序列时的不可变字节，必须完全一致。
- **派生表示**：用于搜索和显示的索引、解码或迁移形式。
- **解释说明**：解释字段、默认值和版本特定行为的文档化规则。

派生表示可以重新生成，原始证据不能。将原始证据存放在只允许追加的证据包或对象存储中，用摘要为它们寻址，并让每个派生条目都指向对应摘要。如果保留规则要求删除某些内容，就把删除本身记录为一个事件。不要悄悄压缩历史，然后把结果称作升级。

即使日志使用加密记录，这一点仍然适用。加密可以保护内容不被未经授权的人读取，但不能让有损迁移变得无害。验证器仍然必须识别产生结果的具体密文、标头和链规则。

## 为每条记录提供明确版本

在哈希或签名之前，将明确的 `schema_version` 放入每条记录。不要从文件扩展名、数据库迁移编号、应用版本，或某个最近新增的字段推断版本。

推断方式在第一次取证导出之前通常都能正常工作。调查人员可能从备份、支持工单或一台不再运行当前应用的机器中复制出一个记录文件夹。周围的上下文已经不完整，但记录仍然存在。自带版本的记录可以告诉验证器需要哪个解码器和哪些规则。

对于规则由你控制的格式，使用一个小整数。将版本 0 保留为无效值，这样缺失字段就不会悄悄变成旧格式。版本字段应承担一个明确而有限的任务：选择记录语法和验证步骤。不要把它变成通用的产品发布编号。

一个持久的封装可以如下所示：

```json
{
  "schema_version": 3,
  "record_id": "01J8X7K5W3H0Q9M6P2R4A1C8ZD",
  "recorded_at": "2026-07-22T14:08:31.482Z",
  "kind": "http.request.completed",
  "previous_digest": "sha256:4a4d...",
  "payload": {
    "method": "POST",
    "authority": "api.example.test",
    "status": 201
  }
}
```

版本必须属于认证输入。如果记录声称自己是版本 3，但计算摘要时没有包含这个字段，那么能够编辑存储字节的攻击者可能会把验证器引向另一条解释路径。凡是会选择解析器、哈希算法、规范化规则或签名算法的字段，都要包含在受保护字节中。

还要将**记录架构版本**与**事件含义版本**分开。前者回答的是「如何解析和验证这些字节？」后者回答的是「这个事件在发出时意味着什么？」

例如，将 `actor` 从自由格式的显示名称改为稳定的进程身份，可能不会改变 JSON 结构，却会改变所作的主张。这不只是架构版本 4，而是语义变化。证据规范需要说明新含义从什么时候开始。字段单位发生变化、时间戳从本地时间改为 UTC，或状态从观察到的响应改为策略决策时，也同样如此。

## 冻结验证步骤，而不只是字段

如果验证器无法还原用于认证记录的精确字节步骤，版本化架构就是不完整的。字段布局只是这套步骤的一部分。

请为每个版本写明：

- 接受的记录语法和必需字段；
- 文本或二进制编码，以及规范化规则；
- 摘要和签名算法；
- 使用的域分隔符；
- 链接规则和创世规则；
- 对格式错误或未知输入的失败行为。

RFC 8785 解释了为什么 JSON 在执行加密操作前需要确定性表示：普通 JSON 允许同一个逻辑值拥有多种序列化形式，而哈希和签名要求字节保持不变。它的 JSON 规范化方案限制了输入，并以确定性方式对对象属性排序。它还通过格式限制提醒我们，重复属性名和超出支持范围的数字并不是可以忽略的小事。

只有明确指定所采用的配置，这个标准才有用。说「我们对 JSON 做哈希」不是一套步骤。说「我们调用当前运行时的序列化器」更糟，因为运行时升级可能在没有任何有意的审计变更时，改变转义、数字格式或排序方式。

二进制格式也有相同问题。RFC 8949 规定了 CBOR 的确定性编码要求，并指出，如果使用此前的排序约定，就需要明确命名兼容模式。验证器不能安全地假定每个历史生产者对「规范」的理解都相同。

不要构建这样一种新验证器：解析任意 JSON，用今天的库重新序列化，然后对结果做哈希。这种模式会以两种方式破坏旧证据。它可能拒绝旧步骤下有效的记录，也可能依据从未产生原始摘要的新步骤接受一条记录。

相反，应将版本分派放在字节边界附近：

```text
read envelope bytes
  -> identify protected schema_version
  -> select verifier V1, V2, or V3
  -> validate that version's grammar
  -> reproduce that version's authenticated bytes
  -> verify digest, signature, and chain link
  -> decode a display model only after verification
```

显示模型最后才出现是有意为之。渲染器可以追求易用，验证器不能凭想象补全信息。

## 未知版本必须默认拒绝

验证器遇到不支持的版本时，必须返回明确的 `unsupported_version` 结果。它不能把未知字段当作可忽略内容，不能假定使用最新布局，也不能运行通用的备用解码器。

工程师经常对此有所抵触，因为他们希望实现向前兼容。应用读取可选的展示字段时，向前兼容很合适。但在证据验证中，某个看起来可选的字段之后可能会控制认证解释，因此这样做很危险。

使用一种能区分证据失败与工具限制的结果结构：

```json
{
  "record_id": "01J8X7K5W3H0Q9M6P2R4A1C8ZD",
  "schema_version": 4,
  "status": "unsupported_version",
  "verified": false,
  "supported_versions": [1, 2, 3],
  "reason": "Verifier 2.7.0 has no verification recipe for schema version 4"
}
```

这个结果表达得很准确：工具尚未确认真实性。它没有指控记录遭到篡改，也没有假装记录有效。无效、不完整、不支持的版本和已验证状态，必须在命令输出和 API 中保持区分。

哈希链还带来另一项兼容性要求。不能只根据最终摘要推断链的历史。验证器需要知道第一条记录、记录排序、父摘要编码和检查点格式各自对应的版本规则。这里可以参考证书透明度的思路：RFC 9162 定义了一致性证明，用来说明较早的树是较晚的树的同一前缀，而不是要求审计人员相信一个新报告的根哈希。

你的审计序列可能不使用 Merkle 树，但这个教训仍然成立。当链规则发生变化时，要在边界处证明连续性。创建一个终止 v1 检查点，其中包含最后一个已验证摘要、记录数和版本。让第一条 v2 记录在定义好的字段中认证这个检查点。v2 验证器应先按照各自规则验证两侧，然后再报告这是一条连续历史。

绝不要把旧摘要作为注释或显示字段保存，以此连接两条历史。连接关系必须成为受保护输入的一部分。

## 迁移应生成派生内容，而不是替换内容

好的迁移会在源证据旁边创建一个新的、可复现的派生内容，并记录足够的来源信息，让其他人能够重新生成相同的输出，再与源证据进行比较。

对于每条迁移记录或每个迁移批次，记录：

```json
{
  "source_digest": "sha256:4a4d...",
  "source_schema_version": 1,
  "migration_id": "audit-v1-to-v2",
  "migration_build": "2.7.0+e31c9f4",
  "output_digest": "sha256:77c8...",
  "migrated_at": "2026-07-22T14:12:09Z"
}
```

`migrated_at` 表示派生内容的时间，而不是原始事件的时间。不要覆盖 `recorded_at`，也不要把生成的 v2 记录呈现得像旧系统发出的记录。与明显的解析器崩溃相比，这个错误在内部调查中造成的混乱更多。

有些迁移无法做到无损。v1 记录可能只有一个 `target` 字符串，而 v2 要求结构化 URI。如果解析失败或存在歧义，就保留源字符串，并记录明确的迁移状态。不要因为新索引需要结构化值，就凭空创造一个。

例如：

```json
{
  "source_digest": "sha256:4a4d...",
  "migration_status": "partial",
  "derived": {
    "target_raw": "build@prod.example:22",
    "host": "prod.example",
    "port": 22
  },
  "unresolved": ["user"]
}
```

这看起来可能没有完整填充的行整齐，但更加诚实。未来的调查人员可以同时看到旧记录说了什么，以及迁移推断出了什么。

不要采用那种流行的建议，把每条旧记录都交给当前写入器处理，然后称之为升级。它之所以流行，是因为这样可以简化一条代码路径，让报告看起来统一。但对于证据来说这是错误的，因为写入器通常会应用当前默认值、删除已弃用字段并规范化值。生成的输出可以用于搜索，但它是翻译，不是原始证词。

## 永久测试样本库能捕捉悄无声息的破坏

兼容性是一项测试资产，不是发布说明中的承诺。为每个已发布的架构版本建立证据语料库，并让每个声称支持该版本的验证器构建都运行它。

语料库不能只有少量正常记录。请为以下情况保留字节完全一致的测试样本：

- 一条普通的有效记录和一条有效的多记录链；
- 该版本接受的边界时间戳、Unicode 文本、空的可选值和数值上限；
- 载荷字节发生变化的记录；
- 父摘要发生变化或序列顺序被重新排列的记录；
- 格式错误、重复、截断以及未知版本的输入。

保存预期结果，而不只是预期解码对象。测试应断言证据结果和诊断类别。一个把有效记录正确标记为无效的验证器仍然是失败的。一个把格式错误的记录变成通用解析器异常的验证器，虽然失败得不那么明显，但它让调查变得更加困难。

使用清单固定测试样本摘要和预期的验证器行为：

```yaml
fixture: v1/0007-http-request.json
sha256: 4a4d5f0c...
expect:
  status: verified
  schema_version: 1
  chain_position: 7

fixture: v1/0007-http-request-tampered.json
sha256: 91af2a7d...
expect:
  status: invalid
  error_code: payload_digest_mismatch
```

然后从多个方向进行测试。

1. 最旧的保留验证器仍必须验证其原始语料库。
2. 当前验证器必须验证所有保留的历史语料库。
3. 候选写入器必须生成当前验证器能够按照新版本接受的记录。
4. 候选迁移必须保留声明的源摘要，并生成预期的派生内容。
5. 每个验证器都必须拒绝为不支持的未来版本设计的测试样本。

测试失败时，不要直接重写预期样本。先检查字节、选择的验证器版本和失败代码。测试样本更新应当很少发生，并且像协议变更一样接受审查，同时附上理由，说明这是有意新增的样本，还是证据发生了变化。

在解析器周围加入属性测试，但不要把它与永久语料库混为一谈。随机输入可以发现崩溃和奇怪的边界情况。命名测试样本则保留你已经付出代价才学到的情况，包括删除敏感内容后的真实发布版本记录。

## 像调查一样测试升级边界

最高风险通常在版本之间的边界，而不是某个版本内部。编写一个从升级前开始、升级后结束的场景，然后问自己：一个独立的人员能否解释整个序列？

假设一条链中，v1 记录了代理会话批准、几次 HTTP 调用和会话撤销。版本 2 引入了更详细的请求结果字段和新的校验和编码。测试应从 v1 创世记录开始，追加有效的 v1 条目，创建文档规定的边界检查点，追加 v2 条目，然后导出完整证据包。

预期的验证报告需要清楚显示过渡：

```text
$ audit verify evidence-bundle
verified v1 records: 18
verified v1 terminal digest: sha256:6c12...e98a
verified v1-to-v2 continuity checkpoint
verified v2 records: 6
chain status: verified
```

现在运行生产升级会造成的失败情况：

- 删除最后一条 v1 记录，但保留 v2 记录；
- 修改检查点中的 v1 摘要；
- 使用一条标记为 v1 版本的 v2 记录；
- 只导出 v2 片段，然后要求给出完整历史判断；
- 使用 v2 之前的验证器验证混合证据包。

正确的系统会给出不同答案。前三种情况无效。第四种情况可以在证据包声明起始检查点时，作为部分片段通过验证，但不能声称完整历史已经验证。第五种情况应在报告能够验证的 v1 证据后返回 `unsupported_version`，前提是命令设计允许部分报告。它不能把整个证据包报告为已验证。

团队通常会在这里发现，日志和控制面板隐藏了源边界。只要界面标记出架构转换，并允许审查人员检查原始封装，将记录合并为一条时间线没有问题。不要让调查人员通过某个字段突然出现，自己猜测格式发生了变化。

## 让验证器足够小，能够比应用活得更久

审计验证器的依赖和权限都应该少于生成记录的应用。如果读取旧证据需要启动图形应用、连接账户、打开凭据保险库，或下载兼容性软件包，那么你的证据计划就依赖于最不该消失时最可能消失的条件。

分开承担这些职责：

- 应用写入记录并展示实时活动。
- 小型验证器读取导出的证据包，选择版本化步骤，并输出机器可读的报告。
- 渲染器可以将已验证的记录转换为表格和时间线，但不参与真实性判断。

让验证器保持确定性。对于同一个证据包和相同的命令选项，它应返回相同的状态码和报告结构。报告中可以包含验证器发布标识，但不能让这个标识改变证据结果。

对于 Sallyport，在升级运行手册中保留 `sp audit verify` 是正确的检查方式，因为它可以在离线状态下，直接对密文验证加密哈希链，而且不需要保险库密钥。在修改应用之前，将命令报告保存到未经改动的导出文件旁边，升级后再次验证同一份导出文件。

「离线」这个词需要严格定义。它意味着验证器可以根据现有证据和内置验证步骤，确认链的结果。它不意味着验证器可以还原缺失记录、判断是谁操作了某台机器，或证明用户理解了某张批准卡。设计良好的报告会准确说明它检查了哪些主张。

将证据格式规范与验证器源代码和测试样本一起发布。没有测试样本库的源代码发布，会让未来维护人员猜测兼容性。没有书面步骤的语料库，也会让他们猜测通过的测试究竟反映了有意制定的规则，还是某个实现中的偶然行为。

## 在第一次紧急情况之前制定停用政策

只有在决定如何处理使用旧格式的证据之后，才能停止支持某种历史格式。这个决定属于安全、法律、运营和调查事件的相关人员，不应成为删除旧软件包时顺带产生的结果。

编写支持表，列出保留的版本、能够读取这些版本的验证器发布版本、预期证据保留期限，以及特殊归档的流程。如果打算停用某个读取器，先提供一个独立的归档验证器，并冻结它的测试样本库。将构建说明和预期校验和与证据文档放在一起。

不要轻易承诺永久支持。算法会老化，操作系统会变化，旧解析器也可能带有安全缺陷。但要保留验证已保留证据的途径。有时这意味着使用只接受本地文件的沙箱归档工具，有时意味着保留一个带有已记录哈希的容器或虚拟机镜像。选择取决于你的环境，必须做到的是，不让未来的调查人员凭记忆重建一套已经消失的工具链。

第一步很具体：导出一小份真实证据包，运行验证器，然后写下命令依赖的每条版本规则。如果你无法描述这套步骤，也无法在测试升级后重现结果，那么你还没有升级计划，只有一个希望旧证据仍然可读的愿望。
