Files
HY2XS_flamy/orchestrator/test/redaction.test.ts
T
founder abdcb881f3 fix(security): структурная редакция секретов и строгая проверка конфига
Diagnostics-бандл уносил machine token наружу. Построчное правило
`.replace(/(auth:\s*).*/gi, ...)` подставляло маркер в заголовок
mapping'а и оставляло нетронутым вложенный

  auth.http.url: http://127.0.0.1:8080/hui/hysteria2/auth?access_token=<секрет>

Это тот же trafficStats secret, который открывает и traffic API, и
auth-endpoint. Бандл собирается автоматически при любом падении
install/reconfigure и предназначен для передачи наружу.

Редакция YAML переписана структурно: документ разбирается и обходится
как дерево. Значение секрета может лежать где угодно, поэтому обходить
нужно дерево, а не строки. Для неразбираемого документа остаётся
консервативный построчный fallback.

В env-артефактах секрет теперь вырезается и из URL-значений:
HY2_AUTH_URL в post-install.env не подходит ни под один маркер имени
ключа, но несёт access_token в значении.

Семантическая проверка сгенерированного конфига:

- добавлен quic.maxIdleTimeout - он был в production-профиле, но не
  проверялся, и конфиг с уехавшим idle timeout проходил проверку;
- auth.http.url сверяется целиком (host/port/path/token), а не по
  наличию подстроки access_token=. Это единственный канал допуска
  пиров, уехавший порт или путь остались бы незамеченными;
- сообщение об ошибке auth.http.url не печатает сам токен: текст
  уходит в логи и в diagnostics-бандл;
- добавлены auth.http.insecure, поля ACME и запрет посторонних секций
  верхнего уровня.

Маркеры секретных имён в Go-санитайзере расширены и синхронизированы с
оркестратором. Формулировка гарантии сужена до честной: известные
секреты и неизвестные поля с секретоподобным именем.
2026-08-27 12:15:07 +05:00

204 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>;
expect(parsed.listen).toBe("0.0.0.0:443");
const auth = parsed.auth as Record<string, unknown>;
const http = auth.http as Record<string, unknown>;
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<string, unknown>;
const acme = parsed.acme as Record<string, unknown>;
expect(acme.email).toBe("admin@example.com");
expect(acme.ca).toBe("letsencrypt");
const obfs = parsed.obfs as Record<string, unknown>;
const gecko = obfs.gecko as Record<string, unknown>;
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<string, unknown>;
const future = parsed.future as Record<string, unknown>;
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<string, unknown>;
const dns = (parsed.acme as Record<string, unknown>).dns as Record<string, unknown>;
const cfg = dns.config as Record<string, unknown>;
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);
}
});
});