From a3445f79c54ab00cc3f0991cbef42d2d62be60ef Mon Sep 17 00:00:00 2001 From: Crimson Date: Mon, 7 Sep 2026 19:52:11 +0500 Subject: [PATCH] fix(security): harden diagnostics storage boundary --- CHANGELOG.md | 7 + README.md | 8 +- docs/operations/13-production-runbook.md | 7 +- docs/runtime/08-orchestrator-spec.md | 12 +- docs/testing/11-2-builder-layer.md | 9 + orchestrator/src/commands/diagnostics.ts | 86 ++++-- orchestrator/src/lib/diagnosticsStorage.ts | 253 ++++++++++++++++++ orchestrator/src/lib/packageMeta.ts | 21 +- orchestrator/src/steps/filesystem.ts | 2 + orchestrator/test/diagnostics-storage.test.ts | 145 ++++++++++ orchestrator/test/package-meta-utf8.test.ts | 16 +- tools/build/lib/acceptance.sh | 33 +++ 12 files changed, 562 insertions(+), 37 deletions(-) create mode 100644 orchestrator/src/lib/diagnosticsStorage.ts create mode 100644 orchestrator/test/diagnostics-storage.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34de030..16e928a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ ## [Unreleased] +Diagnostics больше не пишет root-артефакты в `/var/log/hy2xs`, которым владеет +`hy2xs-admin`. Staging и архив перенесены в проверяемый через `lstat` каталог +`/var/lib/hy2xs/diagnostics` (`root:root 0700`), имена создаются эксклюзивно, +symlink и ослабленные права дают fail-closed отказ, а временный staging после +упаковки удаляется. Чтение metadata теперь использует fallback только при +`ENOENT`; ошибки прав, ввода-вывода и неверного типа больше не маскируются. + Очередной hardening-проход закрывает границу кодировок. Оркестратор теперь читает конфигурацию и секреты как байты с fatal UTF-8 decoding, сохраняет BOM как U+FEFF до явного транспортного отказа и проверяет исходный EnvironmentFile diff --git a/README.md b/README.md index a029b6a..c8901e4 100644 --- a/README.md +++ b/README.md @@ -749,7 +749,7 @@ hy2xs-orchestrator reconfigure \ | `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения | | `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию | | `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** | -| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/log/hy2xs/diagnostics` | +| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/lib/hy2xs/diagnostics` | | `hy2xs-orchestrator redact-config` | Отредактировать секреты в env/yaml перед публикацией логов | `repair` без `--allow-partial-state` работает только поверх полностью успешной @@ -771,6 +771,12 @@ hy2xs-orchestrator diagnostics collect \ декодируется с заменой и не попадает в архив; вместо него записывается безопасная причина пропуска. +Архив создаётся в `/var/lib/hy2xs/diagnostics` с режимом `0600`. Этот каталог +принадлежит `root:root`, имеет режим `0700` и отделён от +`HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`. +Оркестратор отказывает, если каталог подменён symlink, имеет другого владельца +или ослабленные права. Незавершённый staging-каталог после упаковки удаляется. + ## Проверка безопасности после установки Минимальный набор проверок: diff --git a/docs/operations/13-production-runbook.md b/docs/operations/13-production-runbook.md index 2fc0508..2314b25 100644 --- a/docs/operations/13-production-runbook.md +++ b/docs/operations/13-production-runbook.md @@ -349,9 +349,12 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/ (`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при неудачной установке или реконфигурации. Бандл предназначен для передачи наружу, поэтому попадающие в него `hy2xs.env`, `post-install.env` и `config.yaml` -редактируются перед упаковкой. +редактируются перед упаковкой. Готовый архив находится в +`/var/lib/hy2xs/diagnostics`, принадлежит `root:root` и имеет режим `0600`. +Сам каталог имеет режим `0700`; не ослабляйте его права и не переносите bundle +в `HY2XS_LOG_DIR`, доступный сервисному пользователю. При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не -собирается: его сбор сам создал бы каталоги в `/var/log/hy2xs` на сервере, +собирается: его сбор сам создал бы каталог в `/var/lib/hy2xs/diagnostics` на сервере, который мы обещали не трогать. diff --git a/docs/runtime/08-orchestrator-spec.md b/docs/runtime/08-orchestrator-spec.md index 395bc45..bbe27df 100644 --- a/docs/runtime/08-orchestrator-spec.md +++ b/docs/runtime/08-orchestrator-spec.md @@ -254,7 +254,7 @@ preflight-ошибка со словом `nftables` приводила к отк него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом случае system rollback не выполняется, `install-state.json` не пишется, diagnostics-бандл не собирается (его сбор сам создал бы каталоги в - `/var/log/hy2xs`). + `/var/lib/hy2xs/diagnostics`). - `systemctl stop/disable` выполняется **только если текущая операция сама развернула эти unit-файлы**. @@ -695,7 +695,15 @@ Diagnostics не копирует env/YAML и не перенаправляет редакцию и лишь затем записываются с режимом `0600`. Некорректный UTF-8 в конфигурационном файле даёт безопасный маркер пропуска без исходных байтов. Вывод каждой внешней команды ограничен 8 МиБ на поток и при усечении явно -помечается; архив создаётся сразу под `umask 077`. +помечается. + +Staging и архив лежат только в `/var/lib/hy2xs/diagnostics`, а не в +service-writable `HY2XS_LOG_DIR`. Родитель проверяется через `lstat`: symlink, +не-root владелец, доступ на запись для группы/остальных или режим дочернего +каталога не `0700` останавливают сбор fail closed. Рабочий каталог получает +непредсказуемое имя через `mkdtemp`, archive path заранее резервируется через +эксклюзивный `open("wx")`, итоговый файл проверяется как обычный +`root:root 0600`. После успешной упаковки staging удаляется. Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в заголовок mapping'а и оставляло нетронутым вложенный diff --git a/docs/testing/11-2-builder-layer.md b/docs/testing/11-2-builder-layer.md index 56b733c..9fdfa0b 100644 --- a/docs/testing/11-2-builder-layer.md +++ b/docs/testing/11-2-builder-layer.md @@ -406,6 +406,15 @@ хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где редактировались env и YAML, а `journal-admin.log` копировался как есть. +`orchestrator/test/diagnostics-storage.test.ts` отдельно фиксирует границу +привилегий: production path не входит в `HY2XS_LOG_DIR`, symlink и чужой +владелец отвергаются, режим root-каталога равен `0700`, рабочие каталоги +уникальны, а archive path резервируется эксклюзивно до запуска `tar`. + +`orchestrator/test/package-meta-utf8.test.ts` проверяет соседнюю fail-closed +границу metadata: fallback разрешён только для отсутствующего файла; каталог +вместо файла и повреждённый UTF-8 пробрасываются как ошибка пакета. + ## A7. Machine token в журналах (unit) `apps/middleware/log_test.go` — запрос diff --git a/orchestrator/src/commands/diagnostics.ts b/orchestrator/src/commands/diagnostics.ts index f1dcec7..71aaa8c 100644 --- a/orchestrator/src/commands/diagnostics.ts +++ b/orchestrator/src/commands/diagnostics.ts @@ -2,12 +2,14 @@ import { join } from "node:path"; import type { CommonOptions } from "../types/context"; import { InvalidUtf8Error, readText, writeTextAtomic } from "../lib/fs"; import { info, setOperationContext } from "../lib/log"; -import { runMutating, runReadOnlyArgvStatus } from "../lib/process"; +import { runMutatingArgv, runReadOnlyArgvStatus } from "../lib/process"; import { redactEnv, redactLogText, redactYaml } from "../lib/redaction"; - -function shellEscapeSingleQuotes(value: string): string { - return value.replaceAll("'", "'\\''"); -} +import { + cleanupDiagnosticsWorkspace, + createDiagnosticsWorkspace, + discardDiagnosticsArchive, + finalizeDiagnosticsArchive +} from "../lib/diagnosticsStorage"; const unavailable = (reason: string): string => `[HY2XS: источник не включён в диагностику: ${reason}]\n`; @@ -68,29 +70,61 @@ export async function prepareDiagnosticFile( } export async function diagnosticsCollect(_options: CommonOptions): Promise { - const opId = `diag-${Date.now().toString(36)}`; - setOperationContext(opId); + const workspace = await createDiagnosticsWorkspace(); + setOperationContext(workspace.operationId); + let archiveComplete = false; + let collectionFailed = false; - const outDir = `/var/log/hy2xs/diagnostics/${opId}`; - const archive = `/var/log/hy2xs/diagnostics/${opId}.tar.gz`; - await runMutating`install -d -m 0700 ${outDir}`; + try { + await collectCommand(workspace.outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]); + await collectCommand(workspace.outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]); + await collectCommand(workspace.outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]); + await collectCommand(workspace.outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]); + await collectCommand(workspace.outDir, "nftables.ruleset", ["nft", "list", "ruleset"]); + await collectCommand(workspace.outDir, "uname.txt", ["uname", "-a"]); + await collectCommand(workspace.outDir, "ss-ltnup.txt", ["ss", "-ltnup"]); - await collectCommand(outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]); - await collectCommand(outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]); - await collectCommand(outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]); - await collectCommand(outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]); - await collectCommand(outDir, "nftables.ruleset", ["nft", "list", "ruleset"]); - await collectCommand(outDir, "uname.txt", ["uname", "-a"]); - await collectCommand(outDir, "ss-ltnup.txt", ["ss", "-ltnup"]); + await collectFile(workspace.outDir, "os-release.txt", "/etc/os-release", redactLogText); + await collectFile(workspace.outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv); + await collectFile(workspace.outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv); + await collectFile(workspace.outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml); + await collectFile(workspace.outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText); - await collectFile(outDir, "os-release.txt", "/etc/os-release", redactLogText); - await collectFile(outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv); - await collectFile(outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv); - await collectFile(outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml); - await collectFile(outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText); + // Archive path уже создан через open("wx") внутри доверенного root-only + // namespace. argv-форма не запускает shell и сохраняет границы аргументов. + await runMutatingArgv(["tar", "-czf", workspace.archive, "-C", workspace.outDir, "."]); + await finalizeDiagnosticsArchive(workspace.archive); + archiveComplete = true; + } catch (error) { + collectionFailed = true; + throw error; + } finally { + const cleanupErrors: unknown[] = []; + try { + await cleanupDiagnosticsWorkspace(workspace); + } catch (error) { + cleanupErrors.push(error); + } + if (!archiveComplete) { + try { + await discardDiagnosticsArchive(workspace); + } catch (error) { + cleanupErrors.push(error); + } + } + if (cleanupErrors.length > 0) { + if (!collectionFailed) { + throw new AggregateError(cleanupErrors, "не удалось полностью очистить временные diagnostics-артефакты"); + } + for (const cleanupError of cleanupErrors) { + info( + `diagnostics cleanup failed after the primary error: ${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }` + ); + } + } + } - await runMutating`sh -c ${`umask 077; tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`; - await runMutating`chmod 0600 ${archive}`; - - info(`diagnostics bundle collected: ${archive}`); + info(`diagnostics bundle collected: ${workspace.archive}`); } diff --git a/orchestrator/src/lib/diagnosticsStorage.ts b/orchestrator/src/lib/diagnosticsStorage.ts new file mode 100644 index 0000000..0588afb --- /dev/null +++ b/orchestrator/src/lib/diagnosticsStorage.ts @@ -0,0 +1,253 @@ +import { chmod, chown, lstat, mkdir, mkdtemp, open, rm, unlink } from "node:fs/promises"; +import type { Stats } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { assertMutationAllowed } from "./guard"; +import { INSTALL_STATE_DIR } from "./installState"; + +export const DIAGNOSTICS_ROOT = `${INSTALL_STATE_DIR}/diagnostics`; + +const ROOT_UID = 0; +const ROOT_GID = 0; +const DIAGNOSTICS_MODE = 0o700; +const ARCHIVE_MODE = 0o600; +const HAS_POSIX_MODE_SEMANTICS = process.platform !== "win32"; + +type DirectoryPolicy = { + uid: number; + gid: number; + exactMode?: number; + rejectGroupOrOtherWrite?: boolean; +}; + +export type DiagnosticsStorageOptions = { + stateRoot?: string; + diagnosticsRoot?: string; + uid?: number; + gid?: number; +}; + +export type DiagnosticsWorkspace = { + operationId: string; + outDir: string; + archive: string; +}; + +function errnoCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; +} + +async function lstatOptional(path: string): Promise { + try { + return await lstat(path); + } catch (error) { + if (errnoCode(error) === "ENOENT") { + return null; + } + throw error; + } +} + +function octal(mode: number): string { + return (mode & 0o777).toString(8).padStart(4, "0"); +} + +/** + * Проверяет каталог именно по lstat-снимку: stat здесь запрещён, поскольку он + * скрыл бы symlink и тем самым снова перенёс root-запись в чужой namespace. + */ +export function assertTrustedDirectory(path: string, stats: Stats, policy: DirectoryPolicy): void { + if (stats.isSymbolicLink()) { + throw new Error(`небезопасный каталог ${path}: symbolic link запрещён`); + } + if (!stats.isDirectory()) { + throw new Error(`небезопасный каталог ${path}: ожидается обычный каталог`); + } + if (stats.uid !== policy.uid || stats.gid !== policy.gid) { + throw new Error( + `небезопасный каталог ${path}: ожидается владелец ${policy.uid}:${policy.gid}, ` + + `получен ${stats.uid}:${stats.gid}` + ); + } + + const mode = stats.mode & 0o777; + if (policy.exactMode !== undefined && mode !== policy.exactMode) { + throw new Error( + `небезопасный каталог ${path}: ожидается режим ${octal(policy.exactMode)}, получен ${octal(mode)}` + ); + } + if (policy.rejectGroupOrOtherWrite && (mode & 0o022) !== 0) { + throw new Error( + `небезопасный каталог ${path}: группа или остальные могут изменять root namespace (режим ${octal(mode)})` + ); + } +} + +function resolveOptions(options: DiagnosticsStorageOptions = {}): Required { + const stateRoot = options.stateRoot ?? INSTALL_STATE_DIR; + const diagnosticsRoot = options.diagnosticsRoot ?? join(stateRoot, "diagnostics"); + const uid = options.uid ?? ROOT_UID; + const gid = options.gid ?? ROOT_GID; + + if (dirname(diagnosticsRoot) !== stateRoot) { + throw new Error(`diagnostics root обязан быть непосредственным дочерним каталогом ${stateRoot}`); + } + return { stateRoot, diagnosticsRoot, uid, gid }; +} + +function assertWorkspacePaths( + workspace: DiagnosticsWorkspace, + options: DiagnosticsStorageOptions = {} +): void { + const resolved = resolveOptions(options); + const directoryName = basename(workspace.outDir); + if ( + dirname(workspace.outDir) !== resolved.diagnosticsRoot || + !/^diag-[A-Za-z0-9]{6}$/.test(directoryName) || + workspace.operationId !== directoryName || + workspace.archive !== `${workspace.outDir}.tar.gz` + ) { + throw new Error(`небезопасные пути diagnostics workspace: ${workspace.outDir}`); + } +} + +/** + * Создаёт и затем повторно проверяет root-only namespace диагностики. + * + * Родитель уже принадлежит install-state и недоступен сервисному пользователю. + * Неверные существующие права не «исправляются» автоматически: это может быть + * следом подмены, поэтому граница привилегий обязана отказать fail closed. + */ +export async function ensureDiagnosticsStorageRoot( + options: DiagnosticsStorageOptions = {} +): Promise { + assertMutationAllowed("ensureDiagnosticsStorageRoot"); + const resolved = resolveOptions(options); + const stateStats = await lstatOptional(resolved.stateRoot); + if (!stateStats) { + throw new Error(`доверенный каталог состояния отсутствует: ${resolved.stateRoot}`); + } + assertTrustedDirectory(resolved.stateRoot, stateStats, { + uid: resolved.uid, + gid: resolved.gid, + rejectGroupOrOtherWrite: HAS_POSIX_MODE_SEMANTICS + }); + + let created = false; + try { + await mkdir(resolved.diagnosticsRoot, { mode: DIAGNOSTICS_MODE }); + created = true; + } catch (error) { + // Параллельные read-only diagnostics не берут operation lock и законно + // могут одновременно увидеть отсутствующий каталог. Победитель создаёт + // его, проигравший всё равно обязан проверить итоговый inode через lstat. + if (errnoCode(error) !== "EEXIST") { + throw error; + } + } + + if (created) { + // mkdir применяет umask. Под доверенным root-only родителем pathname нельзя + // подменить непривилегированным процессом, поэтому режим можно довести до + // точного публичного контракта и затем проверить ещё раз. + if (HAS_POSIX_MODE_SEMANTICS) { + await chown(resolved.diagnosticsRoot, resolved.uid, resolved.gid); + } + await chmod(resolved.diagnosticsRoot, DIAGNOSTICS_MODE); + } + + const diagnosticsStats = await lstat(resolved.diagnosticsRoot); + assertTrustedDirectory(resolved.diagnosticsRoot, diagnosticsStats, { + uid: resolved.uid, + gid: resolved.gid, + exactMode: HAS_POSIX_MODE_SEMANTICS ? DIAGNOSTICS_MODE : undefined + }); + return resolved.diagnosticsRoot; +} + +/** Резервирует непредсказуемые рабочий каталог и archive path эксклюзивно. */ +export async function createDiagnosticsWorkspace( + options: DiagnosticsStorageOptions = {} +): Promise { + assertMutationAllowed("createDiagnosticsWorkspace"); + const root = await ensureDiagnosticsStorageRoot(options); + const outDir = await mkdtemp(join(root, "diag-")); + try { + const resolved = resolveOptions(options); + if (HAS_POSIX_MODE_SEMANTICS) { + await chown(outDir, resolved.uid, resolved.gid); + } + await chmod(outDir, DIAGNOSTICS_MODE); + + assertTrustedDirectory(outDir, await lstat(outDir), { + uid: resolved.uid, + gid: resolved.gid, + exactMode: HAS_POSIX_MODE_SEMANTICS ? DIAGNOSTICS_MODE : undefined + }); + + const archive = `${outDir}.tar.gz`; + const archiveHandle = await open(archive, "wx", ARCHIVE_MODE); + try { + if (HAS_POSIX_MODE_SEMANTICS) { + await archiveHandle.chown(resolved.uid, resolved.gid); + } + await archiveHandle.chmod(ARCHIVE_MODE); + } finally { + await archiveHandle.close(); + } + + return { operationId: basename(outDir), outDir, archive }; + } catch (error) { + await rm(outDir, { recursive: true, force: true }).catch(() => undefined); + throw error; + } +} + +export async function assertDiagnosticsArchive(path: string, options: DiagnosticsStorageOptions = {}): Promise { + const resolved = resolveOptions(options); + if (dirname(path) !== resolved.diagnosticsRoot || !/^diag-[A-Za-z0-9]{6}\.tar\.gz$/.test(basename(path))) { + throw new Error(`небезопасный путь diagnostics archive: ${path}`); + } + const stats = await lstat(path); + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error(`небезопасный diagnostics archive ${path}: ожидается обычный файл`); + } + if (stats.uid !== resolved.uid || stats.gid !== resolved.gid) { + throw new Error(`небезопасный diagnostics archive ${path}: неверный владелец`); + } + if (HAS_POSIX_MODE_SEMANTICS && (stats.mode & 0o777) !== ARCHIVE_MODE) { + throw new Error(`небезопасный diagnostics archive ${path}: ожидается режим 0600`); + } +} + +export async function finalizeDiagnosticsArchive( + path: string, + options: DiagnosticsStorageOptions = {} +): Promise { + assertMutationAllowed("finalizeDiagnosticsArchive"); + await chmod(path, ARCHIVE_MODE); + await assertDiagnosticsArchive(path, options); +} + +export async function cleanupDiagnosticsWorkspace( + workspace: DiagnosticsWorkspace, + options: DiagnosticsStorageOptions = {} +): Promise { + assertMutationAllowed("cleanupDiagnosticsWorkspace"); + assertWorkspacePaths(workspace, options); + await rm(workspace.outDir, { recursive: true, force: true }); +} + +export async function discardDiagnosticsArchive( + workspace: DiagnosticsWorkspace, + options: DiagnosticsStorageOptions = {} +): Promise { + assertMutationAllowed("discardDiagnosticsArchive"); + assertWorkspacePaths(workspace, options); + await unlink(workspace.archive).catch((error) => { + if (errnoCode(error) !== "ENOENT") { + throw error; + } + }); +} diff --git a/orchestrator/src/lib/packageMeta.ts b/orchestrator/src/lib/packageMeta.ts index ac6d1bc..4fee64f 100644 --- a/orchestrator/src/lib/packageMeta.ts +++ b/orchestrator/src/lib/packageMeta.ts @@ -1,16 +1,27 @@ import { InvalidUtf8Error, readText } from "./fs"; import { runReadOnly } from "./process"; +export function isMissingPackageMetadataError(error: unknown): boolean { + return ( + !(error instanceof InvalidUtf8Error) && + error !== null && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} + export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise { try { return (await readText(`${packageDir}/metadata/${file}`)).trim(); } catch (error) { - // Отсутствующая metadata допускает fallback, повреждённая — нет: иначе - // битый пакет выдаёт себя за пакет с корректным запасным значением. - if (error instanceof InvalidUtf8Error) { - throw error; + // Только отсутствие необязательного файла означает fallback. Ошибки прав, + // ввода-вывода, неверного типа и повреждённый UTF-8 описывают битый либо + // недоступный пакет и не имеют права маскироваться запасным значением. + if (isMissingPackageMetadataError(error)) { + return fallback; } - return fallback; + throw error; } } diff --git a/orchestrator/src/steps/filesystem.ts b/orchestrator/src/steps/filesystem.ts index 67e2bc6..2087470 100644 --- a/orchestrator/src/steps/filesystem.ts +++ b/orchestrator/src/steps/filesystem.ts @@ -1,5 +1,6 @@ import type { InstallContext } from "../types/context"; import { runMutating } from "../lib/process"; +import { ensureDiagnosticsStorageRoot } from "../lib/diagnosticsStorage"; async function userExists(user: string): Promise { try { @@ -58,5 +59,6 @@ export async function prepareFilesystem(context: InstallContext): Promise await ensureDir(context.config.installDir, "0755", "root:root"); await ensureDir("/usr/local/lib/hy2xs", "0755", "root:root"); await ensureDir("/etc/nftables.d", "0755", "root:root"); + await ensureDiagnosticsStorageRoot(); await runMutating`chmod -R go-w ${context.config.installDir}`; } diff --git a/orchestrator/test/diagnostics-storage.test.ts b/orchestrator/test/diagnostics-storage.test.ts new file mode 100644 index 0000000..7195ab1 --- /dev/null +++ b/orchestrator/test/diagnostics-storage.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, lstat, mkdir, mkdtemp, open, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DIAGNOSTICS_ROOT, + assertTrustedDirectory, + cleanupDiagnosticsWorkspace, + createDiagnosticsWorkspace, + discardDiagnosticsArchive, + ensureDiagnosticsStorageRoot +} from "../src/lib/diagnosticsStorage"; +import { disableReadOnlyGuard, enableReadOnlyGuard } from "../src/lib/guard"; + +const directories: string[] = []; + +afterEach(async () => { + disableReadOnlyGuard(); + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +function fakeStats(options: { + directory?: boolean; + symlink?: boolean; + uid?: number; + gid?: number; + mode?: number; +}): import("node:fs").Stats { + return { + isDirectory: () => options.directory ?? true, + isSymbolicLink: () => options.symlink ?? false, + uid: options.uid ?? 0, + gid: options.gid ?? 0, + mode: options.mode ?? 0o40700 + } as import("node:fs").Stats; +} + +async function temporaryPolicy(): Promise<{ + base: string; + stateRoot: string; + diagnosticsRoot: string; + uid: number; + gid: number; +}> { + const base = await mkdtemp(join(tmpdir(), "hy2xs-diagnostics-storage-")); + directories.push(base); + const stateRoot = join(base, "hy2xs"); + const diagnosticsRoot = join(stateRoot, "diagnostics"); + await mkdir(stateRoot, { mode: 0o755 }); + await chmod(stateRoot, 0o755); + const stats = await lstat(stateRoot); + return { base, stateRoot, diagnosticsRoot, uid: stats.uid, gid: stats.gid }; +} + +describe("граница привилегий diagnostics storage", () => { + test("production path не находится внутри service-writable logDir", () => { + expect(DIAGNOSTICS_ROOT).toBe("/var/lib/hy2xs/diagnostics"); + expect(DIAGNOSTICS_ROOT.startsWith("/var/log/hy2xs/")).toBe(false); + }); + + test("создание storage проходит через read-only guard", async () => { + const policy = await temporaryPolicy(); + enableReadOnlyGuard("test phase"); + await expect(ensureDiagnosticsStorageRoot(policy)).rejects.toThrow("read-only guard violation"); + }); + + test("symlink не принимается за доверенный каталог", () => { + expect(() => + assertTrustedDirectory("/var/lib/hy2xs/diagnostics", fakeStats({ symlink: true }), { + uid: 0, + gid: 0, + exactMode: 0o700 + }) + ).toThrow("symbolic link запрещён"); + }); + + test("чужой владелец и записываемый родитель отвергаются", () => { + expect(() => + assertTrustedDirectory("/var/lib/hy2xs", fakeStats({ uid: 1001, mode: 0o40755 }), { + uid: 0, + gid: 0, + rejectGroupOrOtherWrite: true + }) + ).toThrow("ожидается владелец 0:0"); + expect(() => + assertTrustedDirectory("/var/lib/hy2xs", fakeStats({ mode: 0o40775 }), { + uid: 0, + gid: 0, + rejectGroupOrOtherWrite: true + }) + ).toThrow("могут изменять root namespace"); + }); + + test("diagnostics root требует точный режим 0700", () => { + expect(() => + assertTrustedDirectory("/var/lib/hy2xs/diagnostics", fakeStats({ mode: 0o40750 }), { + uid: 0, + gid: 0, + exactMode: 0o700 + }) + ).toThrow("ожидается режим 0700"); + }); + + test("существующая symlink вместо diagnostics root отвергается fail closed", async () => { + const policy = await temporaryPolicy(); + const target = join(policy.base, "attacker-controlled"); + await mkdir(target); + const sentinel = join(target, "root-file"); + await writeFile(sentinel, "не изменять\n"); + await symlink(target, policy.diagnosticsRoot, process.platform === "win32" ? "junction" : "dir"); + + await expect(ensureDiagnosticsStorageRoot(policy)).rejects.toThrow("symbolic link запрещён"); + expect(await readFile(sentinel, "utf8")).toBe("не изменять\n"); + }); + + test("рабочие каталоги уникальны, а archive path заранее занят через wx", async () => { + const policy = await temporaryPolicy(); + await ensureDiagnosticsStorageRoot(policy); + const first = await createDiagnosticsWorkspace(policy); + const second = await createDiagnosticsWorkspace(policy); + + expect(first.outDir).not.toBe(second.outDir); + expect(first.operationId.startsWith("diag-")).toBe(true); + await expect(open(first.archive, "wx")).rejects.toBeDefined(); + + await cleanupDiagnosticsWorkspace(first, policy); + await cleanupDiagnosticsWorkspace(second, policy); + await discardDiagnosticsArchive(first, policy); + await discardDiagnosticsArchive(second, policy); + }); + + test("очистка отвергает workspace вне доверенного diagnostics root", async () => { + const policy = await temporaryPolicy(); + await expect( + cleanupDiagnosticsWorkspace( + { + operationId: "diag-ABC123", + outDir: join(policy.base, "diag-ABC123"), + archive: join(policy.base, "diag-ABC123.tar.gz") + }, + policy + ) + ).rejects.toThrow("небезопасные пути diagnostics workspace"); + }); +}); diff --git a/orchestrator/test/package-meta-utf8.test.ts b/orchestrator/test/package-meta-utf8.test.ts index a0ba39f..33c2cc2 100644 --- a/orchestrator/test/package-meta-utf8.test.ts +++ b/orchestrator/test/package-meta-utf8.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { InvalidUtf8Error } from "../src/lib/fs"; -import { readPackageValue } from "../src/lib/packageMeta"; +import { isMissingPackageMetadataError, readPackageValue } from "../src/lib/packageMeta"; const directories: string[] = []; @@ -19,6 +19,14 @@ async function packageDir(): Promise { } describe("metadata пакета", () => { + test("fallback классифицирует только ENOENT", () => { + expect(isMissingPackageMetadataError(Object.assign(new Error("missing"), { code: "ENOENT" }))).toBe(true); + for (const code of ["EACCES", "EIO", "EISDIR"]) { + expect(isMissingPackageMetadataError(Object.assign(new Error(code), { code }))).toBe(false); + } + expect(isMissingPackageMetadataError(new InvalidUtf8Error("metadata/value", new Error("decode")))).toBe(false); + }); + test("отсутствующее необязательное значение использует fallback", async () => { const directory = await packageDir(); expect(await readPackageValue(directory, "missing", "unknown")).toBe("unknown"); @@ -31,4 +39,10 @@ describe("metadata пакета", () => { InvalidUtf8Error ); }); + + test("каталог вместо metadata-файла не маскируется fallback-значением", async () => { + const directory = await packageDir(); + await mkdir(join(directory, "metadata", "package.version")); + await expect(readPackageValue(directory, "package.version", "unknown")).rejects.toBeDefined(); + }); }); diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index fbf3272..134bf3c 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -1514,6 +1514,39 @@ run_single_owner_acceptance() { } ' || fail "acceptance: a diagnostics failure must never cancel the rollback" + log_step "Acceptance: diagnostics writes only into a trusted root namespace" + grep -qF 'export const DIAGNOSTICS_ROOT = `${INSTALL_STATE_DIR}/diagnostics`' \ + orchestrator/src/lib/diagnosticsStorage.ts \ + || fail "acceptance: diagnostics root must live below /var/lib/hy2xs" + ! grep -qF '/var/log/hy2xs/diagnostics' orchestrator/src/commands/diagnostics.ts \ + || fail "acceptance: root diagnostics must not use the service-writable log namespace" + grep -q 'ensureDiagnosticsStorageRoot' orchestrator/src/steps/filesystem.ts \ + || fail "acceptance: install must prepare the root-only diagnostics directory" + "$BUN_BIN" -e ' + const fs = require("node:fs"); + const storage = fs.readFileSync("orchestrator/src/lib/diagnosticsStorage.ts", "utf8"); + const diagnostics = fs.readFileSync("orchestrator/src/commands/diagnostics.ts", "utf8"); + for (const marker of ["lstat(", "isSymbolicLink()", "mkdtemp(", "open(archive, \"wx\"", "exactMode: DIAGNOSTICS_MODE"]) { + if (!storage.includes(marker)) throw new Error("missing diagnostics trust-boundary marker: " + marker); + } + if (!diagnostics.includes("createDiagnosticsWorkspace()")) { + throw new Error("diagnostics does not enter the trusted storage boundary"); + } + if (!diagnostics.includes("runMutatingArgv([\"tar\"")) { + throw new Error("diagnostics archive creation must not use a shell string"); + } + ' || fail "acceptance: diagnostics storage is not fail-closed against symlink substitution" + + log_step "Acceptance: metadata fallback is ENOENT-only" + "$BUN_BIN" -e ' + const fs = require("node:fs"); + const source = fs.readFileSync("orchestrator/src/lib/packageMeta.ts", "utf8"); + if (!source.includes(".code === \"ENOENT\"")) throw new Error("missing explicit ENOENT branch"); + if (!source.includes("throw error")) throw new Error("non-ENOENT metadata errors are not rethrown"); + const tests = fs.readFileSync("orchestrator/test/package-meta-utf8.test.ts", "utf8"); + if (!tests.includes("каталог вместо metadata-файла")) throw new Error("missing non-ENOENT regression test"); + ' || fail "acceptance: package metadata errors may still be masked by fallback" + log_step "Acceptance: persisting the failure state never blocks the rollback" # Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше. # Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она