dsh-config-manager 0.1.39 → 0.1.41

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.
Files changed (50) hide show
  1. package/lib/client.d.ts +90 -8
  2. package/lib/client.js +1904 -1086
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/cache-cleaner.d.ts +39 -0
  5. package/lib/core/cache-cleaner.js +164 -0
  6. package/lib/core/cache-cleaner.js.map +1 -0
  7. package/lib/index.js +172 -3
  8. package/lib/index.js.map +1 -1
  9. package/lib/market/github-repos.d.ts +19 -8
  10. package/lib/market/github-repos.js +62 -33
  11. package/lib/market/github-repos.js.map +1 -1
  12. package/lib/market/my-repo.d.ts +111 -3
  13. package/lib/market/my-repo.js +309 -26
  14. package/lib/market/my-repo.js.map +1 -1
  15. package/lib/market/prepare.js +3 -2
  16. package/lib/market/prepare.js.map +1 -1
  17. package/lib/market/types.d.ts +2 -0
  18. package/lib/security/secret-scanner.d.ts +18 -3
  19. package/lib/security/secret-scanner.js +144 -20
  20. package/lib/security/secret-scanner.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/client/common/ConfirmDialog.tsx +135 -0
  23. package/src/client/config-manager.module.css +101 -0
  24. package/src/client/market/MarketPanel.tsx +138 -45
  25. package/src/client/market/MyConfigsView.tsx +515 -132
  26. package/src/client/market/disclaimer.test.ts +57 -0
  27. package/src/client/market/disclaimer.ts +43 -0
  28. package/src/client/market/market-api.test.ts +3 -0
  29. package/src/client/market/market-locales.ts +37 -13
  30. package/src/client/market/my-configs-api.ts +20 -2
  31. package/src/client/market/my-configs-view.test.ts +137 -2
  32. package/src/client/market/my-configs-view.ts +174 -2
  33. package/src/client/run-store.test.ts +40 -2
  34. package/src/client/run-store.ts +70 -10
  35. package/src/client/sync/SyncConfirmView.tsx +28 -22
  36. package/src/client/sync/SyncSettingsView.tsx +175 -65
  37. package/src/client/sync/sync-api.ts +19 -0
  38. package/src/client/sync/sync-locales.ts +14 -2
  39. package/src/core/cache-cleaner.test.ts +333 -0
  40. package/src/core/cache-cleaner.ts +196 -0
  41. package/src/index.ts +165 -5
  42. package/src/market/github-repos.test.ts +99 -8
  43. package/src/market/github-repos.ts +67 -35
  44. package/src/market/my-repo.test.ts +218 -27
  45. package/src/market/my-repo.ts +403 -30
  46. package/src/market/prepare.test.ts +71 -1
  47. package/src/market/prepare.ts +3 -2
  48. package/src/market/types.ts +2 -0
  49. package/src/security/secret-scanner.ts +139 -22
  50. package/src/security/security.test.ts +111 -2
@@ -194,6 +194,8 @@ export interface MarketStatusResponse {
194
194
  /** 是否已添加过至少一个市场 */
195
195
  configured: boolean;
196
196
  markets: MarketSummary[];
197
+ /** 本次 dsh 启动后市场是否已刷新过(首次打开市场页自动更新一次的依据;进程内存,dsh 重启后重置) */
198
+ bootAutoRefreshed?: boolean;
197
199
  }
198
200
 
199
201
  /** POST /market/add | /market/remove 响应:{ ok, markets } */
@@ -7,10 +7,19 @@
7
7
  * 判定策略(两级,避免误伤/漏报):
8
8
  * - 字段名:规范化(小写、去 `_-. ` 分隔符)后做「精确命中 / 敏感后缀 / 敏感前缀」三级匹配;
9
9
  * 只剥离**字符串叶值**(对象/数组整体递归,不整块剥离——保证 `credentials: [...]` 状态数组存活)。
10
- * - 引用豁免:`apiKeyEnv` 等「只存引用名」字段、以及形如 `DEEPSEEK_API_KEY` 的全大写环境变量名
11
- * (`isEnvVarName`)一律保留——值是名字不是秘密。
10
+ * - 引用豁免:`apiKeyEnv` 等「只存引用名」字段、形如 `DEEPSEEK_API_KEY` 的全大写环境变量名
11
+ * (`isEnvVarName`)以及 `${VAR}` / `{{VAR}}` / `%VAR%` / `$VAR` 模板引用(`isTemplateReference`)
12
+ * 一律保留——值是名字不是秘密。
12
13
  * - 值形状启发式(规范 §6「不能只根据固定字段判断」):字段名未命中但值长得像 secret
13
- * (sk- / JWT / AKIA / GitHub PAT / PEM 私钥 / Bearer)也剥离;高熵 base64 默认关(误伤率高)。
14
+ * (sk- / JWT / AKIA / GitHub PAT / PEM 私钥 / Bearer)也剥离;**示例/占位形态**(含
15
+ * your/example/xxx/test/<...> 等占位词的 `sk-your-*`、`Bearer example-*`、JWT 教学串)放行。
16
+ * - 两种判定档位(2026-08-21 优化):
17
+ * * 保守档(默认,导出/同步脱敏用):字段名敏感**即**剥离(值形状优先、引用/env 名豁免);
18
+ * * 宽松档(`literalValueOnly`,市场发布扫描用):字段名敏感**且**值像真实字面量凭据
19
+ * (非占位符 / 非类型词 / 非代码表达式 / 非模板引用 / 非短标识符)才命中——消除
20
+ * `"token": "your-token"`、`"token": "${OPENAI_API_KEY}"`、`"Authorization": "Bearer <token>"`
21
+ * 等「值不是秘密」的误报。
22
+ * - 高熵 base64 默认关(误伤率高)。
14
23
  *
15
24
  * 实现不变量:返回 sanitized 为**全新对象**(不改原数据);迭代式显式栈(防爆栈)+ 深度上限;
16
25
  * 命中记录只含路径与字段名,**值永不外泄**。
@@ -65,6 +74,42 @@ export const SECRET_VALUE_PATTERNS: readonly ValuePattern[] = [
65
74
  { name: 'bearer-token', re: /Bearer [A-Za-z0-9._~+/=-]{8,}/ },
66
75
  ];
67
76
 
77
+ /**
78
+ * 值形状命中片段中的「示例/占位」词:匹配到的片段含其中任一 → 视为示例形态放行。
79
+ * 覆盖 `sk-your-*`、`sk-example-*`、`sk-test-*`、`Bearer example-*`、`AKIA...EXAMPLE`(AWS 官方示例)、
80
+ * JWT 教学串(第三段 signature)等;真实随机密钥串几乎不可能包含这些完整单词,安全不回退。
81
+ */
82
+ const SECRET_SHAPE_PLACEHOLDER_WORDS = [
83
+ 'your', 'their', 'example', 'sample', 'dummy', 'placeholder',
84
+ 'test', 'demo', 'signature', 'secret', 'value', 'xxxx',
85
+ ] as const;
86
+
87
+ /** 值形状命中是否「示例/占位」形态(含占位词或尖括号包裹)→ 放行 */
88
+ function isExampleSecretShape(matched: string): boolean {
89
+ if (/<[^>]+>/.test(matched)) return true;
90
+ const lower = matched.toLowerCase();
91
+ return SECRET_SHAPE_PLACEHOLDER_WORDS.some((w) => lower.includes(w));
92
+ }
93
+
94
+ /** 值形状命中详情(模式名 + 实际匹配片段;示例降噪用) */
95
+ interface ValuePatternHit { name: string; matched: string }
96
+
97
+ function matchSecretValuePatternHit(value: string): ValuePatternHit | null {
98
+ for (const p of SECRET_VALUE_PATTERNS) {
99
+ const m = p.re.exec(value);
100
+ if (m !== null) return { name: p.name, matched: m[0] };
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /** 值是否命中强模式 secret 形状(示例/占位形态 → null;返回命中模式名;未命中返回 null) */
106
+ export function matchSecretValuePattern(value: string): string | null {
107
+ const hit = matchSecretValuePatternHit(value);
108
+ if (hit === null) return null;
109
+ if (isExampleSecretShape(hit.matched)) return null;
110
+ return hit.name;
111
+ }
112
+
68
113
  export interface SecretScannerOptions {
69
114
  /** 附加敏感字段名(任意原始形态,内部会规范化) */
70
115
  extraFieldNames?: string[];
@@ -74,6 +119,13 @@ export interface SecretScannerOptions {
74
119
  valuePatterns?: boolean;
75
120
  /** 高熵长串启发式(默认 false:误伤风险高,需显式开启) */
76
121
  highEntropy?: boolean;
122
+ /**
123
+ * 宽松档(默认 false = 保守档)。
124
+ * 保守档(导出/同步脱敏):字段名敏感即命中,值形状优先、引用/env 名豁免;
125
+ * 宽松档(市场发布扫描):字段名敏感**且**值像真实字面量凭据才命中——占位符 /
126
+ * 模板引用 / 代码表达式 / 类型词 / 短标识符放行,消除「值不是秘密」的误报。
127
+ */
128
+ literalValueOnly?: boolean;
77
129
  /** 最大递归深度(默认 64,与 JSON 深度上限一致) */
78
130
  maxDepth?: number;
79
131
  }
@@ -88,6 +140,23 @@ export function isEnvVarName(value: string): boolean {
88
140
  return /^[A-Z][A-Z0-9_]{2,}$/.test(value);
89
141
  }
90
142
 
143
+ /** 是否为模板/环境变量引用形态(${VAR} / {{VAR}} / %VAR% / $VAR)→ 值是名字不是秘密。
144
+ * 注意:文本扫描的 `field: value` 正则用 `[^\s,;}]` 取值,`${...}` / `{{...}}` 的右花括号
145
+ * 会被截断,因此除完整闭合形态外,也接受「以 ${ / {{ / % 开头、内容全为变量名字符」的截断形态。 */
146
+ export function isTemplateReference(value: string): boolean {
147
+ const v = value.trim();
148
+ // 完整闭合形态
149
+ if (/^\$\{[A-Za-z0-9_.]+\}$/.test(v)) return true;
150
+ if (/^\{\{[A-Za-z0-9_.]+\}\}$/.test(v)) return true;
151
+ if (/^%[A-Za-z0-9_.]+%$/.test(v)) return true;
152
+ if (/^\$[A-Z][A-Z0-9_]{2,}$/.test(v)) return true;
153
+ // 截断形态(右花括号被取值正则排除):真实凭据不会以 ${ / {{ / % 开头,放行安全
154
+ if (/^\$\{[A-Za-z0-9_.]*$/.test(v)) return true;
155
+ if (/^\{\{[A-Za-z0-9_.]*$/.test(v)) return true;
156
+ if (/^%[A-Za-z0-9_.]*$/.test(v)) return true;
157
+ return false;
158
+ }
159
+
91
160
  /** 字段名是否命中敏感名单(规范化后精确 / 敏感后缀 / 敏感前缀) */
92
161
  export function isSensitiveFieldName(
93
162
  field: string,
@@ -109,14 +178,6 @@ export function isReferenceField(
109
178
  return [...DEFAULT_REFERENCE_FIELDS, ...extra].includes(norm);
110
179
  }
111
180
 
112
- /** 值是否命中强模式 secret 形状(返回命中模式名;未命中返回 null) */
113
- export function matchSecretValuePattern(value: string): string | null {
114
- for (const p of SECRET_VALUE_PATTERNS) {
115
- if (p.re.test(value)) return p.name;
116
- }
117
- return null;
118
- }
119
-
120
181
  /** 高熵启发式:长 base64/hex 串且 Shannon 熵高(默认关) */
121
182
  function isHighEntropySecret(value: string): boolean {
122
183
  if (!/^[A-Za-z0-9+/=_-]{32,}$/.test(value)) return false;
@@ -131,10 +192,65 @@ function isHighEntropySecret(value: string): boolean {
131
192
  return entropy > 4.2;
132
193
  }
133
194
 
195
+ /* ---------------- 值形态判定(结构化扫描与文本扫描共用) ---------------- */
196
+
197
+ /** 占位符 / 示例形态:xxx、your-token、<...>、example、sample、dummy、placeholder、... */
198
+ const PLACEHOLDER_RE = /^(x{3,}|your[-_ ]?[\w]+|their[-_ ]?[\w]+|<[^>]+>|example|examples?|sample|dummy|placeholder|som[e]thing|^\u2026)/i;
199
+
200
+ /** 类型/关键字形态(代码声明,如 `authToken: string`、`token: null`) */
201
+ const TYPE_KEYWORD_RE = /^(string|number|boolean|object|array|any|unknown|null|undefined|void|true|false|required|optional)$/i;
202
+
203
+ /** 代码表达式/引用形态:值里出现调用/成员访问/引号/运算符/空白等 → 是代码而非字面量密钥 */
204
+ function isCodeExpressionValue(value: string): boolean {
205
+ // 方法调用 / 成员访问:data.token、process.env.X、wx.getStorageSync(...)、a.b(...)
206
+ if (/[.([\]'"`]/.test(value)) return true;
207
+ // 赋值/比较/模板:a=b、a&&b 等
208
+ if (/[=+*/%<>!&|?]+/.test(value)) return true;
209
+ // 含空白 → 短语/语句而非单个字面量
210
+ if (/\s/.test(value)) return true;
211
+ return false;
212
+ }
213
+
214
+ /** 混合字符长串是否「疑似真实凭据」:含数字、或含字母数字连字符下划线以外的符号、或大小写混合
215
+ * (纯小写字母+连字符的示例值如 `abc-def-ghi-jkl` → 放行;`s3cretP@ssw0rd` / `abc123-def456` → 拦截) */
216
+ function looksLikeCredential(value: string): boolean {
217
+ if (value.length < 8) return false;
218
+ if (/[0-9]/.test(value)) return true;
219
+ if (/[^A-Za-z0-9_-]/.test(value)) return true;
220
+ if (/[a-z]/.test(value) && /[A-Z]/.test(value)) return true;
221
+ return false;
222
+ }
223
+
224
+ /** 值是否为「字面量密钥」:既不是代码表达式,也不是占位符/类型词/短标识符/环境引用。
225
+ * 只有字段名敏感 **且** 值像真实字面量凭据时才报告 —— 消除技能文档中
226
+ * `authToken: string`、`password: process.env.X`、`token: data.token` 等代码示例误报。 */
227
+ function isLiteralSecretValue(value: string): boolean {
228
+ if (value === '') return false;
229
+ // 值形状强信号(sk- / ghp_ / AKIA / JWT / PEM / Bearer,示例/占位形态已降噪)—— 任何字段都视为真凭据
230
+ if (matchSecretValuePattern(value) !== null) return true;
231
+ if (PLACEHOLDER_RE.test(value)) return false;
232
+ if (TYPE_KEYWORD_RE.test(value)) return false;
233
+ if (isCodeExpressionValue(value)) return false;
234
+ if (isEnvVarName(value)) return false;
235
+ if (isTemplateReference(value)) return false;
236
+ // 纯字母数字短标识符(变量名/普通词,如 authToken、data、key)→ 非密钥
237
+ if (/^[A-Za-z0-9_]+$/.test(value) && value.length < 20) return false;
238
+ // 其余:混合字符串,仅当像真实凭据(含数字/符号/大小写混合)才疑似
239
+ return looksLikeCredential(value);
240
+ }
241
+
134
242
  /** 判定单个「字段名 + 字符串值」:strip | keep(含引用豁免与值形状)
135
- * 顺序:值形状优先(AKIA 等全大写 secret 形状不能被 env 名豁免误放)→ 引用字段 → env 名 → 字段名。 */
136
- function judgeFieldValue(field: string, value: string, opts: Required<Pick<SecretScannerOptions, 'extraFieldNames' | 'extraReferenceFields' | 'valuePatterns' | 'highEntropy'>>): boolean {
243
+ * 顺序:值形状优先(AKIA 等全大写 secret 形状不能被 env 名豁免误放)→ 引用字段 → env 名 → 字段名。
244
+ * 宽松档(literalValueOnly,市场发布):字段名命中还要求值像真实字面量凭据(非占位/引用/代码)。 */
245
+ function judgeFieldValue(field: string, value: string, opts: Required<Pick<SecretScannerOptions, 'extraFieldNames' | 'extraReferenceFields' | 'valuePatterns' | 'highEntropy' | 'literalValueOnly'>>): boolean {
137
246
  if (value === '') return false;
247
+ if (opts.literalValueOnly) {
248
+ // 宽松档(市场发布扫描):值形状强信号字段无关必拦;字段名敏感 + 值像真实字面量凭据才拦
249
+ if (opts.valuePatterns && matchSecretValuePattern(value) !== null) return true;
250
+ if (isSensitiveFieldName(field, opts.extraFieldNames) && isLiteralSecretValue(value)) return true;
251
+ return false;
252
+ }
253
+ // 保守档(导出/同步脱敏):值形状优先 → 引用字段/env 名豁免 → 字段名敏感即剥
138
254
  if (opts.valuePatterns && matchSecretValuePattern(value) !== null) return true;
139
255
  if (isReferenceField(field, opts.extraReferenceFields)) return false;
140
256
  if (isEnvVarName(value)) return false; // 值是 env 变量名 → 引用
@@ -146,11 +262,12 @@ function judgeFieldValue(field: string, value: string, opts: Required<Pick<Secre
146
262
  /** 递归扫描 + 剥离(纯函数,不改原数据;深度保护;返回清洗后数据与命中清单) */
147
263
  export function scanAndRedact(data: unknown, opts: SecretScannerOptions = {}): { sanitized: unknown; hits: SensitiveHit[] } {
148
264
  const maxDepth = opts.maxDepth ?? 64;
149
- const cfg: Required<Pick<SecretScannerOptions, 'extraFieldNames' | 'extraReferenceFields' | 'valuePatterns' | 'highEntropy'>> = {
265
+ const cfg: Required<Pick<SecretScannerOptions, 'extraFieldNames' | 'extraReferenceFields' | 'valuePatterns' | 'highEntropy' | 'literalValueOnly'>> = {
150
266
  extraFieldNames: opts.extraFieldNames ?? [],
151
267
  extraReferenceFields: opts.extraReferenceFields ?? [],
152
268
  valuePatterns: opts.valuePatterns ?? true,
153
269
  highEntropy: opts.highEntropy ?? false,
270
+ literalValueOnly: opts.literalValueOnly ?? false,
154
271
  };
155
272
  const hits: SensitiveHit[] = [];
156
273
 
@@ -225,7 +342,11 @@ const FIELD_VALUE_RE = [
225
342
  { re: /([A-Za-z0-9_.\-]+)\s*:\s*([^\s,;}]+)/g, json: false },
226
343
  ] as const;
227
344
 
228
- /** 文本级扫描:逐行找「敏感字段名 = 值」与「值形状」命中(只报告,不修改文本) */
345
+ /** 文本级扫描:逐行找「敏感字段名 + 疑似真实字面量凭据」与「值形状」命中(只报告,不修改文本)。
346
+ * 2026-08-20 优化:字段名命中不再是充分条件 —— 值必须是「非代码引用 / 非占位符 / 非类型词 /
347
+ * 非短标识符」的字面量,消除技能/代码文档中 `token:`/`password:` 等示例命名的误报;
348
+ * 2026-08-21 优化:值形状(sk-/ghp_/AKIA/JWT/PEM/Bearer)对示例/占位形态(含 your/example/
349
+ * xxx/test/<...> 等占位词)放行;混合字符串需像真实凭据(含数字/符号/大小写混合)才报告。 */
229
350
  export function scanText(text: string, opts: SecretScannerOptions = {}): SensitiveHit[] {
230
351
  const hits: SensitiveHit[] = [];
231
352
  const extra = opts.extraFieldNames ?? [];
@@ -243,16 +364,12 @@ export function scanText(text: string, opts: SecretScannerOptions = {}): Sensiti
243
364
  const field = m[1]!;
244
365
  const value = m[2]!;
245
366
  if (!isSensitiveFieldName(field, extra)) continue;
246
- if (json) {
247
- // JSON 形态下引号内值才报告(引用名豁免由调用方结合场景决定,这里保守报告)
248
- hits.push({ path: linePath, field });
249
- continue;
250
- }
251
- if (isEnvVarName(value)) continue; // 引用名不报告
367
+ // 值必须是「疑似字面量凭据」才报告(消除代码示例/占位符误报)
368
+ if (!isLiteralSecretValue(value)) continue;
252
369
  hits.push({ path: linePath, field });
253
370
  }
254
371
  }
255
- // 值形状(字段名无关)
372
+ // 值形状(字段名无关;整行 trim 后匹配 sk-/ghp_/JWT/PEM/Bearer 等强信号,示例形态已降噪)
256
373
  if (valuePatterns) {
257
374
  const trimmed = line.trim();
258
375
  const name = matchSecretValuePattern(trimmed);
@@ -619,7 +619,7 @@ test('scanner: 引用豁免(apiKeyEnv 等只存名字的字段、env 名形态
619
619
  apiKeyEnv: 'DEEPSEEK_API_KEY',
620
620
  api_key_env: 'GITHUB_TOKEN',
621
621
  apiKey: 'DEEPSEEK_API_KEY', // 值是 env 变量名形态 → 视为引用
622
- realKey: 'sk-real-secret-value',
622
+ realKey: 'sk-proj-9f8e7d6c5b4a3210abcdef', // 真实形态密钥 → 仍拦截
623
623
  tokenRef: 'MY_TOKEN_REF',
624
624
  };
625
625
  const { sanitized } = scanAndRedact(input);
@@ -635,7 +635,7 @@ test('scanner: 值形状启发式(字段名无关的 secret 形状也剥离)
635
635
  const input = {
636
636
  note: 'use sk-abc1234567890abc for api',
637
637
  jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U',
638
- aws: 'AKIAIOSFODNN7EXAMPLE',
638
+ aws: 'AKIAIOSFODNN7A1B2C3D4',
639
639
  github: 'ghp_abcdefghijklmnopqrstuvwxyz',
640
640
  pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...',
641
641
  fine: 'just a note',
@@ -653,6 +653,26 @@ test('scanner: 值形状启发式(字段名无关的 secret 形状也剥离)
653
653
  assert.equal(matchSecretValuePattern('plain text'), null);
654
654
  });
655
655
 
656
+ test('scanner: 值形状示例/占位形态放行(sk-your-*、AKIA...EXAMPLE、JWT 教学串)', () => {
657
+ const input = {
658
+ yourKey: 'sk-your-api-key-here', // 占位词 your
659
+ exampleKey: 'sk-example-key-12345', // 占位词 example
660
+ testKey: 'sk-test-key-1234567890', // 占位词 test
661
+ awsSample: 'AKIAIOSFODNN7EXAMPLE', // AWS 官方文档示例 key
662
+ jwtSample: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature', // 教学串第三段 signature
663
+ note: 'use sk-your-demo-key for api', // 文本叙述中的示例
664
+ };
665
+ const { sanitized, hits } = scanAndRedact(input);
666
+ const s = sanitized as Record<string, unknown>;
667
+ assert.equal(s['yourKey'], 'sk-your-api-key-here', 'sk-your-* 示例应放行');
668
+ assert.equal(s['exampleKey'], 'sk-example-key-12345', 'sk-example-* 示例应放行');
669
+ assert.equal(s['testKey'], 'sk-test-key-1234567890', 'sk-test-* 示例应放行');
670
+ assert.equal(s['awsSample'], 'AKIAIOSFODNN7EXAMPLE', 'AWS 官方示例应放行');
671
+ assert.equal(s['jwtSample'], 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature', 'JWT 教学串应放行');
672
+ assert.equal(s['note'], 'use sk-your-demo-key for api', '叙述中的示例应放行');
673
+ assert.equal(hits.length, 0);
674
+ });
675
+
656
676
  test('scanner: 深层嵌套上限(≤64 通过,超限抛错)与循环引用防护', () => {
657
677
  const deep = (n: number): unknown => {
658
678
  let v: unknown = 'leaf';
@@ -700,6 +720,95 @@ test('scanner: isSensitiveFieldName / createSecretScanner(core 契约)', ()
700
720
  assert.ok(textHits.some((h) => h.path.startsWith('line:')));
701
721
  });
702
722
 
723
+ test('scanText: 技能/代码文档中的示例命名不再误报(2026-08-20 优化)', () => {
724
+ // 真实技能文件(api-tester.md / frontend-developer.md 等)里出现过的代码示例形态 —— 全部应放行
725
+ const skillDoc = [
726
+ 'let authToken: string;', // 类型声明(值=string 类型词)
727
+ 'password: process.env.TEST_USER_PASSWORD', // 代码引用(含 . 与 ()—— 注意 field: value 正则截取 value 为 process.env.TEST_USER_PASSWORD
728
+ 'authToken = data.token;', // 赋值 + 成员访问
729
+ 'const token = loginResponse.json(\'token\');', // 方法调用
730
+ 'key={virtualItem.key}', // React key prop(key 不入敏感名单)
731
+ 'queryKey: [\'products\'],', // 数组字面量
732
+ 'token: your-token-here', // 占位符
733
+ 'api_key: <your-key>', // 占位符
734
+ 'max_tokens: 100', // 值=数字…… 但正则只匹配 \w+;"100" 是短标识符?100 长3 <20 → 放行 ✓
735
+ ].join('\n');
736
+ const hits = scanText(skillDoc);
737
+ assert.equal(hits.length, 0, `技能文档示例不应误报,实际: ${JSON.stringify(hits)}`);
738
+ });
739
+
740
+ test('scanText: 真实字面量密钥仍然拦截(优化不回退安全)', () => {
741
+ // 字段名敏感 + 真正的字面量长串 → 报
742
+ const real = [
743
+ 'password: s3cretP@ssw0rdXyZ2024', // 字段 password + 混合字面量长串
744
+ 'client_secret: abcdef1234567890abcdef12345678', // 字段敏感 + 长混合串
745
+ '"apiKey": "sk-proj-abcdef1234567890"', // JSON 形态 + sk- 值形状(强信号)
746
+ 'token=ghp_abcdefghijklmnopqrstuvwxyzABCDEF', // GitHub PAT 值形状
747
+ ].join('\n');
748
+ const hits = scanText(real);
749
+ assert.ok(hits.length >= 4, `真实密钥应全部拦截,实际: ${JSON.stringify(hits)} (${hits.length})`);
750
+ });
751
+
752
+ test('scanText: 占位/示例/模板引用/普通示例值放行(2026-08-21 优化)', () => {
753
+ const doc = [
754
+ 'token: ${OPENAI_API_KEY}', // 模板引用
755
+ 'api_key: {{MY_KEY}}', // 模板引用(双花括号)
756
+ 'secret: $ENV_SECRET', // $VAR 引用
757
+ 'password: your-token-here', // 占位符
758
+ 'token: abc-def-ghi-jkl', // 纯小写字母+连字符示例值(无数字/符号/大小写混合)
759
+ 'Bearer example-token-here', // 值形状示例形态
760
+ 'sk-your-api-key-here', // 值形状示例形态
761
+ 'use sk-demo-key-1234567890 in this doc', // 叙述中的示例
762
+ ].join('\n');
763
+ const hits = scanText(doc);
764
+ assert.equal(hits.length, 0, `示例文档不应误报,实际: ${JSON.stringify(hits)}`);
765
+ });
766
+
767
+ test('scanText: 含数字/符号的混合串仍是真实凭据特征 → 拦截(不回退)', () => {
768
+ const real = [
769
+ 'token: abc123-def456', // 含数字的混合串
770
+ 'password: AbCdEfGh-1234', // 大小写混合 + 数字 + 连字符(非纯字母数字,不落短标识符豁免)
771
+ 'apiKey: sk-proj-9f8e7d6c5b4a3210abcdef', // 真实形态 sk-(无占位词)
772
+ ].join('\n');
773
+ const hits = scanText(real);
774
+ assert.ok(hits.length >= 3, `真实凭据特征应拦截,实际: ${JSON.stringify(hits)} (${hits.length})`);
775
+ });
776
+
777
+ test('scanAndRedact: 宽松档(literalValueOnly,市场发布)占位/模板/代码引用放行,真凭据仍拦', () => {
778
+ // 宽松档:字段名敏感但值不是秘密 → 全部放行(市场发布场景,消除误报)
779
+ const benign = {
780
+ token: 'your-token',
781
+ apiKey: '${OPENAI_API_KEY}',
782
+ secret: '{{MY_SECRET}}',
783
+ password: 'process.env.PASSWORD',
784
+ Authorization: 'Bearer <token>',
785
+ clientSecret: 'client-secret-value', // 混合字符但纯小写+连字符(无数字/符号/大小写混合)→ 非凭据
786
+ max_tokens: 100,
787
+ note: 'just a note',
788
+ };
789
+ const r = scanAndRedact(benign, { literalValueOnly: true });
790
+ assert.equal(r.hits.length, 0, `宽松档不应误报,实际: ${JSON.stringify(r.hits)}`);
791
+ assert.deepEqual(r.sanitized, benign);
792
+
793
+ // 宽松档:真实凭据仍然拦截(安全不回退)
794
+ const real = {
795
+ token: 'sk-proj-9f8e7d6c5b4a3210abcdef',
796
+ Authorization: 'Bearer xyz1234567890abcdef',
797
+ clientSecret: 's3cretP@ssw0rdXyZ2024',
798
+ };
799
+ const rr = scanAndRedact(real, { literalValueOnly: true });
800
+ assert.equal(rr.hits.length, 3, `宽松档真凭据仍应拦截,实际: ${JSON.stringify(rr.hits)}`);
801
+ });
802
+
803
+ test('scanAndRedact: 保守档(默认,导出/同步)字段名敏感即剥,不受宽松档影响', () => {
804
+ // 默认模式:字段名敏感 + 任意值 → 剥离(导出脱敏保持保守,行为不变)
805
+ const input = { token: 'your-token', apiKey: '${OPENAI_API_KEY}' };
806
+ const { sanitized } = scanAndRedact(input);
807
+ const s = sanitized as Record<string, unknown>;
808
+ assert.equal(s['token'], REDACTED_PLACEHOLDER, '保守档字段名敏感即剥(占位符值也剥)');
809
+ assert.equal(s['apiKey'], REDACTED_PLACEHOLDER, '保守档模板引用值也剥');
810
+ });
811
+
703
812
  /* ================= redaction ================= */
704
813
 
705
814
  test('redaction: 结构化字段三形态 + 值形状 + URL query', () => {