fix(adapters,security): S-4 rss/browser redaction use the shared contracts key table

- contracts: isSensitiveQueryParam (whole-name match on SENSITIVE_QUERY_PARAMS
  or isSensitiveKey), the single source for query credential names
- rss/redact: drop local SENSITIVE_QUERY_KEYS; param checks go through
  isSensitiveQueryParam (+ caller extras); REDACTED re-exported from contracts
- browser/redact: CREDENTIAL_PARAM built from SENSITIVE_QUERY_PARAMS, JSON keys
  also checked with isSensitiveKey; keeps only its own rules (path passkey,
  cookie names, a11y password fields); remove stray regex statement
- redact-consistency.test: 24 credential spellings (pass_key, c_secure_pass,
  refresh_token, password, uid_key, auth, ...) masked by contracts, rss and
  browser alike; plain params untouched; rss extract/unredact round-trip

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 00:45:56 +08:00
1 parent b48d8592d5
commit 47bd04aec0
5 files changed
+95 -28

No files matched your search

+7 -7
View File
@@ -1,5 +1,4 @@
import type { SnapshotRedactionKind } from "@umt/contracts";
import { REDACTED } from "../rss/redact.ts";
import { isSensitiveKey, REDACTED, SENSITIVE_QUERY_PARAMS, type SnapshotRedactionKind } from "@umt/contracts";
/**
* 快照/截图进入模型前的脱敏钩子(REQ §4.4、§4.7.3)。
@@ -8,9 +7,8 @@ import { REDACTED } from "../rss/redact.ts";
* 这里同时处理 a11y 文本、HTML 片段与 structure JSON,宁可多遮不可漏。
*/
/** URL 查询参数/路径段里的凭据键(PT 站 passkey、RSS key、下载签名等) */
const CREDENTIAL_PARAM =
/([?&;](?:passkey|authkey|torrent_pass|rsskey|downhash|credential|token|access_token|api_key|apikey|secret|sign|signature|key)=)([^&#\s"'<>)\]]+)/gi;
/** URL 查询参数里的凭据键:键表取 `@umt/contracts` 的 SENSITIVE_QUERY_PARAMS(验收 07 S-4,不再本地维护) */
const CREDENTIAL_PARAM = new RegExp(`([?&;](?:${SENSITIVE_QUERY_PARAMS.join("|")})=)([^&#\\s"'<>)\\]]+)`, "gi");
/** 路径形式的 passkey:/passkey/<hex> 或 /rss/<32+ hex> */
const CREDENTIAL_PATH = /(\/(?:passkey|rsskey|torrent_pass)\/)([A-Za-z0-9]{8,})/gi;
@@ -32,7 +30,6 @@ const A11Y_SECRET_FIELD =
/(\[[a-z]+ e\d+[^\]]*\][^\n[]*?(?:password|passwd|密码|passkey|2fa|totp|验证码|otp)[^\n[:"]*?(?::\s*|\s+))("[^"\n]*"|[^\s[\n][^[\n]*?)(?=\s{2,}|\s*\[|\n|$)/gi;
const A11Y_TYPED_PASSWORD =
/(\[[a-z]+ e\d+[^\]]*\bpassword\b[^\]]*\](?:[^\n[:"]*:)?\s*)("[^"\n]*"|[^\s[\n][^[\n]*?)(?=\s{2,}|\s*\[|\n|$)/gi;
/(\[[a-z]+ e\d+[^\]]*\bpassword\b[^\]]*\][^\n[]*?(?::\s*|\s+))("[^"\n]*"|[^\s[\n][^[\n]*?)(?=\s{2,}|\s*\[|\n|$)/gi;
const YAML_SECRET_FIELD = /(-\s*textbox\s+"[^"\n]*(?:password|passwd|密码|passkey)[^"\n]*"\s*:\s*)(\S[^\n]*)/gi;
export interface RedactionReport {
@@ -144,7 +141,10 @@ function redactJson(v: unknown, c: Counter, extra: RegExp[], parentIsPassword =
(typeof o.inputType === "string" && o.inputType.toLowerCase() === "password");
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(o)) {
if ((isPassword && k === "value") || (SECRET_KEY.test(k) && typeof val === "string" && val !== "")) {
if (
(isPassword && k === "value") ||
((SECRET_KEY.test(k) || isSensitiveKey(k)) && typeof val === "string" && val !== "")
) {
c.add(k.toLowerCase().includes("cookie") ? "cookie" : "password_field");
out[k] = REDACTED;
} else out[k] = redactJson(val, c, extra, false);