dsh-sensitive-guard 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.3.4
4
+
5
+ - 修复句尾语气词被吞进密码:`我的密码是123456吗` 以前会把 `123456吗` 整个当成秘密入库,于是哈希与 `123456` 不同、导出时连语气词一起交出。现在命中值末尾的 `吗呢啊吧呀嘛哦呗啦么哈` 与标点会先剥掉再入库,占位符只盖住秘密本身。
6
+ - 全角标点加入取值排除集:原来只认半角 `,;)`,`123456,对吗` 会一路吃到「对」。
7
+ - 自测 64 → 69 项,新增四种写法共用同一占位符、问号不吞、全角逗号切断。
8
+
9
+ ## 0.3.3
10
+
11
+ - 修复被换行打断的厂商 key 识别不到:从网页或截图里粘贴时,`sk-proj-…` 常被插进 `\r`、`\n` 或 `\r\n`,而所有单行规则都会在断点处失配。新增 `wrapped-key` 规则,跨行匹配后把值里的空白去掉再入库,因此与完整写法共用同一个占位符。
12
+ - 新增 `debug.pre`(只出现在 `/guard/vault`,未授权来源不返回):记录 pre-step 每次调用的 payload 字段、消息数、`kind` 与退出阶段,用于确认宿主升级后钩子是否仍然生效。
13
+ - 中文语境的密码下限降到 6 位:`我的密码是 123456` 这类现在会拦(6 位数字密码很常见)。英文和 `key=value` 形式保持 8 位,避免把 `the secret is hidden` 里的 `hidden` 当成密码。
14
+ - 自测 56 → 64 项,覆盖 CRLF、纯 CR、占位符复用、「普通换行不误伤」与中英文阈值差异。
15
+
3
16
  ## 0.3.2
4
17
 
5
18
  - `exports` 增加 `./package.json`,修掉 `require('dsh-sensitive-guard/package.json')` 报 `ERR_PACKAGE_PATH_NOT_EXPORTED`。
package/README.md CHANGED
@@ -110,12 +110,14 @@ sensitive_guard_export(<SG_xxx>) 明文写到 0600 文件,返回路径
110
110
 
111
111
  赋值和环境变量:`password=`、`secret:`、`api_key:`、`SECRET_KEY=`、`DB_PASSWORD=`。
112
112
 
113
- 中文说法:`数据库密码是…`、`我的 token 是…`。
113
+ 中文说法:`数据库密码是…`、`我的 token 是…`。中文语境的密码从 6 位起就拦(`我的密码是 123456`),因为 6 位数字密码很常见;英文和 `key=value` 形式从 8 位起,否则 `the secret is hidden` 里的 `hidden` 会被当成密码。句尾的语气词(`吗`/`呢`/`吧`)和标点会留在占位符外面,`我的密码是123456吗` 遮的是 `123456`,`吗` 原样保留。
114
114
 
115
115
  连接串:`mysql://user:pass@host`、`postgresql://…`、JDBC 参数里的 `password=`、`X-Api-Key:`。
116
116
 
117
117
  命令行:`mysql -uroot -pabc12345`、`redis-cli -a xxx`。只认这几个客户端,`docker run -p 8080:80` 不会被误伤。
118
118
 
119
+ 被换行打断的 key 也认:从网页、截图或终端里粘贴时,`sk-proj-…` 常被插进 `\r`、`\n` 或 `\r\n`(`\r` 在界面上看着仍是同一行)。这类会按跨行匹配,把两段拼回完整值再入库,占位符与不换行时完全相同。
120
+
119
121
  **可选的熵值检测,默认关闭。** `rules.json` 里加 `"entropy": true` 才会开。它抓的是没有前缀的随机串,但只对 base64 或混合字符集有用(熵约 4.8 到 5.0 bits/char)。纯 hex 串的上限就是 4.0,永远够不到 4.5 的默认阈值,所以 `8f3a9b2c…` 这种开了也抓不到;把阈值降到 3.9 又会误伤英文长句。另外 base64 图片、commit hash、UUID 都会被判成高熵。
120
122
 
121
123
  抓不到的三类:自研格式的凭据,得自己往 `extraRules` 加正则;二进制和图片里的;被 base64 或 hex 二次编码搬运过的。
package/index.js CHANGED
@@ -34,13 +34,22 @@ import path from 'node:path';
34
34
  const VAULT_ROUTE = '/guard/vault';
35
35
  const REVEAL_ROUTE = '/guard/reveal';
36
36
  const SETTINGS_ROUTE = '/guard/settings';
37
- const PLUGIN_VERSION = '0.3.2';
37
+ const PLUGIN_VERSION = '0.3.4';
38
38
 
39
39
  /** Stable placeholder form: <SG_ + 12 hex chars of sha256(plaintext) + >. */
40
40
  const PH_EXACT = /^<SG_[0-9a-f]{12}>$/;
41
41
  const PH_HINT = '<SG_';
42
42
  const HASH_EXACT = /^[0-9a-f]{12}$/;
43
43
 
44
+ /**
45
+ * Prose rules capture the rest of the token, so a trailing Chinese particle or
46
+ * punctuation mark rides along: "我的密码是 123456吗" would vault `123456吗` and
47
+ * hand the particle back on export, with a different hash than the bare secret.
48
+ * Stripped only by rules that opt in (`trimTail`), and only while something
49
+ * worth masking is left.
50
+ */
51
+ const TAIL_JUNK = /(?:[??!!。.,,;;::、'"`“”‘’()()[\]【】《》「」『』]|[吗呢啊吧呀嘛哦呗啦么哈]+)$/u;
52
+
44
53
  /**
45
54
  * Vendor prefixes are not the secret — the tail is. Keeping `sk-` / `ghp_` in
46
55
  * place keeps the sentence readable and tells you which service leaked, while
@@ -317,6 +326,10 @@ function entryViewForModel(entry) {
317
326
 
318
327
  const BUILTIN_RULES = [
319
328
  { id: 'openai-key', group: 0, re: /\bsk-[A-Za-z0-9_-]{16,}\b/g },
329
+ // A pasted key whose display soft-wrapped: the prefix and the tail land on
330
+ // different lines, so every single-line rule misses it. `squash` joins the
331
+ // halves back into one value before it reaches the vault.
332
+ { id: 'wrapped-key', group: 1, squash: true, re: /\b((?:sk-|sk_|ghp_|glpat-|npm_|hf_|AKIA|ASIA|AIza|whsec_)[A-Za-z0-9_-]{0,32}[ \t]*(?:\r\n|\r|\n)[ \t]*[A-Za-z0-9_-]{12,})/g },
320
333
  { id: 'openai-live', group: 0, re: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
321
334
  { id: 'github-token', group: 0, re: /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,})\b/g },
322
335
  { id: 'gitlab-token', group: 0, re: /\bglpat-[A-Za-z0-9_-]{16,}\b/g },
@@ -336,7 +349,11 @@ const BUILTIN_RULES = [
336
349
  // assign-only rule silently missed every sentence-shaped secret. The optional
337
350
  // `[A-Za-z_]{0,12}` tail catches env-style names such as SECRET_KEY or
338
351
  // DB_PASSWORD_HASH, where `secret` is glued to a suffix by an underscore.
339
- { id: 'keyword-prose', group: 2, re: /(password|passwd|pwd|secret|token|api[\s_-]?key|apikey|access[\s_-]?key|client[\s_-]?secret|密码|口令|密钥|令牌|凭证|私钥)[A-Za-z_]{0,12}\s*(?:[:=:]|是|为|就是|is)\s*["'`]?([^\s"'`,;)}\]]{8,})/gi },
352
+ { id: 'keyword-prose', group: 2, trimTail: true, re: /(password|passwd|pwd|secret|token|api[\s_-]?key|apikey|access[\s_-]?key|client[\s_-]?secret|密码|口令|密钥|令牌|凭证|私钥)[A-Za-z_]{0,12}\s*(?:[:=:]|是|为|就是|is)\s*["'`]?([^\s"'`,;)}\],。、;:!?()【】《》「」『』“”‘’…]{8,})/gi },
353
+ // Chinese prose gets a lower floor: "我的密码是 123456" is a real secret and
354
+ // six-digit passwords are common. English prose keeps 8 so that sentences like
355
+ // "the secret is hidden" are not masked on the word "hidden".
356
+ { id: 'keyword-prose-cn', group: 2, trimTail: true, re: /(密码|口令|密钥|令牌|凭证|私钥)\s*(?:[:=:]|是|为|就是)\s*["'`]?([^\s"'`,;)}\],。、;:!?()【】《》「」『』“”‘’…]{6,})/gi },
340
357
  // Opaque bearer credentials have no vendor prefix and no `key: value` shape.
341
358
  { id: 'bearer-token', group: 1, re: /\bBearer\s+([A-Za-z0-9\-._~+/]{12,}={0,2})/gi },
342
359
  // Command-line passwords. Scoped to clients whose `-p` / `-a` really means a
@@ -460,16 +477,30 @@ function findHits(text) {
460
477
  let match;
461
478
  while ((match = rule.re.exec(scan)) !== null) {
462
479
  if (match[0].length === 0) { rule.re.lastIndex += 1; continue; }
463
- const value = match[rule.group];
464
- if (typeof value !== 'string' || value.length < 6) continue;
480
+ const raw = match[rule.group];
481
+ if (typeof raw !== 'string') continue;
482
+ // A key that got soft-wrapped when pasted carries a line break inside it.
483
+ // `squash` joins it back so the vault hashes the clean value and the
484
+ // placeholder stays identical to the unwrapped capture.
485
+ const value = rule.squash ? raw.replace(/\s+/g, '') : raw;
486
+ if (value.length < 6) continue;
465
487
  if (value.includes(PH_HINT)) continue;
466
488
  const range = match.indices && match.indices[rule.group];
467
- const offset = range ? range[0] : match.index + match[0].indexOf(value);
468
- const end = range ? range[1] : offset + value.length;
489
+ const offset = range ? range[0] : match.index + match[0].indexOf(raw);
490
+ let end = range ? range[1] : offset + raw.length;
491
+ // Trim after the offsets are known, so the placeholder covers only the
492
+ // secret and the particle stays as readable text.
493
+ let trimmed = value;
494
+ if (rule.trimTail) {
495
+ let prev;
496
+ do { prev = trimmed; trimmed = trimmed.replace(TAIL_JUNK, ''); } while (trimmed !== prev);
497
+ if (trimmed.length < 6) continue;
498
+ end -= value.length - trimmed.length;
499
+ }
469
500
  // The keyword group (when the rule has one) says *what* leaked — password /
470
501
  // token / key — which the rule id alone cannot express for keyword-prose.
471
502
  const label = rule.group > 1 && typeof match[1] === 'string' ? match[1] : rule.id;
472
- hits.push({ offset, end, value, rule: rule.id, label });
503
+ hits.push({ offset, end, value: trimmed, rule: rule.id, label });
473
504
  }
474
505
  }
475
506
  if (fileRules.entropy) {
@@ -551,16 +582,32 @@ function scrubDeep(value, origin, key) {
551
582
 
552
583
  /* ----------------------------------------------------------------- hooks -- */
553
584
 
585
+ /** Last pre-step invocation, exposed via /guard/vault while diagnosing. */
586
+ const debugPre = { at: 0, stage: 'never', msgs: -1, isArray: false, keys: null, kind: null, changed: false, error: null };
587
+
554
588
  function attachHooks(ctx) {
555
589
  // Ingress A: user messages entering the step (before the log commit).
556
590
  ctx.on('agent/pre-step', async (payload, next) => {
557
- const decision = await next();
591
+ debugPre.at = Date.now();
592
+ debugPre.stage = 'entered';
593
+ debugPre.keys = payload && typeof payload === 'object' ? Object.keys(payload) : null;
594
+ debugPre.isArray = Boolean(payload && Array.isArray(payload.messages));
595
+ debugPre.msgs = payload && Array.isArray(payload.messages) ? payload.messages.length : -1;
596
+ let decision;
597
+ try {
598
+ decision = await next();
599
+ debugPre.kind = decision && decision.kind;
600
+ } catch (error) {
601
+ debugPre.stage = 'next-threw';
602
+ debugPre.error = String((error && error.message) || error);
603
+ throw error;
604
+ }
558
605
  try {
559
606
  // A rejected step never enters the log; rewriting its messages would only
560
607
  // corrupt the shape the loop reads back.
561
- if (!decision || decision.kind !== 'enter') return decision;
562
- if (pausedMs() > 0) return decision;
563
- if (!payload || !Array.isArray(payload.messages)) return decision;
608
+ if (!decision || decision.kind !== 'enter') { debugPre.stage = 'skip:kind'; return decision; }
609
+ if (pausedMs() > 0) { debugPre.stage = 'skip:paused'; return decision; }
610
+ if (!payload || !Array.isArray(payload.messages)) { debugPre.stage = 'skip:shape'; return decision; }
564
611
  let changed = false;
565
612
  const messages = payload.messages.map((message) => {
566
613
  if (!message || typeof message !== 'object') return message;
@@ -569,9 +616,13 @@ function attachHooks(ctx) {
569
616
  changed = true;
570
617
  return { ...message, content };
571
618
  });
619
+ debugPre.changed = changed;
620
+ debugPre.stage = changed ? 'masked' : 'skip:no-hit';
572
621
  if (!changed) return decision;
573
622
  return { ...decision, messages };
574
623
  } catch (error) {
624
+ debugPre.stage = 'threw';
625
+ debugPre.error = String((error && error.message) || error);
575
626
  lastError = `pre-step: ${String((error && error.message) || error)}`;
576
627
  return decision;
577
628
  }
@@ -1063,6 +1114,7 @@ function registerRoutes(ctx) {
1063
1114
  suggested: restricted ? suggestCidr(remoteIp(req)) : null,
1064
1115
  host: String(req.headers.host || ''),
1065
1116
  },
1117
+ debug: restricted ? undefined : { pre: { ...debugPre } },
1066
1118
  });
1067
1119
  },
1068
1120
  }), 'sensitive-guard: vault route');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sensitive-guard",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "敏感保护:进入模型上下文前把密钥/令牌/密码替换为 <SG_> 占位符,明文加密存本地保管库;设置页与对话内提供小锁点击还原,工具可将明文导出为 0600 文件供命令执行时使用。",
5
5
  "license": "MIT",
6
6
  "author": "wangxinyu",
package/test.mjs CHANGED
@@ -369,6 +369,58 @@ await vaultRoute.handler({ method: 'GET', socket: { remoteAddress: '198.51.100.1
369
369
  const allowedBody = JSON.parse(allowedPreview.body || '{}');
370
370
  check('allowlisted peer still sees preview', allowedBody.restricted === false && allowedBody.entries.some((e) => 'preview' in e), JSON.stringify(allowedBody.entries[0] || {}));
371
371
 
372
+ /* --------------------------------------------------- wrapped / soft wrap -- */
373
+
374
+ console.log('wrapped key');
375
+
376
+ const runPre = async (text) => {
377
+ const decision = await pre(
378
+ { messages: [{ content: text }] },
379
+ async () => ({ kind: 'enter', messages: [] }),
380
+ );
381
+ // Unchanged text comes back as the mock's empty array; treat that as "as-is".
382
+ return decision.messages.length ? decision.messages[0].content : text;
383
+ };
384
+
385
+ const cleanOut = await runPre('我的 api key 是 sk-proj-abcdefghijklmnopqrstuvwx');
386
+ const cleanPh = (cleanOut.match(/<SG_[0-9a-f]{12}>/) || [])[0];
387
+ check('完整 key 正常遮挡', Boolean(cleanPh) && cleanOut.startsWith('我的 api key 是 sk-'), cleanOut);
388
+
389
+ const crlfOut = await runPre('我的 api key 是 sk-\r\nproj-abcdefghijklmnopqrstuvwx');
390
+ check('CRLF 打断的 key 能识别', /sk-<SG_[0-9a-f]{12}>$/.test(crlfOut) && !crlfOut.includes('proj-'), JSON.stringify(crlfOut));
391
+ check('换行版复用同一个占位符', crlfOut.includes(cleanPh), `${crlfOut} vs ${cleanPh}`);
392
+
393
+ const crOut = await runPre('我的 api key 是 sk-\rproj-abcdefghijklmnopqrstuvwx');
394
+ check('纯 CR 打断的 key 能识别', /sk-<SG_[0-9a-f]{12}>$/.test(crOut), JSON.stringify(crOut));
395
+
396
+ const proseOut = await runPre('第一行文字\n第二行文字继续');
397
+ check('普通换行不误伤', proseOut === '第一行文字\n第二行文字继续', JSON.stringify(proseOut));
398
+
399
+ /* ------------------------------------------------ short password floor -- */
400
+
401
+ console.log('password floor');
402
+
403
+ const six = await runPre('我的密码是 123456');
404
+ check('中文语境 6 位密码也拦', /我的密码是 <SG_[0-9a-f]{12}>$/.test(six), JSON.stringify(six));
405
+
406
+ const five = await runPre('我的密码是 12345');
407
+ check('5 位仍不拦', five === '我的密码是 12345', JSON.stringify(five));
408
+
409
+ const english = await runPre('the secret is hidden');
410
+ check('英文句子里 6 字单词不误伤', english === 'the secret is hidden', JSON.stringify(english));
411
+
412
+ // A trailing particle must stay outside the placeholder, and all spellings of
413
+ // the same secret must share one hash — otherwise export hands back "123456吗".
414
+ const withSpace = await runPre('我的密码是 123456 吗?');
415
+ const noSpace = await runPre('我的密码是123456吗');
416
+ const fullComma = await runPre('我的密码是123456,对吗');
417
+ const bare = await runPre('密码是 123456 吗');
418
+ check('语气词留在占位符外(有空格)', withSpace === '我的密码是 <SG_8d969eef6eca> 吗?', withSpace);
419
+ check('语气词留在占位符外(无空格)', noSpace === '我的密码是<SG_8d969eef6eca>吗', noSpace);
420
+ check('全角逗号切断取值', fullComma === '我的密码是<SG_8d969eef6eca>,对吗', fullComma);
421
+ check('四种写法共用同一占位符', new Set([withSpace, noSpace, fullComma, bare].map((s) => (s.match(/<SG_[0-9a-f]{12}>/) || [''])[0])).size === 1);
422
+ check('句尾问号不被吞进密码', (await runPre('我的密码是 abcdef?')).endsWith('<SG_') === false);
423
+
372
424
  /* --------------------------------------------------------------------- */
373
425
 
374
426
  fs.rmSync(TEMP_HOME, { recursive: true, force: true });