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/internal/hysteria/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/internal/hysteria/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"); }); // Паритет с Go-санитайзером админки: URL-значение определяется по самому // значению, а не по имени ключа. Обе реализации описывают один контракт и // не имеют права расходиться. test("URL под произвольным именем ключа теряет секреты, но сохраняет адрес", () => { const yaml = "future:\n" + " endpoint: https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token\n" + " mirrors:\n" + " - https://mirror-user:mirror-p4ss@mirror.example.com/pull\n"; const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record; const future = parsed.future as Record; const endpoint = String(future.endpoint); expect(endpoint).toContain("relay.example.com"); expect(endpoint).not.toContain("svc-p4ss"); expect(endpoint).not.toContain("endpoint-token"); const mirrors = future.mirrors as string[]; expect(mirrors).toHaveLength(1); expect(mirrors[0]).toContain("mirror.example.com"); expect(mirrors[0]).not.toContain("mirror-p4ss"); }); test("не-URL скаляры проходят редакцию без изменений", () => { const yaml = "bandwidth:\n up: 50 mbps\n down: 50 mbps\n" + "quic:\n maxIdleTimeout: 30s\n initStreamReceiveWindow: 8388608\n" + "outbounds:\n - addr: 10.0.0.1:1080\n"; const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record; const bandwidth = parsed.bandwidth as Record; const quic = parsed.quic as Record; const outbounds = parsed.outbounds as Record[]; expect(bandwidth.up).toBe("50 mbps"); expect(quic.maxIdleTimeout).toBe("30s"); expect(quic.initStreamReceiveWindow).toBe(8388608); expect(outbounds[0].addr).toBe("10.0.0.1:1080"); }); 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/internal/hysteria/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/internal/hysteria/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); } }); });