从 #4653(双源 C5)落地时掉出来的范围外发现,不在 #4653 的 PR(#4662)里修,单独立案。归 #4650「基线手编漏洞」的加固范围。
事实:authorable-surface.json 在 main 上不是生成器的输出
scripts/build-schemas.ts:491 用 JSON.stringify(updated, null, 2) 写这个文件。JSON.stringify 不转义非 ASCII,所以生成器写出的 description 行里是字面 —(U+2014)。
同一个脚本、同一个写法写出的姊妹文件 json-schema.manifest.json,在 main 上确实是字面 —。但 authorable-surface.json 在 main 上是转义形式 — —— 也就是说,它在某一刻被生成器以外的东西写过。
#4662 里我只跑了 pnpm gen:schema,没碰这个文件,而它的 description 行就自动变回了字面形式:
- "description": "Ratchet of every AUTHORABLE key in the spec — what a metadata author may write, ...
+ "description": "Ratchet of every AUTHORABLE key in the spec — what a metadata author may write, ...
转义是什么时候进来的
逐 commit 检查该文件首行的转义状态:
| commit |
状态 |
355e951e1 |
字面(生成器输出) |
a2cd18af1 — #4587 → PR #4603(双源 C2) |
字面(生成器输出) |
e533b0b1b — #4583 → PR #4601(retire datasource.capabilities) |
转义(非生成器输出) |
0c0fbd952 / c13350b47 / 0a936ea62 / 21676eb5d |
转义,一路带到 main |
e533b0b1b 同时把 key 数从 8304 → 8291(−13)。那正是一个 retire 单,减 key 本身合理;但减 key 的同时该文件不再是生成器输出,说明这一版是手工写/手工改出来的,而不是 gen:schema 重写出来的。
为什么这值得单独记一笔
#4535 §2 与 #4650 已经指出「删基线行就是删证据,门禁不会拦」,但那是推断;这里是实锤:文件在 main 上的字节形态自证它被生成器以外的东西写过,而且是在一个减 key 的 commit 里。
顺带说明这个洞的检测成本几乎为零 —— 只要要求该文件必须与 JSON.stringify(gen(), null, 2) 逐字节相等,手编就无所遁形,不需要去判断被删的行是不是 aged-out tombstone。建议 #4650 的加固至少包含这条字节级 round-trip 检查,它比语义检查更难绕过。
(#4662 已顺手把该行带回生成器形态 —— 那是 gen:schema 自己写的,不是我改的。)
关联:#4650(门禁加固本体)、#4535 §2、#4601 / #4583、#4653 / #4662、ADR-0104
从 #4653(双源 C5)落地时掉出来的范围外发现,不在 #4653 的 PR(#4662)里修,单独立案。归 #4650「基线手编漏洞」的加固范围。
事实:
authorable-surface.json在 main 上不是生成器的输出scripts/build-schemas.ts:491用JSON.stringify(updated, null, 2)写这个文件。JSON.stringify不转义非 ASCII,所以生成器写出的 description 行里是字面—(U+2014)。同一个脚本、同一个写法写出的姊妹文件
json-schema.manifest.json,在 main 上确实是字面—。但authorable-surface.json在 main 上是转义形式——— 也就是说,它在某一刻被生成器以外的东西写过。#4662 里我只跑了
pnpm gen:schema,没碰这个文件,而它的 description 行就自动变回了字面形式:转义是什么时候进来的
逐 commit 检查该文件首行的转义状态:
355e951e1a2cd18af1— #4587 → PR #4603(双源 C2)e533b0b1b— #4583 → PR #4601(retiredatasource.capabilities)0c0fbd952/c13350b47/0a936ea62/21676eb5de533b0b1b同时把 key 数从 8304 → 8291(−13)。那正是一个 retire 单,减 key 本身合理;但减 key 的同时该文件不再是生成器输出,说明这一版是手工写/手工改出来的,而不是gen:schema重写出来的。为什么这值得单独记一笔
#4535 §2 与 #4650 已经指出「删基线行就是删证据,门禁不会拦」,但那是推断;这里是实锤:文件在 main 上的字节形态自证它被生成器以外的东西写过,而且是在一个减 key 的 commit 里。
顺带说明这个洞的检测成本几乎为零 —— 只要要求该文件必须与
JSON.stringify(gen(), null, 2)逐字节相等,手编就无所遁形,不需要去判断被删的行是不是 aged-out tombstone。建议 #4650 的加固至少包含这条字节级 round-trip 检查,它比语义检查更难绕过。(#4662 已顺手把该行带回生成器形态 —— 那是
gen:schema自己写的,不是我改的。)关联:#4650(门禁加固本体)、#4535 §2、#4601 / #4583、#4653 / #4662、ADR-0104