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 { isSensitiveKey, REDACTED, SENSITIVE_QUERY_PARAMS, type SnapshotRedactionKind } from "@umt/contracts";
import { REDACTED } from "../rss/redact.ts";
/** /**
* 快照/截图进入模型前的脱敏钩子(REQ §4.4、§4.7.3)。 * 快照/截图进入模型前的脱敏钩子(REQ §4.4、§4.7.3)。
@@ -8,9 +7,8 @@ import { REDACTED } from "../rss/redact.ts";
* 这里同时处理 a11y 文本、HTML 片段与 structure JSON,宁可多遮不可漏。 * 这里同时处理 a11y 文本、HTML 片段与 structure JSON,宁可多遮不可漏。
*/ */
/** URL 查询参数/路径段里的凭据键(PT 站 passkey、RSS key、下载签名等) */ /** URL 查询参数里的凭据键:键表取 `@umt/contracts` 的 SENSITIVE_QUERY_PARAMS(验收 07 S-4,不再本地维护) */
const CREDENTIAL_PARAM = const CREDENTIAL_PARAM = new RegExp(`([?&;](?:${SENSITIVE_QUERY_PARAMS.join("|")})=)([^&#\\s"'<>)\\]]+)`, "gi");
/([?&;](?:passkey|authkey|torrent_pass|rsskey|downhash|credential|token|access_token|api_key|apikey|secret|sign|signature|key)=)([^&#\s"'<>)\]]+)/gi;
/** 路径形式的 passkey:/passkey/<hex> 或 /rss/<32+ hex> */ /** 路径形式的 passkey:/passkey/<hex> 或 /rss/<32+ hex> */
const CREDENTIAL_PATH = /(\/(?:passkey|rsskey|torrent_pass)\/)([A-Za-z0-9]{8,})/gi; 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; /(\[[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 = 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*)("[^"\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; const YAML_SECRET_FIELD = /(-\s*textbox\s+"[^"\n]*(?:password|passwd|密码|passkey)[^"\n]*"\s*:\s*)(\S[^\n]*)/gi;
export interface RedactionReport { export interface RedactionReport {
@@ -144,7 +141,10 @@ function redactJson(v: unknown, c: Counter, extra: RegExp[], parentIsPassword =
(typeof o.inputType === "string" && o.inputType.toLowerCase() === "password"); (typeof o.inputType === "string" && o.inputType.toLowerCase() === "password");
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(o)) { 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"); c.add(k.toLowerCase().includes("cookie") ? "cookie" : "password_field");
out[k] = REDACTED; out[k] = REDACTED;
} else out[k] = redactJson(val, c, extra, false); } else out[k] = redactJson(val, c, extra, false);
@@ -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);
});
});
+1 -1
View File
@@ -16,7 +16,7 @@ export {
parseFeed, parseFeed,
RSS_EXPIRED_MESSAGES, RSS_EXPIRED_MESSAGES,
} from "./parse.ts"; } 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 { buildRssLinkRequest, extractRssLink, RSS_LINK_CONF, type RssLinkConf, rssSiteDomain } from "./rss-link.ts";
export { export {
type FeedCursor, type FeedCursor,
+6 -20
View File
@@ -1,31 +1,17 @@
import { isSensitiveQueryParam, REDACTED } from "@umt/contracts";
/** /**
* URL/文本脱敏。含 passkey 的 URL 不得进入日志、错误信息、模型上下文(REQ §4.1)。 * URL/文本脱敏。含 passkey 的 URL 不得进入日志、错误信息、模型上下文(REQ §4.1)。
* 规则:query 中凭据类参数值替换为 [REDACTED],URL userinfo 一并抹除。 * 规则:query 中凭据类参数值替换为 [REDACTED],URL userinfo 一并抹除。
* 凭据参数名的键表只有一份,在 `@umt/contracts` 的 redact.ts(SENSITIVE_QUERY_PARAMS / isSensitiveKey)。
*/ */
export const REDACTED = "[REDACTED]"; export { REDACTED };
/** 视为凭据的 query 参数名(小写比较)。 */
export const SENSITIVE_QUERY_KEYS: ReadonlySet<string> = new Set([
"passkey",
"token",
"authkey",
"auth",
"torrent_pass",
"rsskey",
"apikey",
"api_key",
"access_token",
"secret",
"sign",
"downhash",
"credential",
"key",
]);
/** 参数判定统一取 `@umt/contracts` 的共享键表(isSensitiveQueryParam);extra 为调用方追加的站点特有参数名 */
function isSensitiveKey(key: string, extra?: Iterable<string>): boolean { function isSensitiveKey(key: string, extra?: Iterable<string>): boolean {
if (isSensitiveQueryParam(key)) return true;
const k = key.toLowerCase(); 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; if (extra) for (const e of extra) if (e.toLowerCase() === k) return true;
return false; return false;
} }
+10
View File
@@ -55,6 +55,16 @@ export const SENSITIVE_QUERY_PARAMS: readonly string[] = [
"key", "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 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 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; const BEARER_RE = /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{6,}/g;