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 +13 -0
- package/README.md +3 -1
- package/index.js +63 -11
- package/package.json +1 -1
- package/test.mjs +52 -0
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.
|
|
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
|
|
464
|
-
if (typeof
|
|
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(
|
|
468
|
-
|
|
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
|
-
|
|
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
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 });
|