diff --git a/apps/service/hysteria2_export.go b/apps/service/hysteria2_export.go index 2999415..ad82130 100644 --- a/apps/service/hysteria2_export.go +++ b/apps/service/hysteria2_export.go @@ -42,7 +42,8 @@ func GetRawHysteria2Config() (string, error) { // Гарантии: // 1. неизвестные upstream-поля сохраняются — будущие версии Hysteria не // обрезаются только потому, что HY2XS ещё не научился их показывать; -// 2. секреты не покидают сервер в открытом виде. +// 2. известные секреты и неизвестные поля с секретоподобным именем не +// покидают сервер в открытом виде. func ExportHysteria2ConfigYaml() ([]byte, error) { raw, err := GetRawHysteria2Config() if err != nil { @@ -72,12 +73,39 @@ func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) { return out, nil } -// isSecretKey — обобщённое правило. Оно важно именно потому, что экспорт -// сохраняет неизвестные поля: новое upstream-поле с секретом будет вырезано -// ещё до того, как HY2XS про него узнает. +// secretKeyMarkers покрывает известные секреты HY2XS/Hysteria и типовые имена, +// под которыми секрет может появиться в будущем upstream-поле. +// +// Гарантия формулируется именно так: вырезаются известные секреты и +// неизвестные поля с секретоподобным именем. Обещать, что под правило попадёт +// ЛЮБОЙ будущий секрет, нельзя — обобщённый sanitizer этого не умеет. +// +// Список синхронизирован с SECRET_KEY_MARKERS в +// orchestrator/src/lib/redaction.ts. +var secretKeyMarkers = []string{ + "password", + "passwd", + "passphrase", + "secret", + "token", + "credential", + "apikey", + "api_key", + "privatekey", + "private_key", + "accesskey", + "access_key", + "secretkey", + "secret_key", + "authorization", + "cookie", + "bearer", + "signature", +} + func isSecretKey(key string) bool { lowered := strings.ToLower(key) - for _, marker := range []string{"password", "passwd", "secret", "token", "credential"} { + for _, marker := range secretKeyMarkers { if strings.Contains(lowered, marker) { return true } @@ -139,7 +167,7 @@ func redactNode(node *yaml.Node, path []string) { func looksLikeURLKey(key string) bool { lowered := strings.ToLower(key) - return lowered == "url" || lowered == "addr" + return lowered == "url" || lowered == "addr" || strings.HasSuffix(lowered, "url") } func redactSubtree(node *yaml.Node) { diff --git a/orchestrator/src/lib/redaction.ts b/orchestrator/src/lib/redaction.ts index 1dc3881..8bbd8d8 100644 --- a/orchestrator/src/lib/redaction.ts +++ b/orchestrator/src/lib/redaction.ts @@ -1,18 +1,221 @@ +/** + * Вырезание секретов из артефактов, которые покидают сервер + * (diagnostics-бандл, `redact-config`). + * + * Почему структурно, а не regex по строкам: `auth:` в серверном конфиге — это + * заголовок mapping'а. Правило вида `auth:\s*(.*)` подставляло маркер в пустое + * место и оставляло нетронутым вложенный + * `http.url: …/hui/hysteria2/auth?access_token=<секрет>`, то есть бандл уносил + * machine token наружу. Значение может быть где угодно в дереве, поэтому + * обходить нужно дерево. + */ + +export const REDACTED = ""; + +/** + * Маркеры имён полей. Список покрывает известные секреты HY2XS/Hysteria и + * типовые имена, под которыми секрет может появиться в будущем upstream-поле. + * Гарантия формулируется именно так: known secrets + secret-shaped unknown + * fields, без обещания «любой будущий секрет». + */ +export const SECRET_KEY_MARKERS: readonly string[] = [ + "password", + "passwd", + "passphrase", + "secret", + "token", + "credential", + "apikey", + "api_key", + "privatekey", + "private_key", + "accesskey", + "access_key", + "secretkey", + "secret_key", + "authorization", + "cookie", + "bearer", + "signature" +]; + +/** Узлы, где секретом являются все значения карты, а не имя ключа. */ +const SECRET_MAP_PATHS: readonly string[] = ["auth.userpass", "acme.dns.config"]; + +export function isSecretKey(key: string): boolean { + const lowered = key.toLowerCase(); + return SECRET_KEY_MARKERS.some((marker) => lowered.includes(marker)); +} + +function isSecretMapPath(path: readonly string[]): boolean { + return SECRET_MAP_PATHS.includes(path.join(".")); +} + +function looksLikeUrlKey(key: string): boolean { + const lowered = key.toLowerCase(); + return lowered === "url" || lowered === "addr" || lowered.endsWith("_url") || lowered.endsWith("url"); +} + +/** + * Убирает из URL встроенные учётные данные и секретные query-параметры, + * сохраняя остальную часть адреса читаемой: в диагностике важно видеть + * host/port/path, но не токен. + * + * Маркер внутри query приходит percent-encoded (`%3Credacted%3E`) — результат + * обязан остаться валидным URL. Go-санитайзер админки ведёт себя так же. + */ +export function sanitizeUrlValue(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed || !trimmed.includes("://")) { + return raw; + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return raw; + } + + if (parsed.username || parsed.password) { + parsed.username = REDACTED; + parsed.password = ""; + } + + for (const key of [...parsed.searchParams.keys()]) { + if (isSecretKey(key)) { + parsed.searchParams.set(key, REDACTED); + } + } + + return parsed.toString(); +} + +function redactValueDeep(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => redactValueDeep(item)); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + out[key] = redactValueDeep(child); + } + return out; + } + if (value === null || value === undefined) { + return value; + } + return REDACTED; +} + +function redactNode(value: unknown, path: readonly string[]): unknown { + if (Array.isArray(value)) { + // Индекс не попадает в путь: правила формулируются по именам полей. + return value.map((item) => redactNode(item, path)); + } + + if (value && typeof value === "object") { + const record = value as Record; + if (isSecretMapPath(path)) { + return redactValueDeep(record); + } + + const out: Record = {}; + for (const [key, child] of Object.entries(record)) { + if (isSecretKey(key)) { + out[key] = redactValueDeep(child); + continue; + } + if (typeof child === "string" && looksLikeUrlKey(key)) { + out[key] = sanitizeUrlValue(child); + continue; + } + out[key] = redactNode(child, [...path, key]); + } + return out; + } + + if (typeof value === "string") { + // Секрет может лежать в URL и без «говорящего» имени ключа. + return sanitizeUrlValue(value); + } + + return value; +} + export function redactKeyValueSecrets(content: string): string { return content.replace( - /^([A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN)[A-Z0-9_]*=).*$/gm, - "$1" + /^([A-Z0-9_]*(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)[A-Z0-9_]*=).*$/gm, + `$1${REDACTED}` ); } +/** + * Помимо ключей-секретов режет секреты внутри URL-значений: в + * post-install.env `HY2_AUTH_URL` несёт `access_token=<секрет>`, а его имя + * ни под один key-маркер не подходит. + */ export function redactEnv(content: string): string { - return redactKeyValueSecrets(content); + return redactKeyValueSecrets(content) + .split(/\r?\n/) + .map((line) => { + const separator = line.indexOf("="); + if (line.startsWith("#") || separator < 1) { + return line; + } + const key = line.slice(0, separator + 1); + const value = line.slice(separator + 1); + if (!value.includes("://")) { + return line; + } + return `${key}${sanitizeUrlValue(value)}`; + }) + .join("\n"); +} + +/** + * Резервный построчный проход на случай, если документ не разбирается как + * YAML. Диагностика не должна падать, но и утекать не должна, поэтому здесь + * правила намеренно грубее: строка с секретным ключом вырезается целиком. + */ +export function redactYamlFallback(content: string): string { + return content + .split(/\r?\n/) + .map((line) => { + const match = /^(\s*)([A-Za-z0-9_.-]+)(\s*:\s*)(.*)$/.exec(line); + if (!match) { + return line.includes("://") ? sanitizeUrlValue(line) : line; + } + const [, indent, key, separator, value] = match; + if (value.trim() === "") { + return line; + } + if (isSecretKey(key)) { + return `${indent}${key}${separator}"${REDACTED}"`; + } + if (value.includes("://")) { + return `${indent}${key}${separator}${sanitizeUrlValue(value)}`; + } + return line; + }) + .join("\n"); } export function redactYaml(content: string): string { - return content - .replace(/(password:\s*).*/gi, "$1") - .replace(/(secret:\s*).*/gi, "$1") - .replace(/(auth:\s*).*/gi, "$1"); -} + let parsed: unknown; + try { + parsed = Bun.YAML.parse(content); + } catch { + return redactYamlFallback(content); + } + if (parsed === null || parsed === undefined) { + return content; + } + + try { + return Bun.YAML.stringify(redactNode(parsed, [])); + } catch { + return redactYamlFallback(content); + } +} diff --git a/orchestrator/src/steps/configAssertions.ts b/orchestrator/src/steps/configAssertions.ts index 4ff521d..f43416d 100644 --- a/orchestrator/src/steps/configAssertions.ts +++ b/orchestrator/src/steps/configAssertions.ts @@ -86,6 +86,7 @@ export function assertHysteriaConfigMatchesProfile(raw: string, config: RuntimeC expectValue(quic, "quic", "maxStreamReceiveWindow", QUIC_BASELINE.maxStreamReceiveWindow); expectValue(quic, "quic", "initConnReceiveWindow", QUIC_BASELINE.initConnReceiveWindow); expectValue(quic, "quic", "maxConnReceiveWindow", QUIC_BASELINE.maxConnReceiveWindow); + expectValue(quic, "quic", "maxIdleTimeout", QUIC_BASELINE.maxIdleTimeout); expectValue(quic, "quic", "maxIncomingStreams", QUIC_BASELINE.maxIncomingStreams); expectValue(quic, "quic", "disablePathMTUDiscovery", QUIC_BASELINE.disablePathMTUDiscovery); @@ -98,15 +99,64 @@ export function assertHysteriaConfigMatchesProfile(raw: string, config: RuntimeC ); expectNonEmptyString(trafficStats, "trafficStats", "secret"); + assertAuthSection(root, config); + assertTlsSection(root, config); + assertNoUnexpectedSections(root, config); +} + +/** + * auth проверяется до точного URL, а не по наличию подстроки `access_token=`. + * Именно этот URL — единственный канал допуска пиров, и молча уехавший порт + * или путь означал бы, что Hysteria спрашивает разрешение не у той службы. + */ +function assertAuthSection(root: YamlRecord, config: RuntimeConfig): void { const auth = requireSection(root, "auth"); expectValue(auth, "auth", "type", "http"); + const authHttp = asRecord(auth.http, "auth.http"); expectNonEmptyString(authHttp, "auth.http", "url"); if (!String(authHttp.url).includes("access_token=")) { throw new Error("hysteria config: auth.http.url must carry the HY2XS machine access token"); } - assertTlsSection(root, config); + const expectedUrl = + `http://127.0.0.1:${config.uiPort}/hui/hysteria2/auth?access_token=${config.hysteriaTrafficStatsSecret}`; + if (authHttp.url !== expectedUrl) { + // Секрет в сообщение не попадает: сравнение уже провалилось, а текст + // ошибки уходит в логи и диагностику. + throw new Error( + `hysteria config: auth.http.url must be http://127.0.0.1:${config.uiPort}` + + "/hui/hysteria2/auth?access_token=" + ); + } + + expectValue(authHttp, "auth.http", "insecure", config.tlsMode === "self_signed_dev"); +} + +/** + * Whitelist верхнего уровня: сгенерированный конфиг обязан состоять ровно из + * секций production-профиля. Лишняя секция означает, что шаблон или рендер + * привнесли то, чего профиль не описывает. + */ +function assertNoUnexpectedSections(root: YamlRecord, config: RuntimeConfig): void { + const allowed = new Set([ + "listen", + "auth", + "obfs", + "bandwidth", + "ignoreClientBandwidth", + "congestion", + "trafficStats", + "quic", + config.tlsMode === "acme" ? "acme" : "tls" + ]); + + const unexpected = Object.keys(root).filter((key) => !allowed.has(key)); + if (unexpected.length > 0) { + throw new Error( + `hysteria config: unexpected top-level sections for the HY2XS production profile: ${unexpected.join(", ")}` + ); + } } function assertObfsSection(root: YamlRecord, config: RuntimeConfig): void { @@ -136,6 +186,10 @@ function assertTlsSection(root: YamlRecord, config: RuntimeConfig): void { } const acme = requireSection(root, "acme"); expectValue(acme, "acme", "type", config.acmeType); + expectValue(acme, "acme", "email", config.acmeEmail); + expectValue(acme, "acme", "ca", "letsencrypt"); + expectValue(acme, "acme", "dir", "/var/lib/hysteria/acme"); + expectValue(acme, "acme", "listenHost", "0.0.0.0"); if (!Array.isArray(acme.domains) || acme.domains[0] !== config.domain) { throw new Error(`hysteria config: acme.domains must start with ${config.domain}`); } diff --git a/orchestrator/test/config-assertions.test.ts b/orchestrator/test/config-assertions.test.ts index 2a6b41e..152351f 100644 --- a/orchestrator/test/config-assertions.test.ts +++ b/orchestrator/test/config-assertions.test.ts @@ -121,4 +121,80 @@ describe("подмены в конфиге обнаруживаются", () => const { config } = renderFor(); expect(() => assertHysteriaConfigMatchesProfile("just a string", config)).toThrow(); }); + + // Инвариант присутствовал в профиле, но не проверялся: конфиг с уехавшим + // idle timeout проходил семантическую проверку. + test("подменён quic.maxIdleTimeout", () => { + const { config, yaml } = renderFor(); + const tampered = yaml.replace("maxIdleTimeout: 30s", "maxIdleTimeout: 5s"); + expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/maxIdleTimeout/); + }); + + test("исчез quic.maxIdleTimeout", () => { + const { config, yaml } = renderFor(); + const tampered = yaml.replace(/ {2}maxIdleTimeout: 30s\n/, ""); + expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/maxIdleTimeout/); + }); + + test("auth url указывает на другой порт", () => { + const { config, yaml } = renderFor(); + const tampered = yaml.replace("http://127.0.0.1:8080/hui", "http://127.0.0.1:9090/hui"); + expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/auth\.http\.url must be/); + }); + + test("auth url указывает на другой путь", () => { + const { config, yaml } = renderFor(); + const tampered = yaml.replace("/hui/hysteria2/auth", "/hui/hysteria2/authorize"); + expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/auth\.http\.url must be/); + }); + + test("auth url несёт чужой токен", () => { + const { config, yaml } = renderFor(); + const tampered = yaml.replace(/access_token=[^\s]*/, "access_token=someone-elses-token"); + expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/auth\.http\.url must be/); + }); + + test("сообщение об ошибке auth url не печатает сам токен", () => { + const { config, yaml } = renderFor(); + const tampered = yaml.replace("http://127.0.0.1:8080/hui", "http://127.0.0.1:9090/hui"); + try { + assertHysteriaConfigMatchesProfile(tampered, config); + throw new Error("expected assertion to fail"); + } catch (error) { + expect(String(error)).not.toContain(config.hysteriaTrafficStatsSecret); + } + }); + + test("auth.http.insecure включён вне self_signed_dev", () => { + const { config, yaml } = renderFor(); + const tampered = yaml.replace("insecure: false", "insecure: true"); + expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/auth\.http\.insecure/); + }); + + test("подменён acme.email", () => { + const { config, yaml } = renderFor({ HY2XS_TLS_MODE: "acme" }); + const tampered = yaml.replace("email: admin@example.com", "email: attacker@example.com"); + expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/acme\.email/); + }); + + test("подменён acme.ca", () => { + const { config, yaml } = renderFor({ HY2XS_TLS_MODE: "acme" }); + const tampered = yaml.replace("ca: letsencrypt", "ca: zerossl"); + expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/acme\.ca/); + }); + + test("посторонняя секция верхнего уровня отвергается", () => { + const { config, yaml } = renderFor(); + const tampered = `${yaml}\nresolver:\n type: system\n`; + expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow( + /unexpected top-level sections/ + ); + }); + + test("сгенерированный конфиг не содержит посторонних секций", () => { + for (const tlsMode of ["acme", "file"]) { + const { config, yaml } = renderFor({ HY2XS_TLS_MODE: tlsMode }); + expect(() => assertHysteriaConfigMatchesProfile(yaml, config)).not.toThrow(); + } + }); }); diff --git a/orchestrator/test/redaction.test.ts b/orchestrator/test/redaction.test.ts new file mode 100644 index 0000000..020c65e --- /dev/null +++ b/orchestrator/test/redaction.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + REDACTED, + isSecretKey, + redactEnv, + redactYaml, + redactYamlFallback, + sanitizeUrlValue +} from "../src/lib/redaction"; + +const MACHINE_TOKEN = "hy2xs-machine-token-must-not-leak"; +const OBFS_PASSWORD = "hy2xs-obfs-password-must-not-leak"; + +const SERVER_CONFIG = `listen: 0.0.0.0:443 + +acme: + domains: + - vpn.example.com + email: admin@example.com + ca: letsencrypt + dir: /var/lib/hysteria/acme + listenHost: 0.0.0.0 + type: http + +auth: + type: http + http: + url: http://127.0.0.1:8080/hui/hysteria2/auth?access_token=${MACHINE_TOKEN} + insecure: false + +obfs: + type: gecko + gecko: + password: "${OBFS_PASSWORD}" + minPacketSize: 512 + maxPacketSize: 1200 + +trafficStats: + listen: 127.0.0.1:36712 + secret: "${MACHINE_TOKEN}" +`; + +afterEach(() => { + // Тесты не должны зависеть друг от друга через глобальное состояние. +}); + +describe("редакция серверного конфига", () => { + // Регрессия: правило `auth:\\s*(.*)` подставляло маркер в заголовок mapping'а + // и оставляло вложенный auth.http.url с machine token нетронутым. + test("machine token не переживает редакцию", () => { + const redacted = redactYaml(SERVER_CONFIG); + expect(redacted).not.toContain(MACHINE_TOKEN); + }); + + test("obfs-пароль не переживает редакцию", () => { + expect(redactYaml(SERVER_CONFIG)).not.toContain(OBFS_PASSWORD); + }); + + test("результат остаётся валидным YAML", () => { + const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record; + expect(parsed.listen).toBe("0.0.0.0:443"); + const auth = parsed.auth as Record; + const http = auth.http as Record; + expect(String(http.url)).toContain("127.0.0.1:8080/hui/hysteria2/auth"); + // В query-параметре маркер приходит percent-encoded — так же, как это + // делает Go-санитайзер админки: результат обязан остаться валидным URL. + expect(String(http.url)).toContain(encodeURIComponent(REDACTED)); + }); + + test("несекретные поля сохраняются: диагностика должна оставаться полезной", () => { + const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record; + const acme = parsed.acme as Record; + expect(acme.email).toBe("admin@example.com"); + expect(acme.ca).toBe("letsencrypt"); + const obfs = parsed.obfs as Record; + const gecko = obfs.gecko as Record; + expect(gecko.minPacketSize).toBe(512); + expect(gecko.maxPacketSize).toBe(1200); + expect(gecko.password).toBe(REDACTED); + }); + + test("неизвестное upstream-поле с секретоподобным именем вырезается", () => { + const yaml = `future:\n apiKey: super-secret\n privateKey: also-secret\n harmless: keep-me\n`; + const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record; + const future = parsed.future as Record; + expect(future.apiKey).toBe(REDACTED); + expect(future.privateKey).toBe(REDACTED); + expect(future.harmless).toBe("keep-me"); + }); + + test("вложенное дерево под секретным ключом вырезается целиком", () => { + const yaml = `acme:\n dns:\n name: cloudflare\n config:\n api_token: leak-me\n zone: example.com\n`; + const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record; + const dns = (parsed.acme as Record).dns as Record; + const cfg = dns.config as Record; + expect(cfg.api_token).toBe(REDACTED); + // acme.dns.config — карта, где секретны все значения, а не только ключ. + expect(cfg.zone).toBe(REDACTED); + expect(dns.name).toBe("cloudflare"); + }); + + test("невалидный YAML не роняет редакцию и всё равно чистится", () => { + const broken = `auth:\n http:\n url: http://127.0.0.1:8080/x?access_token=${MACHINE_TOKEN}\n\t bad-tab: [`; + const redacted = redactYaml(broken); + expect(redacted).not.toContain(MACHINE_TOKEN); + }); + + test("построчный резервный проход тоже вырезает секреты", () => { + const redacted = redactYamlFallback(SERVER_CONFIG); + expect(redacted).not.toContain(MACHINE_TOKEN); + expect(redacted).not.toContain(OBFS_PASSWORD); + }); +}); + +describe("редакция env-артефактов", () => { + const POST_INSTALL = [ + "PACKAGE_VERSION=1.0.0", + "HY2_AUTH_MODE=http", + `HY2_AUTH_URL=http://127.0.0.1:8080/hui/hysteria2/auth?access_token=${MACHINE_TOKEN}`, + `HY2_OBFS_PASSWORD=${OBFS_PASSWORD}`, + "HY2_PORT=443" + ].join("\n"); + + // Регрессия: HY2_AUTH_URL не подходит ни под один key-маркер, но несёт + // machine token в значении. + test("секрет внутри URL-значения вырезается, даже если имя ключа несекретное", () => { + const redacted = redactEnv(POST_INSTALL); + expect(redacted).not.toContain(MACHINE_TOKEN); + expect(redacted).toContain("HY2_AUTH_URL=http://127.0.0.1:8080/hui/hysteria2/auth"); + }); + + test("ключи-секреты вырезаются по имени", () => { + expect(redactEnv(POST_INSTALL)).toContain(`HY2_OBFS_PASSWORD=${REDACTED}`); + expect(redactEnv(POST_INSTALL)).not.toContain(OBFS_PASSWORD); + }); + + test("несекретные строки не трогаются", () => { + const redacted = redactEnv(POST_INSTALL); + expect(redacted).toContain("PACKAGE_VERSION=1.0.0"); + expect(redacted).toContain("HY2_PORT=443"); + }); + + test("runtime-конфиг теряет все секреты", () => { + const runtime = [ + "HY2XS_CONFIG_SCHEMA_VERSION=2", + `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${MACHINE_TOKEN}`, + `HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`, + "HY2XS_ADMIN_CON_PASS=connection-password", + "HY2XS_ADMIN_INITIAL_PASSWORD=initial-password" + ].join("\n"); + + const redacted = redactEnv(runtime); + expect(redacted).not.toContain(MACHINE_TOKEN); + expect(redacted).not.toContain(OBFS_PASSWORD); + expect(redacted).not.toContain("connection-password"); + expect(redacted).not.toContain("initial-password"); + expect(redacted).toContain("HY2XS_CONFIG_SCHEMA_VERSION=2"); + }); +}); + +describe("санитизация URL", () => { + test("креденшелы в URL вырезаются", () => { + expect(sanitizeUrlValue("https://user:hunter2@example.com/x")).not.toContain("hunter2"); + }); + + test("секретные query-параметры вырезаются, остальные сохраняются", () => { + const out = sanitizeUrlValue("https://example.com/x?access_token=abc&page=2"); + expect(out).not.toContain("abc"); + expect(out).toContain("page=2"); + }); + + test("не-URL строка возвращается как есть", () => { + expect(sanitizeUrlValue("просто строка")).toBe("просто строка"); + expect(sanitizeUrlValue("")).toBe(""); + }); +}); + +describe("маркеры секретных имён", () => { + test("покрывают типовые секретоподобные имена", () => { + for (const key of [ + "password", + "obfsPassword", + "secret", + "access_token", + "apiKey", + "api_key", + "privateKey", + "authorization", + "cookie", + "bearer", + "signature", + "passphrase" + ]) { + expect(isSecretKey(key)).toBe(true); + } + }); + + test("не задевают обычные поля", () => { + for (const key of ["listen", "type", "domains", "minPacketSize", "email", "ca", "dir"]) { + expect(isSecretKey(key)).toBe(false); + } + }); +});