diff --git a/README.md b/README.md index acaf62f..7d7da2f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -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 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-1497%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-1519%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](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 预算(回溯上界被破坏?)`); + } +});