diff --git a/packages/adapters/src/browser/redact.ts b/packages/adapters/src/browser/redact.ts index 31d0b873..d68237cd 100644 --- a/packages/adapters/src/browser/redact.ts +++ b/packages/adapters/src/browser/redact.ts @@ -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/ 或 /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 = {}; 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); diff --git a/packages/adapters/src/redact-consistency.test.ts b/packages/adapters/src/redact-consistency.test.ts new file mode 100644 index 00000000..a0b2bf19 --- /dev/null +++ b/packages/adapters/src/redact-consistency.test.ts @@ -0,0 +1,71 @@ +import { isSensitiveQueryParam, redactUrl as redactUrlShared } from "@umt/contracts"; +import { describe, expect, it } from "vitest"; +import { redactBrowserUrl, urlHasCredential } from "./browser/redact.ts"; +import { extractSecretParams, redactUrl as redactRssUrl, redactText, unredactUrl } from "./rss/redact.ts"; + +// 验收 07 S-4:脱敏键表只有一份(@umt/contracts redact.ts)。contracts(logger + 模型上下文)、rss、browser +// 三处对同一组凭证参数给出一致结论;任何一处漏遮都会让 passkey 进日志或模型。 + +const SECRET = "s3cr3tVALUE42"; +const CREDENTIAL_PARAMS = [ + "passkey", + "pass_key", + "pass-key", + "PassKey", + "authkey", + "auth_key", + "torrent_pass", + "rsskey", + "rss_key", + "c_secure_pass", + "downhash", + "apikey", + "api_key", + "access_token", + "refresh_token", + "token", + "auth", + "sign", + "signature", + "credential", + "secret", + "password", + "uid_key", + "key", +]; +const PLAIN_PARAMS = ["id", "cat", "page", "q", "tokens", "limit", "type"]; + +describe("脱敏键表三处一致", () => { + it.each(CREDENTIAL_PARAMS)("凭证参数 %s:contracts / rss / browser 都遮蔽", (p) => { + const url = `https://pt.example.org/rss.php?id=7&${p}=${SECRET}&cat=401`; + expect(isSensitiveQueryParam(p)).toBe(true); + for (const out of [ + redactUrlShared(url), + redactRssUrl(url), + redactText(`GET ${url} failed`), + redactBrowserUrl(url), + ]) { + expect(out, out).not.toContain(SECRET); + expect(out).toContain("id=7"); + } + expect(urlHasCredential(url)).toBe(true); + // rss 的去重键可还原:取出的 secret 正好填回被遮的参数 + const secrets = extractSecretParams(url); + expect(Object.values(secrets)).toContain(SECRET); + expect(unredactUrl(redactRssUrl(url), secrets)).toContain(`${p}=${SECRET}`); + }); + + it.each(PLAIN_PARAMS)("普通参数 %s:三处都不遮", (p) => { + const url = `https://pt.example.org/torrents.php?${p}=abc123`; + expect(isSensitiveQueryParam(p)).toBe(false); + expect(redactUrlShared(url)).toBe(url); + expect(redactRssUrl(url)).toBe(url); + expect(redactBrowserUrl(url)).toBe(url); + }); + + it("URL userinfo 三处都抹掉", () => { + const url = `https://user:${SECRET}@pt.example.org/x`; + for (const out of [redactUrlShared(url), redactRssUrl(url), redactBrowserUrl(url)]) + expect(out).not.toContain(SECRET); + }); +}); diff --git a/packages/adapters/src/rss/index.ts b/packages/adapters/src/rss/index.ts index 15609055..2ecc68e1 100644 --- a/packages/adapters/src/rss/index.ts +++ b/packages/adapters/src/rss/index.ts @@ -16,7 +16,7 @@ export { parseFeed, RSS_EXPIRED_MESSAGES, } from "./parse.ts"; -export { extractSecretParams, REDACTED, redactText, redactUrl, SENSITIVE_QUERY_KEYS, unredactUrl } from "./redact.ts"; +export { extractSecretParams, REDACTED, redactText, redactUrl, unredactUrl } from "./redact.ts"; export { buildRssLinkRequest, extractRssLink, RSS_LINK_CONF, type RssLinkConf, rssSiteDomain } from "./rss-link.ts"; export { type FeedCursor, diff --git a/packages/adapters/src/rss/redact.ts b/packages/adapters/src/rss/redact.ts index 1b331fb4..8e967df0 100644 --- a/packages/adapters/src/rss/redact.ts +++ b/packages/adapters/src/rss/redact.ts @@ -1,31 +1,17 @@ +import { isSensitiveQueryParam, REDACTED } from "@umt/contracts"; + /** * URL/文本脱敏。含 passkey 的 URL 不得进入日志、错误信息、模型上下文(REQ §4.1)。 * 规则:query 中凭据类参数值替换为 [REDACTED],URL userinfo 一并抹除。 + * 凭据参数名的键表只有一份,在 `@umt/contracts` 的 redact.ts(SENSITIVE_QUERY_PARAMS / isSensitiveKey)。 */ -export const REDACTED = "[REDACTED]"; - -/** 视为凭据的 query 参数名(小写比较)。 */ -export const SENSITIVE_QUERY_KEYS: ReadonlySet = new Set([ - "passkey", - "token", - "authkey", - "auth", - "torrent_pass", - "rsskey", - "apikey", - "api_key", - "access_token", - "secret", - "sign", - "downhash", - "credential", - "key", -]); +export { REDACTED }; +/** 参数判定统一取 `@umt/contracts` 的共享键表(isSensitiveQueryParam);extra 为调用方追加的站点特有参数名 */ function isSensitiveKey(key: string, extra?: Iterable): boolean { + if (isSensitiveQueryParam(key)) return true; const k = key.toLowerCase(); - if (SENSITIVE_QUERY_KEYS.has(k)) return true; if (extra) for (const e of extra) if (e.toLowerCase() === k) return true; return false; } diff --git a/packages/contracts/src/redact.ts b/packages/contracts/src/redact.ts index cb364bc1..2d2f4366 100644 --- a/packages/contracts/src/redact.ts +++ b/packages/contracts/src/redact.ts @@ -55,6 +55,16 @@ export const SENSITIVE_QUERY_PARAMS: readonly string[] = [ "key", ]; +const PARAM_NAME_RE = new RegExp(`^(?:${SENSITIVE_QUERY_PARAMS.join("|")})$`, "i"); + +/** + * 单个 URL 查询参数名是否为凭证(整名匹配 SENSITIVE_QUERY_PARAMS,或按 isSensitiveKey 的键名规则命中)。 + * rss / browser 等适配器的参数判定都走这里,不再各自维护键表(验收 07 S-4)。 + */ +export function isSensitiveQueryParam(name: string): boolean { + return PARAM_NAME_RE.test(name) || isSensitiveKey(name); +} + const PARAM_RE = new RegExp(`([?&;#]|\\b)(${SENSITIVE_QUERY_PARAMS.join("|")})=([^&;#\\s"'<>]+)`, "gi"); const URL_USERINFO_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/gi; const BEARER_RE = /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{6,}/g;