diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 34fdc0d..f2a2ecd 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +## 🆕 新增 + +- **写入边界的密钥 / 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 条负样本)原样跑真判据:正样本不漏、负样本不误杀。 + ## [0.8.12] - 2026-10-01 ## 🐛 修复 diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js index 9f2cdf2..b03b523 100644 --- a/dsh-mneme/lib/api.js +++ b/dsh-mneme/lib/api.js @@ -143,6 +143,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = "llmAudit.enabled": ["llmAudit", "enabled"], "writeAdmission.enabled": ["writeAdmission", "enabled"], "writeAdmission.enforce": ["writeAdmission", "enforce"] + // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue + // 的默认分支即可。 }; function configFlagValue(key) { const path = NESTED_FLAG_PATHS[key]; diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 0523a46..ec29420 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -493,6 +493,11 @@ window.__ModuleLoader__.load({ "memory.features.writeAdmission.enabled.hint": "写入前判空白/噪声,命中留审计(不拦)", "memory.features.writeAdmission.enforce": "写入准入拦截", "memory.features.writeAdmission.enforce.hint": "真拦下命中的写入;关闭时只留审计不拦", + // #164 A2:密钥/PII 判据自己的闸。文案要写清它管的是哪一批判据(与上面的 + // 空白/噪声分开)、它跑在「写入准入判定」里面(那一项关着就不跑),以及 + // 「开着也不拦」——拦是上面那项的事。 + "memory.features.sensitiveScanEnabled": "密钥/PII 扫描", + "memory.features.sensitiveScanEnabled.hint": "写入前扫密钥与个人信息;需先打开写入准入判定才会执行,命中只留审计,要拦再打开写入准入拦截", "memory.features.bm25SearchEnabled": "BM25 关键词检索", "memory.features.bm25SearchEnabled.hint": "传统关键词打分检索,与向量召回互补", "memory.features.conflictFreezeEnabled": "冲突冻结", @@ -925,6 +930,12 @@ window.__ModuleLoader__.load({ "memory.features.writeAdmission.enabled.hint": "Flag blank / noise writes before storing, with an audit row (never blocks)", "memory.features.writeAdmission.enforce": "Write admission enforcement", "memory.features.writeAdmission.enforce.hint": "Actually reject flagged writes; off keeps the audit row only", + // #164 A2: its own gate for the secret / PII rules (separate from the + // blank/noise pair above), and it runs inside the write admission checks + // above, so with those off it never runs. Hits are audited only; + // enforcement stays with the key above. + "memory.features.sensitiveScanEnabled": "Secret / PII scan", + "memory.features.sensitiveScanEnabled.hint": "Scan writes for credentials and personal data; needs write admission checks to run. Hits are audited only; enforcement needs the enforcement toggle too", "memory.features.bm25SearchEnabled": "BM25 keyword search", "memory.features.bm25SearchEnabled.hint": "Classic keyword scoring, complementary to vector recall", "memory.features.conflictFreezeEnabled": "Conflict freezing", @@ -2122,7 +2133,7 @@ window.__ModuleLoader__.load({ // test/inject-parent-gate.test.js 钉住。 const FEATURE_CHILDREN = { autoInject: ["injectGuidanceEnabled", "continuityRescueEnabled"] }; const FEATURE_GROUPS = [ - { key: "group.core", items: ["autoInject", "autoSummarize", "hotMemoryEnabled", "injectTimePrefix", "memoryQualityFilter.enabled", "llmAudit.enabled", "writeAdmission.enabled", "writeAdmission.enforce"] }, + { key: "group.core", items: ["autoInject", "autoSummarize", "hotMemoryEnabled", "injectTimePrefix", "memoryQualityFilter.enabled", "llmAudit.enabled", "writeAdmission.enabled", "writeAdmission.enforce", "sensitiveScanEnabled"] }, { key: "group.enhance", items: ["entityExtractionEnabled", "codingRetrospect", "rerankEnabled", "resilientModelDownload", "searchSemanticDedup", "bm25SearchEnabled", "heatEnabled", "documentMemoryEnabled"] }, { key: "group.dream", items: ["autoDream", "sleepModeEnabled"] }, // v0.8.0 A4(issue #17):作用域隔离组——标注总开关 + 严格硬过滤。 diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index 551e253..5c8b648 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -651,6 +651,25 @@ export const Config = z.object({ enforce: z.boolean().default(false) }).default({}), + // --- #164 A2: secret / PII scan at the write boundary ---------------------- + // 写入边界的密钥 / PII 判据(src/sensitive-scan.js)自身的闸。与 writeAdmission + // 的 enabled 分开是有意的:那一个管 #254 第 1 级的空白 / 噪声判据,本键管 A2 这 + // 一类判据,两批的误杀面差一个量级(空白 / 噪声没有解释空间,邮箱 / 手机号有), + // 绑在同一个开关上就没法单独观察 A2 的命中分布。 + // + // 分层必须写清:判据的唯一调用点在 #254 第 1 级的闸门里(write-admission.js 的 + // firstLevelHit),闸门不走第 1 级就没人来调这个扫描器。所以本键是「闸门内这一批 + // 判据参不参与」,不是一条能独立跑的链路——单开本键就是零行为变化: + // writeAdmission.enabled 关 → 第 1 级整个不跑,本键开也没用 + // enabled 开 + 本键关 → 只跑空白 / 噪声那一批 + // enabled 开 + 本键开 + enforce 关 → 命中留审计,写入照常(观察档) + // enabled 开 + 本键开 + enforce 开 → 命中即拒绝 + // 默认关 = 只计量那一阶段的行为逐字节保留(#332 合并时 sensitiveScan 就是 null)。 + // #164 的「默认仅告警、拦截 opt-in」落在 enforce 上:命中落一条审计 + // (metadata.deny.kind)但照常写入,真要拦得 enabled 与 enforce 同时开。 + // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。 + sensitiveScanEnabled: z.boolean().default(false), + // --- recall evaluation: test-result storage (v0.4.6, 方案 B) -------------- // Separate retrieval evaluation snapshots from the production recall audit. // When false (default) evaluateRetrieval still computes precision/recall/mrr diff --git a/dsh-mneme/lib/index.js b/dsh-mneme/lib/index.js index b691e6c..21a1b64 100644 --- a/dsh-mneme/lib/index.js +++ b/dsh-mneme/lib/index.js @@ -5,6 +5,8 @@ import { resolveDocumentDir } from "./document.js"; import { createService } from "./service.js"; // #254 写入准入(第一阶段只计量,不拦截):见 src/write-admission.js 的文件头。 import { createWriteAdmission } from "./write-admission.js"; +// #164 A2:写入边界的密钥 / PII 判据,注入给上面的写入准入。 +import { createSensitiveScan } from "./sensitive-scan.js"; import { createTools } from "./tools.js"; import { createInjector } from "./inject.js"; import { createContinuityRescue } from "./continuity.js"; @@ -278,11 +280,17 @@ export const apply = (ctx, config) => { // 关掉,在无保留期的表里按写入频次增长是不能接受的)。 // // sensitiveScan 是密钥 / PII 那一类判据的注入点。按 #254 验收第 4 条它是 #164 A2 - // 的判据来源(A2 记在维护者排期里),所以本批不实现它,只把接口形状定在这里—— - // 接上时只改这一行: - // createWriteAdmission({ ..., sensitiveScan: createSensitiveScan({ config: cfg }) }) - // 缺省 null 时第 1 级只跑空白 / 噪声两类判据,其余一切照旧。 - const writeAdmission = createWriteAdmission({ store, config: cfg, logger: ctx.logger }); + // 的判据来源,现在由 src/sensitive-scan.js 实现(维护者 09-28 把 A2 的认领转给 + // 本侧)。这里只做接线:判据开不开由它自己的键 sensitiveScanEnabled 决定,工厂在 + // 关时返回 null,闸门的行为就与 #332 合并时逐字段一致(那一版根本没有这个函数); + // 命中之后是仅告警还是真拦,仍是 writeAdmission.enforce 的事(#164 口径: + // 默认仅告警、拦截 opt-in),判据不碰决策。 + const writeAdmission = createWriteAdmission({ + store, + config: cfg, + logger: ctx.logger, + sensitiveScan: createSensitiveScan({ config: cfg }) + }); const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission }); // F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry diff --git a/dsh-mneme/lib/sensitive-scan.js b/dsh-mneme/lib/sensitive-scan.js new file mode 100644 index 0000000..0f25e32 --- /dev/null +++ b/dsh-mneme/lib/sensitive-scan.js @@ -0,0 +1,178 @@ +// #164 A2:写入边界的密钥 / PII 判据。判据来源是 #164 A2(维护者 09-28 把认领转给 +// 本侧),消费方是 #254 的写入准入——`write-admission.js` 从 #332 起就留了 +// `sensitiveScan` 注入点,本模块是那个占位的实现。 +// +// 为什么判据独立成文件、不写进 write-admission.js:判据的归属是 #164 A2,闸门的归属 +// 是 #254。闸门只消费 `{kind, label}`(deny 面已在 #332 定死),判据自己既不认识 +// 「会话预算」也不认识 `llm_audit_logs`——换一个判据(比如将来接更重的 PII 分类器) +// 只换注入的那一行。写入边界的另外两个出口(autoSummarize / dream 输出)要复用同一份 +// 判据时也直接 import 本模块的 `scanSensitive`,不必各写一套形状。 +// +// 三条判据口径(前两条是 #254 已定的硬约束,第三条是本模块自己的): +// +// 1. 零 LLM、纯确定性。写入路径在最上游,这里多花的时间会乘上每一次写入;EdgeMem +// (2609.05553)那句「能不用模型判定就不用」说的就是这一层。 +// +// 2. 假阳率由负样本定。`test/helpers/write-admission-samples.js` 的 12 条负样本是 +// #332 就配好的验收面,维护者把话说死了:「同一套负样本原样跑,抓错任何一条就不 +// 算落地」。所以本模块的形状是**先认形状再认关键词**——纯关键词匹配在那组负样本 +// 上全军覆没(「把 API key 放进环境变量」这类讨论句里一个凭据值都没有)。 +// +// 3. 报最严重的那一类(`kind` 落审计)。同一个字符串可能同时像两类(`sk_live_` 前缀 +// 既是 Stripe 的形状、也能被通用的 `sk-` 规则吃下),所以规则表按「越具体越靠前」 +// 排,命中即返回。密钥在前、PII 在后:两者误杀面差一个量级(密钥串出现在正常项目 +// 记忆里就是事故,邮箱 / 手机号完全可能是正当内容),同时命中时报更严重的那一类。 +// `kind` 是稳定判据键(不是展示文案),enforce 打开前先按它看分布,将来要按类放行 +// 也只改策略、不动判据。 +// +// 已知边界(都是「宁漏不误杀」的取舍,不是遗漏): +// - 只扫文本(title / content / tags)。不扫路径、id、时间戳这类元数据字段——它们 +// 由系统生成,不是用户写进来的内容,扫它们只会引入假阳。 +// - 中文关键词(密码 / 令牌)不认。补进去会让「密码必须脱敏后再入库」这类讨论句 +// 命中,而那正是负样本 group 的形状。要覆盖中文赋值得先设计「关键词语种 × 值形状」 +// 的两维判据,不在本批。 +// - 手机号只认大陆移动号段(1[3-9] + 9 位),固定电话与带国家码的写法不认。宽一位 +// 就会把长度相近的订单号 / 内部编号吃进来,而 PII 这一档的误杀面已经比密钥大一档。 +// - 银行卡号加 Luhn 校验:`0000000000000000`、`4111111111111112` 这类形状对但校验 +// 不过的串不报。少了这道校验,任何 16 位数字串(订单号、时间戳拼接)都会命中。 +// +// 归一化:这里**不**用 content-hash.js 的 normalizeForHash。那套口径(NFKC → 小写 → +// 去标点)是为「只差格式的两条写入是否同一件事」定的,判据要的是原串的形状——大小写 +// 是密钥 alphabet 的一部分(`AKIA` 与 `akia` 不是同一个值),去掉 `-` / `_` 会把 JWT +// 与 Slack token 的分段结构一起折没。两个口径别互换。 + +/** 判据命中的返回形状(与 `createWriteAdmission` 的 sensitiveScan 契约一致)。 */ +// kind 是稳定键、label 是给人看的一行说明;两者都落 llm_audit_logs 的 +// metadata.deny,所以别在其中塞运行时的值(命中的凭据本身绝不落盘 / 不回显)。 +const SECRET_RULES = [ + { kind: "aws_access_key", label: "AWS access key id", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, + // AWS 的 secret access key 没有前缀,只能靠赋值键认:`AWS_SECRET_ACCESS_KEY` / + // `aws_secret_access_key` 这个键名是它的形状。最早的一批 `secret` 关键词规则被 + // 「必须在赋值号前」的位置要求挡在外面(多词键里 `secret` 后面跟的是 `_`,不是 + // `:`/`=`),所以它得单独一条。40 位下限取 AWS 官方密钥的固定长度。 + { + kind: "aws_secret_key", + label: "AWS secret access key", + // 键名不分大小写,所以挂 `/i` 而不是行内修饰符组 `(?i:...)`:后者是 ES2025 语法, + // CI 矩阵里的 Node 22 直接抛 SyntaxError(本文件其余规则也没有用它的)。 + // 值那一半本来就是 `[A-Za-z0-9/+=]`,带上 `/i` 不改变大小写敏感度。 + re: /(?:aws[_-]?secret[_-]?access[_-]?key)\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])/i + }, + { kind: "github_token", label: "GitHub token", re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b/ }, + { kind: "slack_token", label: "Slack token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ }, + // PEM 头是明文私钥的确定标志,带不带正文都一样判——`BEGIN` 与 `PRIVATE KEY` 之间 + // 可能有 `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,}@/ }, + // 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/ }, + { kind: "npm_token", label: "npm auth token", re: /_authToken\s*=\s*[A-Za-z0-9_-]{20,}/ }, + // 下限 24 而不是 20:这是一条纯收紧、不动语料读数的加固——OpenAI 的 key 没有校验 + // 位,前缀 + 长度是唯一形状,20 位会把更长的假阳性面留在表里。`sk-` 后面跟短串的 + // 赋值句仍会被赋值型那条认下(那是**对的**:值长了 8 位以上就该报)。 + { kind: "openai_key", label: "OpenAI API key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{24,}\b/ }, + { + // 赋值型:最常见的一类,也是负样本组(占位符 / 环境变量 / 模板变量 / 只提关键词) + // 的主要靶子。判据是「关键词 + 赋值号 + 右边真的是个值的形状」,值本身不校验 + // 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, + // 占位符守卫:右边是尖括号占位、shell / 模板变量、环境变量读取、或一串 x / * / … + // 时不算命中。少这道守卫,`password: ` 与 `token: ${TOKEN}` 都会被报, + // 而它们正是「配置里该怎么写」的示例文本。 + guard: (value) => !/^(?:<[^>]*>|\$\{|\$[A-Z_]+$|process\.env|redacted|xx+|\*+|\u2026)/i.test(value) + } +]; + +const PII_RULES = [ + { + kind: "email", + 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/ + }, + { kind: "cn_mobile", label: "mainland mobile number", re: /(? 19) return false; + let sum = 0; + let double = false; + for (let i = digits.length - 1; i >= 0; i--) { + let d = digits.charCodeAt(i) - 48; + if (double) { + d *= 2; + if (d > 9) d -= 9; + } + sum += d; + double = !double; + } + return sum % 10 === 0; +} + +/** + * 扫一段文本里的密钥 / PII。命中返回 `{kind, label}`、未命中返回 null,不抛。 + * + * 只认第一处命中(按规则表顺序 = 严重度顺序)。一次写入里出现第二条凭据时不再报—— + * deny 面只记判据类别,报多条只是把同一件事写几遍。 + * @param {unknown} value + * @returns {{kind: string, label: string}|null} + */ +export function scanSensitive(value) { + const text = typeof value === "string" ? value : String(value ?? ""); + if (!text) return null; + for (const rule of [...SECRET_RULES, ...PII_RULES]) { + const match = text.match(rule.re); + if (!match) continue; + // 守卫只看捕获组:没有捕获组的规则天然没有守卫。 + if (rule.guard && !rule.guard(match[1] ?? "")) continue; + if (rule.luhn && !passesLuhn(match[0])) continue; + return { kind: rule.kind, label: rule.label }; + } + return null; +} + +/** + * 判据的两种调用面,共用同一份规则表: + * `scanSensitive(text)` — 任意文本,给写入边界之外的消费方(autoSummarize / + * dream 输出)直接调;它们手里是文本,不是记忆行。 + * `createSensitiveScan({config})` — `write-admission.js` 的 `sensitiveScan` 契约, + * 入参是记忆对象、返回 `{kind, label}` 或 null。 + * + * 开关在工厂里判一次:`sensitiveScanEnabled` 关时返回 null,闸门侧连函数都拿不到, + * 于是「第 1 级只跑空白 / 噪声」这条路与 #332 合并时**逐字段一致**(那一版根本没有 + * 这个函数)。反过来,本键开着也不会自己去扫:唯一的调用点是闸门的 firstLevelHit, + * 而闸门要 `writeAdmission.enabled` 打开才走第 1 级判据。所以两个键是「闸门」与 + * 「闸门内这一批判据」的关系,不是互为子开关——任一个关着,A2 都不产生任何判定。 + * 分成两个键是为了能单独观察 A2 的命中分布(空白 / 噪声与密钥 / PII 的误杀面差一个 + * 量级),不是为了让它能脱离闸门独立生效。 + * + * @param {{config?: object}} [deps] + * @returns {((memory: object) => ({kind: string, label: string}|null))|null} + */ +export function createSensitiveScan({ config } = {}) { + if (config?.sensitiveScanEnabled !== true) return null; + const scan = (memory) => { + const text = [memory?.title, memory?.content, ...(Array.isArray(memory?.tags) ? memory.tags : [])] + .filter((s) => typeof s === "string") + .join("\n"); + return scanSensitive(text); + }; + // 工厂只认「开 / 关」;enforce 由 write-admission 决定(命中即 deny 还是仅告警), + // 判据自己不碰决策——那一步在闸门里。两层分开就能先开检测看分布、再开拦截。 + return scan; +} diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index ed4b0ff..c214535 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -123,7 +123,11 @@ const FEATURE_FLAG_BOOLEANS = [ // enforce 真拦;都默认关。同上,点号键平铺存、合并时展开回 // writeAdmission 对象。 "writeAdmission.enabled", - "writeAdmission.enforce" + "writeAdmission.enforce", + // Issue #164 A2:写入边界的密钥 / PII 判据(src/sensitive-scan.js)。与上面两个 + // 键分层——本键决定「这类判据参不参与」(默认关),命中之后是仅告警还是真拦仍由 + // writeAdmission.enforce 决定(#164 口径:默认仅告警、拦截 opt-in)。 + "sensitiveScanEnabled" ]; // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。 const FEATURE_FLAG_INT_RANGES = { diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js index 9f2cdf2..b03b523 100644 --- a/dsh-mneme/src/api.js +++ b/dsh-mneme/src/api.js @@ -143,6 +143,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = "llmAudit.enabled": ["llmAudit", "enabled"], "writeAdmission.enabled": ["writeAdmission", "enabled"], "writeAdmission.enforce": ["writeAdmission", "enforce"] + // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue + // 的默认分支即可。 }; function configFlagValue(key) { const path = NESTED_FLAG_PATHS[key]; diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index 551e253..5c8b648 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -651,6 +651,25 @@ export const Config = z.object({ enforce: z.boolean().default(false) }).default({}), + // --- #164 A2: secret / PII scan at the write boundary ---------------------- + // 写入边界的密钥 / PII 判据(src/sensitive-scan.js)自身的闸。与 writeAdmission + // 的 enabled 分开是有意的:那一个管 #254 第 1 级的空白 / 噪声判据,本键管 A2 这 + // 一类判据,两批的误杀面差一个量级(空白 / 噪声没有解释空间,邮箱 / 手机号有), + // 绑在同一个开关上就没法单独观察 A2 的命中分布。 + // + // 分层必须写清:判据的唯一调用点在 #254 第 1 级的闸门里(write-admission.js 的 + // firstLevelHit),闸门不走第 1 级就没人来调这个扫描器。所以本键是「闸门内这一批 + // 判据参不参与」,不是一条能独立跑的链路——单开本键就是零行为变化: + // writeAdmission.enabled 关 → 第 1 级整个不跑,本键开也没用 + // enabled 开 + 本键关 → 只跑空白 / 噪声那一批 + // enabled 开 + 本键开 + enforce 关 → 命中留审计,写入照常(观察档) + // enabled 开 + 本键开 + enforce 开 → 命中即拒绝 + // 默认关 = 只计量那一阶段的行为逐字节保留(#332 合并时 sensitiveScan 就是 null)。 + // #164 的「默认仅告警、拦截 opt-in」落在 enforce 上:命中落一条审计 + // (metadata.deny.kind)但照常写入,真要拦得 enabled 与 enforce 同时开。 + // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。 + sensitiveScanEnabled: z.boolean().default(false), + // --- recall evaluation: test-result storage (v0.4.6, 方案 B) -------------- // Separate retrieval evaluation snapshots from the production recall audit. // When false (default) evaluateRetrieval still computes precision/recall/mrr diff --git a/dsh-mneme/src/index.js b/dsh-mneme/src/index.js index b691e6c..21a1b64 100644 --- a/dsh-mneme/src/index.js +++ b/dsh-mneme/src/index.js @@ -5,6 +5,8 @@ import { resolveDocumentDir } from "./document.js"; import { createService } from "./service.js"; // #254 写入准入(第一阶段只计量,不拦截):见 src/write-admission.js 的文件头。 import { createWriteAdmission } from "./write-admission.js"; +// #164 A2:写入边界的密钥 / PII 判据,注入给上面的写入准入。 +import { createSensitiveScan } from "./sensitive-scan.js"; import { createTools } from "./tools.js"; import { createInjector } from "./inject.js"; import { createContinuityRescue } from "./continuity.js"; @@ -278,11 +280,17 @@ export const apply = (ctx, config) => { // 关掉,在无保留期的表里按写入频次增长是不能接受的)。 // // sensitiveScan 是密钥 / PII 那一类判据的注入点。按 #254 验收第 4 条它是 #164 A2 - // 的判据来源(A2 记在维护者排期里),所以本批不实现它,只把接口形状定在这里—— - // 接上时只改这一行: - // createWriteAdmission({ ..., sensitiveScan: createSensitiveScan({ config: cfg }) }) - // 缺省 null 时第 1 级只跑空白 / 噪声两类判据,其余一切照旧。 - const writeAdmission = createWriteAdmission({ store, config: cfg, logger: ctx.logger }); + // 的判据来源,现在由 src/sensitive-scan.js 实现(维护者 09-28 把 A2 的认领转给 + // 本侧)。这里只做接线:判据开不开由它自己的键 sensitiveScanEnabled 决定,工厂在 + // 关时返回 null,闸门的行为就与 #332 合并时逐字段一致(那一版根本没有这个函数); + // 命中之后是仅告警还是真拦,仍是 writeAdmission.enforce 的事(#164 口径: + // 默认仅告警、拦截 opt-in),判据不碰决策。 + const writeAdmission = createWriteAdmission({ + store, + config: cfg, + logger: ctx.logger, + sensitiveScan: createSensitiveScan({ config: cfg }) + }); const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission }); // F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry diff --git a/dsh-mneme/src/sensitive-scan.js b/dsh-mneme/src/sensitive-scan.js new file mode 100644 index 0000000..0f25e32 --- /dev/null +++ b/dsh-mneme/src/sensitive-scan.js @@ -0,0 +1,178 @@ +// #164 A2:写入边界的密钥 / PII 判据。判据来源是 #164 A2(维护者 09-28 把认领转给 +// 本侧),消费方是 #254 的写入准入——`write-admission.js` 从 #332 起就留了 +// `sensitiveScan` 注入点,本模块是那个占位的实现。 +// +// 为什么判据独立成文件、不写进 write-admission.js:判据的归属是 #164 A2,闸门的归属 +// 是 #254。闸门只消费 `{kind, label}`(deny 面已在 #332 定死),判据自己既不认识 +// 「会话预算」也不认识 `llm_audit_logs`——换一个判据(比如将来接更重的 PII 分类器) +// 只换注入的那一行。写入边界的另外两个出口(autoSummarize / dream 输出)要复用同一份 +// 判据时也直接 import 本模块的 `scanSensitive`,不必各写一套形状。 +// +// 三条判据口径(前两条是 #254 已定的硬约束,第三条是本模块自己的): +// +// 1. 零 LLM、纯确定性。写入路径在最上游,这里多花的时间会乘上每一次写入;EdgeMem +// (2609.05553)那句「能不用模型判定就不用」说的就是这一层。 +// +// 2. 假阳率由负样本定。`test/helpers/write-admission-samples.js` 的 12 条负样本是 +// #332 就配好的验收面,维护者把话说死了:「同一套负样本原样跑,抓错任何一条就不 +// 算落地」。所以本模块的形状是**先认形状再认关键词**——纯关键词匹配在那组负样本 +// 上全军覆没(「把 API key 放进环境变量」这类讨论句里一个凭据值都没有)。 +// +// 3. 报最严重的那一类(`kind` 落审计)。同一个字符串可能同时像两类(`sk_live_` 前缀 +// 既是 Stripe 的形状、也能被通用的 `sk-` 规则吃下),所以规则表按「越具体越靠前」 +// 排,命中即返回。密钥在前、PII 在后:两者误杀面差一个量级(密钥串出现在正常项目 +// 记忆里就是事故,邮箱 / 手机号完全可能是正当内容),同时命中时报更严重的那一类。 +// `kind` 是稳定判据键(不是展示文案),enforce 打开前先按它看分布,将来要按类放行 +// 也只改策略、不动判据。 +// +// 已知边界(都是「宁漏不误杀」的取舍,不是遗漏): +// - 只扫文本(title / content / tags)。不扫路径、id、时间戳这类元数据字段——它们 +// 由系统生成,不是用户写进来的内容,扫它们只会引入假阳。 +// - 中文关键词(密码 / 令牌)不认。补进去会让「密码必须脱敏后再入库」这类讨论句 +// 命中,而那正是负样本 group 的形状。要覆盖中文赋值得先设计「关键词语种 × 值形状」 +// 的两维判据,不在本批。 +// - 手机号只认大陆移动号段(1[3-9] + 9 位),固定电话与带国家码的写法不认。宽一位 +// 就会把长度相近的订单号 / 内部编号吃进来,而 PII 这一档的误杀面已经比密钥大一档。 +// - 银行卡号加 Luhn 校验:`0000000000000000`、`4111111111111112` 这类形状对但校验 +// 不过的串不报。少了这道校验,任何 16 位数字串(订单号、时间戳拼接)都会命中。 +// +// 归一化:这里**不**用 content-hash.js 的 normalizeForHash。那套口径(NFKC → 小写 → +// 去标点)是为「只差格式的两条写入是否同一件事」定的,判据要的是原串的形状——大小写 +// 是密钥 alphabet 的一部分(`AKIA` 与 `akia` 不是同一个值),去掉 `-` / `_` 会把 JWT +// 与 Slack token 的分段结构一起折没。两个口径别互换。 + +/** 判据命中的返回形状(与 `createWriteAdmission` 的 sensitiveScan 契约一致)。 */ +// kind 是稳定键、label 是给人看的一行说明;两者都落 llm_audit_logs 的 +// metadata.deny,所以别在其中塞运行时的值(命中的凭据本身绝不落盘 / 不回显)。 +const SECRET_RULES = [ + { kind: "aws_access_key", label: "AWS access key id", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, + // AWS 的 secret access key 没有前缀,只能靠赋值键认:`AWS_SECRET_ACCESS_KEY` / + // `aws_secret_access_key` 这个键名是它的形状。最早的一批 `secret` 关键词规则被 + // 「必须在赋值号前」的位置要求挡在外面(多词键里 `secret` 后面跟的是 `_`,不是 + // `:`/`=`),所以它得单独一条。40 位下限取 AWS 官方密钥的固定长度。 + { + kind: "aws_secret_key", + label: "AWS secret access key", + // 键名不分大小写,所以挂 `/i` 而不是行内修饰符组 `(?i:...)`:后者是 ES2025 语法, + // CI 矩阵里的 Node 22 直接抛 SyntaxError(本文件其余规则也没有用它的)。 + // 值那一半本来就是 `[A-Za-z0-9/+=]`,带上 `/i` 不改变大小写敏感度。 + re: /(?:aws[_-]?secret[_-]?access[_-]?key)\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])/i + }, + { kind: "github_token", label: "GitHub token", re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b/ }, + { kind: "slack_token", label: "Slack token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ }, + // PEM 头是明文私钥的确定标志,带不带正文都一样判——`BEGIN` 与 `PRIVATE KEY` 之间 + // 可能有 `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,}@/ }, + // 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/ }, + { kind: "npm_token", label: "npm auth token", re: /_authToken\s*=\s*[A-Za-z0-9_-]{20,}/ }, + // 下限 24 而不是 20:这是一条纯收紧、不动语料读数的加固——OpenAI 的 key 没有校验 + // 位,前缀 + 长度是唯一形状,20 位会把更长的假阳性面留在表里。`sk-` 后面跟短串的 + // 赋值句仍会被赋值型那条认下(那是**对的**:值长了 8 位以上就该报)。 + { kind: "openai_key", label: "OpenAI API key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{24,}\b/ }, + { + // 赋值型:最常见的一类,也是负样本组(占位符 / 环境变量 / 模板变量 / 只提关键词) + // 的主要靶子。判据是「关键词 + 赋值号 + 右边真的是个值的形状」,值本身不校验 + // 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, + // 占位符守卫:右边是尖括号占位、shell / 模板变量、环境变量读取、或一串 x / * / … + // 时不算命中。少这道守卫,`password: ` 与 `token: ${TOKEN}` 都会被报, + // 而它们正是「配置里该怎么写」的示例文本。 + guard: (value) => !/^(?:<[^>]*>|\$\{|\$[A-Z_]+$|process\.env|redacted|xx+|\*+|\u2026)/i.test(value) + } +]; + +const PII_RULES = [ + { + kind: "email", + 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/ + }, + { kind: "cn_mobile", label: "mainland mobile number", re: /(? 19) return false; + let sum = 0; + let double = false; + for (let i = digits.length - 1; i >= 0; i--) { + let d = digits.charCodeAt(i) - 48; + if (double) { + d *= 2; + if (d > 9) d -= 9; + } + sum += d; + double = !double; + } + return sum % 10 === 0; +} + +/** + * 扫一段文本里的密钥 / PII。命中返回 `{kind, label}`、未命中返回 null,不抛。 + * + * 只认第一处命中(按规则表顺序 = 严重度顺序)。一次写入里出现第二条凭据时不再报—— + * deny 面只记判据类别,报多条只是把同一件事写几遍。 + * @param {unknown} value + * @returns {{kind: string, label: string}|null} + */ +export function scanSensitive(value) { + const text = typeof value === "string" ? value : String(value ?? ""); + if (!text) return null; + for (const rule of [...SECRET_RULES, ...PII_RULES]) { + const match = text.match(rule.re); + if (!match) continue; + // 守卫只看捕获组:没有捕获组的规则天然没有守卫。 + if (rule.guard && !rule.guard(match[1] ?? "")) continue; + if (rule.luhn && !passesLuhn(match[0])) continue; + return { kind: rule.kind, label: rule.label }; + } + return null; +} + +/** + * 判据的两种调用面,共用同一份规则表: + * `scanSensitive(text)` — 任意文本,给写入边界之外的消费方(autoSummarize / + * dream 输出)直接调;它们手里是文本,不是记忆行。 + * `createSensitiveScan({config})` — `write-admission.js` 的 `sensitiveScan` 契约, + * 入参是记忆对象、返回 `{kind, label}` 或 null。 + * + * 开关在工厂里判一次:`sensitiveScanEnabled` 关时返回 null,闸门侧连函数都拿不到, + * 于是「第 1 级只跑空白 / 噪声」这条路与 #332 合并时**逐字段一致**(那一版根本没有 + * 这个函数)。反过来,本键开着也不会自己去扫:唯一的调用点是闸门的 firstLevelHit, + * 而闸门要 `writeAdmission.enabled` 打开才走第 1 级判据。所以两个键是「闸门」与 + * 「闸门内这一批判据」的关系,不是互为子开关——任一个关着,A2 都不产生任何判定。 + * 分成两个键是为了能单独观察 A2 的命中分布(空白 / 噪声与密钥 / PII 的误杀面差一个 + * 量级),不是为了让它能脱离闸门独立生效。 + * + * @param {{config?: object}} [deps] + * @returns {((memory: object) => ({kind: string, label: string}|null))|null} + */ +export function createSensitiveScan({ config } = {}) { + if (config?.sensitiveScanEnabled !== true) return null; + const scan = (memory) => { + const text = [memory?.title, memory?.content, ...(Array.isArray(memory?.tags) ? memory.tags : [])] + .filter((s) => typeof s === "string") + .join("\n"); + return scanSensitive(text); + }; + // 工厂只认「开 / 关」;enforce 由 write-admission 决定(命中即 deny 还是仅告警), + // 判据自己不碰决策——那一步在闸门里。两层分开就能先开检测看分布、再开拦截。 + return scan; +} diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index ed4b0ff..c214535 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -123,7 +123,11 @@ const FEATURE_FLAG_BOOLEANS = [ // enforce 真拦;都默认关。同上,点号键平铺存、合并时展开回 // writeAdmission 对象。 "writeAdmission.enabled", - "writeAdmission.enforce" + "writeAdmission.enforce", + // Issue #164 A2:写入边界的密钥 / PII 判据(src/sensitive-scan.js)。与上面两个 + // 键分层——本键决定「这类判据参不参与」(默认关),命中之后是仅告警还是真拦仍由 + // writeAdmission.enforce 决定(#164 口径:默认仅告警、拦截 opt-in)。 + "sensitiveScanEnabled" ]; // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。 const FEATURE_FLAG_INT_RANGES = { diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 6900b9b..7ef6531 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -673,8 +673,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // issue #24 块1 新增 graphAnchoringEnabled/graphSeedCap/graphCascadeDepth、 // 块2 新增 graphWeightEnabled/graphWeightDelta、块3 新增 graphInjectHint/graphInjectBudget、 // 块4 新增 graphPassiveConfirm、 - // issue #339 新增 dreamMergeGuard) - assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1); + // issue #339 新增 dreamMergeGuard、 + // issue #164 A2 新增 sensitiveScanEnabled(顶层扁平键,走 configFlagValue 默认分支)) + assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1); assert.equal(data.effective.dreamSkipInvalid, true); assert.equal(data.effective.allowCrossTypeMerge, false); assert.equal(data.effective.dreamMergeGuard, false); @@ -697,6 +698,8 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // #254:写入准入两个键都默认关(默认路径零行为变化),面板可逐项启停。 assert.equal(data.effective["writeAdmission.enabled"], false); assert.equal(data.effective["writeAdmission.enforce"], false); + // #164 A2:密钥/PII 判据自己的闸,同样默认关(#332 的行为逐字节保留)。 + assert.equal(data.effective.sensitiveScanEnabled, false); // 新增字符串 / URL / 枚举键 assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5"); assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434"); @@ -751,6 +754,9 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys" // 这里往返一次就是钉这件事的(计数锁只钉数量,钉不住值)。 "writeAdmission.enabled": true, "writeAdmission.enforce": true, + // #164 A2:顶层扁平键(不是对象子字段),与上面两个点号键走同一条往返; + // 它的默认值断言在计数锁那一条里,这里钉的是「PUT 存进去、effective 读得回」。 + sensitiveScanEnabled: true, embedProvider: "local", ollamaBaseUrl: "http://127.0.0.1:11434", dreamProvider: " siliconflow ", @@ -765,6 +771,7 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys" "llmAudit.enabled": false, "writeAdmission.enabled": true, "writeAdmission.enforce": true, + sensitiveScanEnabled: true, embedProvider: "local", ollamaBaseUrl: "http://127.0.0.1:11434", dreamProvider: "siliconflow", @@ -775,6 +782,7 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys" assert.equal(data.effective["llmAudit.enabled"], false); assert.equal(data.effective["writeAdmission.enabled"], true); assert.equal(data.effective["writeAdmission.enforce"], true); + assert.equal(data.effective.sensitiveScanEnabled, true); assert.equal(data.effective.embedProvider, "local"); assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434"); assert.equal(data.effective.dreamProvider, "siliconflow"); diff --git a/dsh-mneme/test/sensitive-scan.test.js b/dsh-mneme/test/sensitive-scan.test.js new file mode 100644 index 0000000..a34e9c2 --- /dev/null +++ b/dsh-mneme/test/sensitive-scan.test.js @@ -0,0 +1,126 @@ +// #164 A2:写入边界的密钥 / PII 判据(src/sensitive-scan.js)。 +// +// 这是「真判据」自己的验收,与 write-admission.test.js 里那组(拿参考扫描器测**闸门 +// 接线**)分工不同:这边测判据本身,且第一条用例就是把 #332 配好的 26 条语料原样跑在 +// 生产实现上——维护者 09-28 的验收条件就是这句「同一套负样本原样跑,抓错任何一条就 +// 不算落地」,所以它放在这里而不是再抄一份语料。 +// +// 参考扫描器 referenceScan 只服务闸门测;本文件刻意**不** import 它——生产判据与 +// 参考实现是两份独立实现,同时跑过同一套语料才算把语料用足(对照着改就成了自证)。 +import test from "node:test"; +import assert from "node:assert/strict"; + +import { scanSensitive, createSensitiveScan } from "../src/sensitive-scan.js"; +import { + SECRET_SAMPLES, + PII_SAMPLES, + POSITIVE_SAMPLES, + NEGATIVE_SAMPLES, + SAMPLE_VALUES +} from "./helpers/write-admission-samples.js"; + +// #164 A2 的语料里那几条凭据值走 helper 的拼接导出,别在测试里另抄一份字面量: +// GitHub 的 push protection 会按形状拦真实凭据(本 PR 实测:`sk_live_` 一条就够 +// 拒推,helper 正是为这件事把值拆成两段拼的)。这里复用同一份,仓库文本里不留完整形状。 +const STRIPE_KEY = SAMPLE_VALUES.stripeKey; + +// 正样本那条 OpenAI key 从语料里取,不另抄字面量(同 STRIPE_KEY 的理由)。 +const OPENAI_KEY = POSITIVE_SAMPLES.find((s) => s.id === "openai-key").content.match(/\S*sk-\S+/)[0]; +/** `sk-proj-…` 前缀的变体:语料那条不带 proj-,这里补同样的正文长度。 */ +const openaiProjKey = (body) => `sk-proj-${body}`; +const OPENAI_BODY = OPENAI_KEY.replace(/^.*?sk-(?:proj-)?/, ""); + +test("真判据:正样本一条都不漏放,且 kind 与语料声明一致", () => { + for (const sample of POSITIVE_SAMPLES) { + const text = `${sample.title}\n${sample.content}`; + const hit = scanSensitive(text); + assert.ok(hit, `${sample.id} 被漏放(${sample.why})`); + // kind 是审计行里的稳定判据键:它错了,「按 kind 看命中分布」这条路就断了。 + // 同一个串可能被多条规则吃下(`sk_live_` 也像通用 `sk-`),所以顺序是判据的一部分。 + assert.equal(hit.kind, sample.kind, `${sample.id} 的 kind 必须是语料声明的那个(规则顺序变了就会漂)`); + assert.equal(typeof hit.label, "string"); + } +}); + +test("真判据:负样本一条都不误杀(抓错任何一条就不算落地)", () => { + for (const sample of NEGATIVE_SAMPLES) { + const hit = scanSensitive(`${sample.title}\n${sample.content}`); + assert.equal(hit, null, `${sample.id} 被误杀(${sample.why}):实际命中 ${hit?.kind}`); + } +}); + +test("密钥与 PII 分两档,但都从同一条通道出来", () => { + // 分档的意义是「将来按类放行只改策略、不动判据」:kind 集合是策略的抓手, + // 所以这里断言两类都能被分辨,而不是把 PII 折进密钥那一档。 + assert.ok(SECRET_SAMPLES.length >= 1 && PII_SAMPLES.length >= 1); + assert.equal(scanSensitive("password = \"Tr0ub4dor&3xKcd\"").kind, "assigned_secret"); + assert.equal(scanSensitive("联系方式 zhang.wei@example.com").kind, "email"); + assert.equal(scanSensitive("值班手机 13800138000").kind, "cn_mobile"); +}); + +test("只扫文本:路径与 id 这类元数据字段不进判据", () => { + // 元数据由系统生成,不是用户写进来的内容;扫它们只会引入假阳(一个文件名里 + // `token` 这种词很常见)。契约是判据只看记忆的文本字段。 + const scan = createSensitiveScan({ config: { sensitiveScanEnabled: true } }); + assert.equal(scan({ title: "配置", content: "正常正文", id: SAMPLE_VALUES.githubPat, doc_path: STRIPE_KEY }), null); + // 反过来,正文里的凭据必须报——上面那条不是靠「什么都扫不到」通过的。 + assert.ok(scan({ title: "配置", content: `正文里有 ${SAMPLE_VALUES.githubPat}` })); +}); + +test("tags 也扫:标签是用户输入的自由文本", () => { + const scan = createSensitiveScan({ config: { sensitiveScanEnabled: true } }); + assert.equal(scan({ title: "正常标题", content: "正常正文", tags: ["正常"] }), null); + assert.equal(scan({ title: "正常标题", content: "正常正文", tags: [SAMPLE_VALUES.githubPat] })?.kind, "github_token"); +}); + +test("工厂:判据自己的闸关时连函数都不给(闸门侧行为与 #332 逐字段一致)", () => { + assert.equal(createSensitiveScan({ config: { sensitiveScanEnabled: false } }), null); + assert.equal(createSensitiveScan({ config: {} }), null); + assert.equal(createSensitiveScan({}), null, "没有 config 也不能默认开"); + assert.equal(createSensitiveScan({ config: { sensitiveScanEnabled: "true" } }), null, "字符串 true 不是 true"); + const scan = createSensitiveScan({ config: { sensitiveScanEnabled: true } }); + assert.equal(typeof scan, "function"); +}); + +test("工厂不碰决策:命中只报 kind,拦不拦由闸门的 enforce 决定", () => { + // 判据与闸门分层(#164:默认仅告警、拦截 opt-in)——判据知道「这是什么」, + // 但不知道也不该知道「要不要拦」。返回值里没有 decision / enforced 这类字段, + // 就是这条分层的机器可判点。 + const scan = createSensitiveScan({ + config: { sensitiveScanEnabled: true, writeAdmission: { enabled: true, enforce: false } } + }); + const hit = scan({ title: "调试", content: openaiProjKey(OPENAI_BODY) }); + assert.deepEqual(Object.keys(hit).sort(), ["kind", "label"]); +}); + +test("不抛:坏输入返回 null,扫描器故障不会把写入变成不可用", () => { + assert.equal(scanSensitive(undefined), null); + assert.equal(scanSensitive(null), null); + assert.equal(scanSensitive(""), null); + assert.equal(scanSensitive(12345), null); + const scan = createSensitiveScan({ config: { sensitiveScanEnabled: true } }); + assert.equal(scan(null), null); + assert.equal(scan({ title: 7, content: null, tags: "not-an-array" }), null); +}); + +test("一处命中只报最严重的那条(deny 面记类别,不记条数)", () => { + // 一篇同时带 Stripe 密钥与邮箱的正文:报密钥那一档。反过来把 PII 排在前面 + // 会让「这条命中过密钥」这个更严重的事实被邮箱盖掉。 + const text = `邮箱 a@b.com,另外 ${STRIPE_KEY}`; + assert.equal(scanSensitive(text).kind, "stripe_key"); +}); + +test("语料之外的探针:漏放那一类锁一条(实现期实测出来的,不是假想)", () => { + // 不在 #332 的 26 条语料里,是拿真判据跑额外形状时发现的:AWS secret access key + // 没有前缀,`secret` 关键词规则又要求它后面紧跟赋值号,多词键 + // (`AWS_SECRET_ACCESS_KEY=`)两条都够不着 → 补了专用的键名规则。 + assert.equal( + scanSensitive("AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY").kind, + "aws_secret_key" + ); + // 反向确认「值 + 赋值键」这条兜底规则还在工作,而且**不**看值的形状:`api_key =` + // 后面跟任何 8 位以上的值都报(判据不知道哪个值是真的)。 + assert.equal(scanSensitive("api_key = sk-abcdefghijklmnopqrst").kind, "assigned_secret"); + // 收紧后的开关键字仍然认 24 位以上的真 key(44 位正文,语料那条)。 + assert.equal(scanSensitive(openaiProjKey(OPENAI_BODY)).kind, "openai_key"); +}); diff --git a/dsh-mneme/test/write-admission.test.js b/dsh-mneme/test/write-admission.test.js index 036c918..d419622 100644 --- a/dsh-mneme/test/write-admission.test.js +++ b/dsh-mneme/test/write-admission.test.js @@ -17,6 +17,12 @@ import { ADMISSION_TRIGGER_SOURCE } from "../src/write-admission.js"; import { POSITIVE_SAMPLES, NEGATIVE_SAMPLES, SAMPLE_VALUES, referenceScan } from "./helpers/write-admission-samples.js"; +// #164 A2 的真判据 + 真配置:这一组测的是**接线**(index.js 那条路径的等价物), +// 判据自己的验收在 test/sensitive-scan.test.js。config 用真 schema 而不是手搓对象, +// 钉住「键名/嵌套形状与生产一致」——手搓的 `{ sensitiveScanEnabled: true }` 在键名 +// 写错时照样过,真 config 才会红。 +import { Config } from "../src/config.js"; +import { createSensitiveScan } from "../src/sensitive-scan.js"; // #254 写入准入(第一阶段:只计量,不拦截)。 // 本批次没有阈值、没有拦截分支,验收看两件事:默认路径零行为变化(无会话身份的 @@ -551,3 +557,112 @@ test("接线:被拒时 memory_save 返回 action=denied 与可行动的 reason assert.ok(ok.id, "正常路径仍然返回 id"); }); +// --- #164 A2 接线:判据自己的闸 × 闸门的 enforce ------------------------------- +// 这一组是 index.js 那条装配路径的等价物:真 config(Config({}))+ createSensitiveScan +// 工厂 + 写入准入。三层开关的语义由此钉住——判据关(默认)不参与、判据开而 enforce 关 +// 只留审计、两个都开才拦。判据本身的假阳/漏放验收在 test/sensitive-scan.test.js。 + +/** 用真 `Config` 解析默认值,再叠加测试要覆盖的那几个键。 */ +function a2Store(overrides = {}) { + const cfg = Config(overrides); + const store = createStore(":memory:"); + const writeAdmission = createWriteAdmission({ + store, + config: cfg, + sensitiveScan: createSensitiveScan({ config: cfg }) + }); + const service = createService({ store, mirror: null, config: cfg, writeAdmission }); + return { store, service, cfg }; +} + +const SECRET_ROW = { + type: "project", + title: "调试记录", + content: `临时代码里贴了 ${SAMPLE_VALUES.githubPat},回头删掉` +}; + +test("A2 默认关:写有密钥的行照常落库,且连拒绝面都不该出现", () => { + const { store, service } = a2Store(); + const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" }); + assert.equal(result.action, "created", "判据默认关时这条路径与 #332 逐字段一致"); + assert.equal(rowCount(store), 1); + // metadata 里不该多出任何 deny/decision 键——「默认关零行为变化」是验收第 1 条, + // 多一个恒为 allow 的键就把它从事实变成需要解释的说法(同上面 #254 那条)。 + const row = admissionRows(store, "s")[0]; + assert.equal(row.metadata.deny, undefined); + assert.equal(row.metadata.decision, undefined); +}); + +test("A2 观察档:判据开、enforce 关 → 写入照常,审计行带 kind 与 enforced=false", () => { + const { store, service } = a2Store({ sensitiveScanEnabled: true, writeAdmission: { enabled: true } }); + const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" }); + assert.equal(result.action, "created", "#164 口径:默认仅告警"); + assert.equal(rowCount(store), 1); + const deny = admissionRows(store, "s")[0].metadata.deny; + assert.equal(deny.reason, "sensitive"); + assert.equal(deny.kind, "github_token"); + assert.equal(deny.enforced, false, "仅告警档的指纹:deny 非空、enforced=false、写入仍发生"); +}); + +test("A2 拦截档:判据开 + enforce 开 → 拒了、没落库、审计标 enforced", () => { + const { store, service } = a2Store({ + sensitiveScanEnabled: true, + writeAdmission: { enabled: true, enforce: true } + }); + const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" }); + assert.equal(result.action, "denied"); + assert.equal(result.reason, "sensitive"); + assert.equal(rowCount(store), 0, "被拒的写入一条都不该落库"); + const deny = admissionRows(store, "s")[0].metadata.deny; + assert.equal(deny.kind, "github_token"); + assert.equal(deny.enforced, true); +}); + +test("A2 与第 1 级空白判据互不依赖:只开 A2 不会把空白写入拦下", () => { + // 两个键是两批判据的闸(空白/噪声 vs 密钥/PII),分开是为了能单独观察 A2 的 + // 命中分布——绑在一个开关上就没法只看这一类的假阳率。 + const { store, service } = a2Store({ sensitiveScanEnabled: true }); + const blank = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "...", content: "" }); + assert.equal(blank.action, "created", "A2 开着不等于第 1 级判据也开"); + assert.equal(admissionRows(store, "s")[0].metadata.deny, undefined); + // 反过来:只开第 1 级(writeAdmission.enabled)、A2 关,密钥行照常落库。 + const { store: s2, service: svc2 } = a2Store({ writeAdmission: { enabled: true } }); + assert.equal(svc2.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" }).action, "created"); + assert.equal(s2.list({ limit: 10 }).length, 1); +}); + +test("A2 跑在闸门里:只开 sensitiveScanEnabled(闸门关)时扫描器根本不参与", () => { + // 判据的唯一调用点是 write-admission 的 firstLevelHit,而闸门要 + // writeAdmission.enabled 打开才走第 1 级判据。所以「本键开着」不等于「会扫」—— + // 配置注释与面板文案都按这条写,否则用户以为单开本键就能拿到 A2 的命中分布。 + const { store, service } = a2Store({ sensitiveScanEnabled: true }); + const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" }); + assert.equal(result.action, "created", "闸门没开时 A2 不参与判定"); + assert.equal(rowCount(store), 1, "写入照常落库"); + const rows = admissionRows(store, "s"); + assert.ok(rows.length > 0, "闸门仍走计量路径(去重 / g2 不受第 1 级开关影响)"); + assert.ok(rows.every((row) => row.metadata.deny === undefined), "一条 deny 审计都没有:第 1 级判据整体没跑"); +}); + +test("A2 负样本在真装配下一条都不误杀(含敏感词但没有值)", () => { + const { store, service } = a2Store({ + sensitiveScanEnabled: true, + writeAdmission: { enabled: true, enforce: true } + }); + for (const sample of NEGATIVE_SAMPLES) { + const result = service.saveWithDedupe({ + _sessionKey: `sess-${sample.id}`, + type: "project", + title: sample.title, + content: sample.content + }); + assert.equal(result.action, "created", `${sample.id} 被误杀(${sample.why})`); + } + assert.equal(rowCount(store), NEGATIVE_SAMPLES.length); + for (const sample of NEGATIVE_SAMPLES) { + for (const row of admissionRows(store, `sess-${sample.id}`)) { + assert.equal(row.metadata.deny, undefined, `${sample.id} 不该带任何拒绝面`); + } + } +}); +