diff --git a/README.md b/README.md
index acaf62f..7d7da2f 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -183,7 +183,7 @@ dsh web
```bash
cd dsh-mneme && npm install
-npm test # 1497 个测试
+npm test # 1519 个测试
npm run stress # 三轴线压测
npm run sync # src → lib 同步
```
@@ -366,7 +366,7 @@ The plugin ships a zero-dependency stdio MCP server (standalone npm package **`m
```bash
cd dsh-mneme && npm install
-npm test # 1497 tests
+npm test # 1519 tests
npm run stress # three-axis stress test
npm run sync # src → lib sync
```
diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md
index 02ccc08..56f97aa 100644
--- a/dsh-mneme/CHANGELOG.md
+++ b/dsh-mneme/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+## 🐛 修复
+
+- **密钥 / PII 判据三处加固(`src/sensitive-scan.js`,均为合并后独立复审发现)**:① **回溯有上界**——`email` 的 local part 与 `connection_string` 的 scheme 都作用在含 `.` 的字符类上,缺上界时每个起点都要一路重扫到结尾才失败(O(n²)),而判据在写入路径上同步跑:实测 34KB 点分链 0.6 秒、120KB 对抗串 23.5 秒(email 18.6s + 连接串 4.1s),等于把写入卡死;加上界后同输入约 60ms,边界取 RFC 5321 给 local part 的 64。② **赋值型规则左边界不再排除 `_`**——环境变量名正是拿 `_` 当分隔符,原写法让 `DB_PASSWORD=` / `MY_API_KEY=` / `MYSQL_PASSWORD=` 这类「前缀_关键词」整类漏放(只有恰好落在行首的 `API_KEY=` 能中);放宽后 #332 的 26 条语料仍全绿,挡误杀的仍是那道占位符守卫。③ **身份证档补校验位**(GB 11643 / ISO 7064 MOD 11-2)——原先只有银行卡档有 Luhn,18 位纯数字(订单号 / 内部编号)先被身份证规则命中,等不到银行卡那条的校验。三处都配了回归测试,并做过变异检验(改回原写法各自变红)。判据仍在 `sensitiveScanEnabled` 默认关之后,线上行为不变。
+
## 🆕 新增
- **写入边界的密钥 / PII 判据(`sensitiveScanEnabled`,默认关)**:写入准入(#254 第 1 级)此前只跑空白 / 噪声两类判据,密钥 / PII 那一档按设计留了注入点而没实现。现在补上 `src/sensitive-scan.js`——纯确定性、零 LLM,先认形状再认关键词(赋值型规则带占位符守卫,所以「把 API key 放进环境变量」这类讨论句不报)。命中落审计 `metadata.deny.reason='sensitive'` + `kind`(密钥 / PII 分档,便于先看分布再决定放行策略),审计位与 #332 定的形状一致。开关分层:本键只决定「这类判据参不参与」,命中之后是仅告警还是真拦仍由 `writeAdmission.enforce` 决定(默认仅告警、拦截 opt-in)。回归样本集(10 条密钥 + 4 条 PII 正样本、12 条负样本)原样跑真判据:正样本不漏、负样本不误杀。
diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md
index 38a0451..ff9e5eb 100644
--- a/dsh-mneme/README.md
+++ b/dsh-mneme/README.md
@@ -5,7 +5,7 @@
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
[](LICENSE)
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
-[](https://github.com/slow-stack/mneme)
+[](https://github.com/slow-stack/mneme)
[](https://github.com/slow-stack/mneme/actions)
[](https://nodejs.org)
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -584,7 +584,7 @@ src/
├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
└── index.js # 插件接线
lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
-test/ # 1497 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
+test/ # 1519 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
```
@@ -593,7 +593,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
```bash
cd dsh-mneme
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
-npm test # 运行 1497 个测试
+npm test # 运行 1519 个测试
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
```
diff --git a/dsh-mneme/lib/sensitive-scan.js b/dsh-mneme/lib/sensitive-scan.js
index 0f25e32..87c32ae 100644
--- a/dsh-mneme/lib/sensitive-scan.js
+++ b/dsh-mneme/lib/sensitive-scan.js
@@ -35,6 +35,9 @@
// 就会把长度相近的订单号 / 内部编号吃进来,而 PII 这一档的误杀面已经比密钥大一档。
// - 银行卡号加 Luhn 校验:`0000000000000000`、`4111111111111112` 这类形状对但校验
// 不过的串不报。少了这道校验,任何 16 位数字串(订单号、时间戳拼接)都会命中。
+// - 身份证同样加校验位(GB 11643 / ISO 7064 MOD 11-2):位数对但校验不过的 18 位
+// 数字串不报。原先只有银行卡有校验、身份证没有,结果是 18 位纯数字先被身份证规则
+// 命中,反倒绕过银行卡那条的 Luhn。
//
// 归一化:这里**不**用 content-hash.js 的 normalizeForHash。那套口径(NFKC → 小写 →
// 去标点)是为「只差格式的两条写入是否同一件事」定的,判据要的是原串的形状——大小写
@@ -64,7 +67,12 @@ const SECRET_RULES = [
// 可能有 `RSA` / `EC` / `OPENSSH`,所以中间那段是 [A-Z ]*。
{ kind: "private_key", label: "PEM private key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
{ kind: "jwt", label: "JSON Web Token", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/ },
- { kind: "connection_string", label: "credentials in URL", re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/:@]{6,}@/ },
+ // scheme 与 userinfo 两段都加上界。scheme 那段的 `*` 作用在含 `.` 的字符类上,
+ // 一条长点分串(包名 / 路径 / 版本链)里每个起点都要一路回溯到结尾才发现没有
+ // `://` → 与 email 同源的 O(n²)(实测 120KB 对抗串里这条占 4.1 秒)。
+ // URL scheme 名本就短、`user:password` 也不会长到 64,上界只钉住回溯面,
+ // 真实连接串一条不少。
+ { kind: "connection_string", label: "credentials in URL", re: /\b[a-z][a-z0-9+.-]{0,31}:\/\/[^\s/:@]{1,64}:[^\s/:@]{6,}@/ },
// Stripe 排在通用 `sk-` 之前:`sk_live_…` 两条规则都吃,先到的那条决定 kind。
// 只认 sk_live_(生产密钥):sk_test_ 是公开测试密钥,报它是纯误杀。
{ kind: "stripe_key", label: "Stripe secret key", re: /\bsk_live_[A-Za-z0-9]{16,}\b/ },
@@ -79,7 +87,11 @@ const SECRET_RULES = [
// alphabet——凭据值没有通用形状,能通用的只有「它不像占位符」。
kind: "assigned_secret",
label: "assigned credential literal",
- re: /(?:^|[^A-Za-z0-9_])(?:password|passwd|pwd|secret|api[_-]?key|token)\b\s*[:=]\s*["']?([^\s"']{8,})/i,
+ // 左边界不能把 `_` 排除在外:环境变量名正是拿 `_` 当分隔符,排除它会让
+ // `DB_PASSWORD=` / `MY_API_KEY=` / `MYSQL_PASSWORD=` 整类漏放(只有恰好落在
+ // 行首的 `API_KEY=` 能中)。放宽后 #332 那套 26 条语料(含 12 条负样本)全绿,
+ // 说明原写法不是语料换来的取舍。挡误杀的是下面那道占位符守卫,不是这个边界。
+ re: /(?:^|[^A-Za-z0-9])(?:password|passwd|pwd|secret|api[_-]?key|token)\b\s*[:=]\s*["']?([^\s"']{8,})/i,
// 占位符守卫:右边是尖括号占位、shell / 模板变量、环境变量读取、或一串 x / * / …
// 时不算命中。少这道守卫,`password: ` 与 `token: ${TOKEN}` 都会被报,
// 而它们正是「配置里该怎么写」的示例文本。
@@ -93,10 +105,15 @@ const PII_RULES = [
label: "email address",
// 先看 TLD 再看 `@`:反过来的 `(?:[A-Za-z]{2,}\.)+[A-Za-z]{2,}` 对
// `a@b.c.d.e` 这类可以回溯出指数条路径。
- re: /\b[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\b/
+ // local part 的 `+` 必须加上界。该字符类含 `.`,所以一条长点分串(包名 / 路径 /
+ // 版本链)后跟一个 `@` 时,每个起点都要重扫到那个 `@` 才失败 → 整体 O(n²)。
+ // 实测 120KB 对抗串 23.5 秒里这条占 18.6 秒,34KB 点分链要 0.6 秒;本判据在写入
+ // 路径上同步跑,等于把写入阻塞住。64 是 RFC 5321 给 local part 的上限,加上界
+ // 不缩检测面——放弃的只是长于 64 的非法形状。
+ re: /\b[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\b/
},
{ kind: "cn_mobile", label: "mainland mobile number", re: /(?` 与 `token: ${TOKEN}` 都会被报,
// 而它们正是「配置里该怎么写」的示例文本。
@@ -93,10 +105,15 @@ const PII_RULES = [
label: "email address",
// 先看 TLD 再看 `@`:反过来的 `(?:[A-Za-z]{2,}\.)+[A-Za-z]{2,}` 对
// `a@b.c.d.e` 这类可以回溯出指数条路径。
- re: /\b[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\b/
+ // local part 的 `+` 必须加上界。该字符类含 `.`,所以一条长点分串(包名 / 路径 /
+ // 版本链)后跟一个 `@` 时,每个起点都要重扫到那个 `@` 才失败 → 整体 O(n²)。
+ // 实测 120KB 对抗串 23.5 秒里这条占 18.6 秒,34KB 点分链要 0.6 秒;本判据在写入
+ // 路径上同步跑,等于把写入阻塞住。64 是 RFC 5321 给 local part 的上限,加上界
+ // 不缩检测面——放弃的只是长于 64 的非法形状。
+ re: /\b[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\b/
},
{ kind: "cn_mobile", label: "mainland mobile number", re: /(? `${prefix}${key}=${value}`;
+/** 一眼假的低熵值:不是占位符(占位符会被守卫挡掉,那样就测不到这条规则了)。 */
+const FAKE_LITERAL = ["super", "sekrit", "000"].join("-");
+
+test("左边界放开 `_`:环境变量名形态的赋值也要抓(原先整类漏放)", () => {
+ // 回归锁:赋值型规则的左边界原为 `[^A-Za-z0-9_]`,把下划线也当成了标识符内部字符,
+ // 于是只有恰好落在行首的 `API_KEY=` 能中,而 `DB_PASSWORD=` / `MY_API_KEY=` /
+ // `MYSQL_PASSWORD=` 这种「前缀_关键词」的环境变量名整类漏放——配置片段与 .env 正文
+ // 恰恰是最常见的落库形态。放宽后 #332 的 26 条语料仍全绿,说明原写法不是语料换来
+ // 的取舍,是白丢的。
+ for (const [key, prefix] of [
+ ["DB_PASSWORD", ""],
+ ["MY_API_KEY", ""],
+ ["MYSQL_PASSWORD", ""],
+ ["GITHUB_TOKEN", "export "] // 带 shell 前缀的写法
+ ]) {
+ const line = envLine(key, FAKE_LITERAL, prefix);
+ assert.equal(scanSensitive(line)?.kind, "assigned_secret", `${key} 形态的赋值不该漏放`);
+ }
+});
+
+test("放宽左边界没有放走占位符与间接引用(守卫仍在管那一半)", () => {
+ // 与上一条成对:放宽的是「关键词前面能不能是 `_`」,不是「右边的值算不算字面量」。
+ // 后者仍归占位符守卫——下面三条正是负样本群里「有敏感词但没有值」的形态。
+ // 这些值同样拼装,理由见本节开头(`${...}` 这类串本身也贴着"像凭据"的形状)。
+ const placeholders = [
+ ["DB_PASSWORD", ["$", "{DB_PASSWORD}"].join("")],
+ ["MY_API_KEY", ["<", "your-key-here", ">"].join("")],
+ ["DB_PASSWORD", ["process", ".env.DB_PASSWORD"].join("")]
+ ];
+ for (const [key, value] of placeholders) {
+ const line = envLine(key, value);
+ assert.equal(scanSensitive(line), null, `${key} 形态的占位 / 间接引用不该报`);
+ }
+});
+
+test("身份证档加校验位:位数对但校验不过的 18 位数字串不报", () => {
+ // 回归锁:原先只有银行卡档有 Luhn、身份证档只看长度,结果是 18 位纯数字(订单号 /
+ // 内部编号 / 拼接时间戳)先被身份证规则命中,等不到银行卡那条的校验。补 GB 11643
+ // MOD 11-2 之后两侧都干净:校验不过的不报,语料里那条真形状照报。
+ assert.equal(scanSensitive("内部编号 123456789012345678 已登记"), null, "校验不过的 18 位不该报");
+ assert.equal(scanSensitive("内部编号 987654321098765432 已登记"), null);
+ assert.equal(scanSensitive("身份证 11010519491231002X 已核验")?.kind, "cn_id_card");
+ // 小写 x 走同一张校验表(语料只覆盖了大写 X)。
+ assert.equal(scanSensitive("身份证 11010519491231002x 已核验")?.kind, "cn_id_card");
+});
+
+test("email 的 local part 上界恰好卡在 RFC 5321 的 64", () => {
+ // 上界不是随手取的数:64 是 RFC 5321 给 local part 的上限,所以「长于 64 不认」
+ // 这条边界必须是精确的——松一位会让回溯面失控,紧一位会漏掉合法邮箱。
+ const at = (n) => `${"a".repeat(n)}@example.com`;
+ assert.ok(scanSensitive(at(64)), "64 位的 local part 是合法的,必须认");
+ assert.equal(scanSensitive(at(65)), null, "65 位超出 RFC 上限,不认(换取回溯有界的代价)");
+});
+
+test("长文本上的回溯有上界:写阻塞类回归的计时护栏", () => {
+ // 回归锁:email 的 local part 与 connection_string 的 scheme 都作用在含 `.` 的字符
+ // 类上,缺上界时每个起点都要一路重扫到结尾才失败 → O(n²)。未加上界时实测:34KB
+ // 点分链 0.6 秒、120KB 对抗串 23.5 秒(email 18.6s + 连接串 4.1s);而本判据在写入
+ // 路径上同步跑,等于把写入卡死。加上界后同输入约 60ms。
+ // 预算取 2 秒:比修复前小一个数量级(12× 安全边),又比修复后大 30 倍,不至于像
+ // 50ms 级断言那样被 CI 抖动误伤——它抓的是「上界被拿掉」这类数量级回归,不是微优化。
+ const inputs = [
+ "a.".repeat(30000) + "@" + "b.".repeat(30000) + "!",
+ "com.example.pkg.".repeat(10000) + "@"
+ ];
+ for (const text of inputs) {
+ const started = process.hrtime.bigint();
+ scanSensitive(text);
+ const ms = Number(process.hrtime.bigint() - started) / 1e6;
+ assert.ok(ms < 2000, `${text.length} 字符耗时 ${ms.toFixed(1)}ms,超 2000ms 预算(回溯上界被破坏?)`);
+ }
+});