From 653123b111e4bf33f97d48dfb801e7436fe9420d Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 2 Nov 2026 10:03:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(test):=20SECURITY.md=20=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E8=A1=8C=E7=9A=84=E5=B9=B3=E4=BB=B7=E5=AE=88=E5=8D=AB=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E6=81=B0=E5=A5=BD=E4=B8=80=E6=9D=A1=E3=80=8D?= =?UTF-8?q?=EF=BC=88#523=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bullet() 原先用 .find() 只读第一条命中,于是同一条目出现两份时守卫完全看不见—— #509 的 merge(b5c0b09)就把两份 Open reads / Gated routes 一起解了进来,第二份 Gated routes 是严格子集(少 /memory/provenance),CI 四条腿全绿。b1491ff 只删掉了 重复副本,.find() 还在,下一份重复清单照样能合进来而没人校验。 改为先收全部命中(带行号)再断言唯一:空了照旧报「应有以…开头的清单行」,多于 一条直接红并列出行号——报行号是为了避免「哪一份算数」变成要人来回翻文件的新谜题。 四个 prefix 全走这条路,内容侧 missing/stale 两条反向断言一字未动。 回归用例喂合成文档,不碰真文件:逐字相同的两份、以及第二份把 /memory/provenance 换成陈旧值的两份,都必须报「必须恰好一条,实际 2 条」并带行号;另加「只有一份时 不能误伤」。变异自证:把 assert.equal(hits.length, 1, …) 改成 assert.equal(hits.length, hits.length, …) 后新用例立刻红(6 pass / 1 fail), 恢复后 7/7 绿。 --- dsh-mneme/CHANGELOG.md | 2 ++ dsh-mneme/test/security-doc.test.js | 47 ++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index a1359cd2..30b2b9be 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -35,6 +35,8 @@ ## 🔒 安全 +- **`SECURITY.md` 的平价守卫只读第一条清单行,于是「同一条目出现两份」对它完全隐形(#523)**:`test/security-doc.test.js` 的 `bullet(prefix)` 一直用 `doc.split(/\r?\n/).find((l) => l.startsWith(prefix))` 取那一行,而 `.find()` 只回第一命中——「文档是代码 `policy` 声明的投影」这条契约的全部信心都建立在它上面,可只要有人把清单多写一份,多出来的那份对守卫零存在感。这不是假想的形状:#509 的 merge 提交 `b5c0b09` 就把两份 `Open reads` / `Gated routes` 一起解了进来,第二份 `Gated routes` 是严格子集(少 `/memory/provenance`),而 CI 四条腿全绿。`b1491ff` 删掉了重复副本,但那是治症状——`.find()` 还在,下一次 merge 照样能合进来一份没人校验的清单。**修法是把「唯一」变成断言而不是假设**:`bullet()` 先把所有命中收进 `hits`(带行号),空了照旧报「应有以…开头的清单行」,多于一条直接红并把行号列出来——报行号是刻意的,否则第二份被抓住之后,「哪一份才算数」会变成下一个要人来回翻文件才能回答的问题。四个 prefix 全走这条路(`- **Gated routes` / `- **受门控的路由` / `- **Open reads` / `- **开放读`),内容侧那两条 `missing` / `stale` 反向断言一字未动:这次补的是「有没有第二份」,不是「这一份对不对」。**为什么不改成去重后校验合并结果**:把两份并起来再比,等于默许拆分清单(一份写一半),而这张表是给人读的一份东西,拆开之后没有任何一处能读出全貌。回归测试 +1(`test("重复的清单行会被守卫抓住(#523)")`):逐字相同的两份、以及第二份把 `/memory/provenance` 换成陈旧值的两份,都必须报出「必须恰好一条,实际 2 条」并带行号;另加「只有一份时不能误伤」。两条都做过变异自证:把 `assert.equal(hits.length, 1, …)` 换成 `assert.equal(hits.length, hits.length, …)`,新用例立刻红(6 pass / 1 fail),恢复后 7/7 绿。`bullet()` 多出的 `source` 参数只为可测——喂一份合成文档就能验唯一性,不必真去改 `SECURITY.md`。 + - **main 的 Security 变红跟代码无关,是上游两小时前才发的两条公告:`glob@13.0.6` 与 `minimatch@10.2.6` 都落在 devDependency 链上(#533/#534 合并之后暴露)**:2026-10-31 04:42 的 `277779f` 与 03:29 的 `1931508` 两次 push 的 Security workflow 都红,而 CI 四条腿全绿——红的不是测试,是 osv-scanner 那一步:`Total 2 packages affected by 2 known vulnerabilities (0 Critical, 0 High, 2 Medium …)`,`GHSA-3g3m-2fjr-6pw4`(CVSS 6.3,glob 的 AbortSignal 取消绕过,超时后仍继续遍历文件系统,别名 CVE-2026-118773)与 `GHSA-g3hv-75wg-m249`(CVSS 6.9,minimatch 的 globparts 放大 DoS,别名 CVE-2026-118764)。两条公告的 `published` 都是 **2026-10-30T23:09Z**,而 `2139237` 的 Security 是当天 08:36 跑的、当时还是 success;`git diff 2139237 277779f -- dsh-mneme/package-lock.json` **完全为空**——同一个 lockfile 隔了 15 小时从未绿变红,所以这不是回归,是「规则变了」。来源链只有一条:`c8@12.0.0`(devDep)→ `test-exclude@8.0.0` → `glob@13.0.6` + `minimatch@10.2.6`(deduped);`c8` 与 `test-exclude` 都已是 npm 上的最新版,其 deps 仍写 `glob: ^13.0.6` / `minimatch: ^10.2.2`,**上游还没跟上**,所以只能在本仓库 overrides 抬 pin 号——与 `adm-zip@0.6.1`、`brace-expansion@5.0.12` 同一手法(PR #345 那次就是这样清零的)。glob 抬到 14.0.3、minimatch 抬到 11.0.3,`npm audit` 双口径 0 vulnerabilities。**为什么钉死而不是写 `^`**:overrides 的职责是「这个闭包就装这一版」,写范围等于把选择权交回解析器,下一次上游发新版时 lockfile 会在某个「只更新 lockfile」的 PR 里悄悄漂走,而 osv-scanner 只看 lockfile——红绿灯本该由人决定。**代价与验证**:glob 14 把 `path-scurry` 从 2.x 提到 3.x、minimatch 11 多带一个 `@isaacs/nonrecursive-glob-match`(都是 dev-only,不进发布包、不参与运行时);这条链唯一的生产用途是 `npm run test:coverage` 的 `c8` 文件筛选,全量 `npm test` **2569 / 2568 pass / 0 fail / 1 skipped**,另在临时工程里跑过 `c8 --include/--exclude/--all` 三种走 glob 的入口,覆盖率表正常。**没有 `ignore`/allowlist**:把公告静音等于让下一条真公告也一起静音,而 `security.yml` 里 osv-scanner 的 `fail-on-vuln: true` 正是为了不让它只当提醒。 ## [0.9.5] - 2026-10-30 diff --git a/dsh-mneme/test/security-doc.test.js b/dsh-mneme/test/security-doc.test.js index 1bdf2c44..c5fc1609 100644 --- a/dsh-mneme/test/security-doc.test.js +++ b/dsh-mneme/test/security-doc.test.js @@ -104,10 +104,22 @@ function routesInBullet(line) { return out; } -function bullet(prefix) { - const line = doc.split(/\r?\n/).find((l) => l.startsWith(prefix)); - assert.ok(line, `SECURITY.md 里应有以「${prefix}」开头的清单行`); - return routesInBullet(line); +// 清单行必须**恰好一条**。原先用 .find() 只读第一条命中,于是「同一条目出现两份」 +// 这件事守卫完全无感:#509 的 merge 把两份 Open reads / Gated routes 一起解进来, +// 其中一份 stale(少 /memory/provenance),CI 却全绿——因为 .find() 读的是第一份。 +// 「文档与代码平价」的全部信心都来自这个文件,一旦它对重复条目零感知,第二份就是没有守卫的谎话。 +// 所以这里先收全部命中,再判唯一;多于一条时把行号报出来,避免变成「哪份算数」的新谜题。 +// source 参数只为可测:守卫本身是「唯一性」,喂一份合成的重复文档就能验,不必真去改 SECURITY.md。 +function bullet(prefix, source = doc) { + const hits = []; + source.split(/\r?\n/).forEach((line, i) => { + if (line.startsWith(prefix)) hits.push({ line, no: i + 1 }); + }); + assert.ok(hits.length > 0, `SECURITY.md 里应有以「${prefix}」开头的清单行`); + assert.equal(hits.length, 1, + `SECURITY.md 里以「${prefix}」开头的清单行必须恰好一条,实际 ${hits.length} 条(行号 ${hits.map((h) => h.no).join(", ")})。` + + "清单只许有一份:多出来的那份不会被本文件校验,改它不会红,只能靠删掉来保持平价。"); + return routesInBullet(hits[0].line); } // 文档里的 /semantic/graph/* 覆盖代码里的 /semantic/graph/ego 与 /semantic/graph/entity-attrs。 @@ -137,3 +149,30 @@ test("SECURITY.md 的受门控清单与代码里的 policy 声明一致", () => assert.deepEqual(stale, [], `${prefix} 写了代码里并不受门控的路由:${stale.join(", ")}`); } }); + +// 上面那条唯一性断言本身要有回归:把 SECURITY.md 的清单行复制成两份必须红。 +// 这条用例喂合成文档,不碰真文件——真文件的平价由上面两条双向比对负责。 +// 变异自证(人工做过一次,记录在 PR 里):把 Gated routes 那一行原样复制一份 → 红; +// 复制成两份且第二份把 /memory/provenance 换成别的 → 也红。两种都只在「恰好一条」上红。 +test("重复的清单行会被守卫抓住(#523)", () => { + const gated = doc.split(/\r?\n/).find((l) => l.startsWith("- **Gated routes")); + assert.ok(gated, "SECURITY.md 里应有 Gated routes 清单行"); + + const withDup = ["# synthetic", gated, gated].join("\n"); + assert.throws( + () => bullet("- **Gated routes", withDup), + /必须恰好一条,实际 2 条(行号 2, 3)/, + "同一清单行出现两份时必须报出重复的行号——这正是 #509 merge 时守卫看不见的形状" + ); + + // 内容不同的两份同样要红:危险的不是写错了什么,而是那份副本对守卫隐形。 + const staleDup = ["# synthetic", gated, gated.replace("/memory/provenance", "/memory/provenance-OLD")].join("\n"); + assert.throws( + () => bullet("- **Gated routes", staleDup), + /必须恰好一条,实际 2 条/, + "两份内容不同的清单同样必须红,否则陈旧副本可以一直过期" + ); + + // 只有一份时不能误伤。 + assert.ok(bullet("- **Gated routes", gated).length > 0); +});