fix(security): harden diagnostics storage boundary
This commit is contained in:
@@ -8,6 +8,13 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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-проход закрывает границу кодировок. Оркестратор теперь
|
Очередной hardening-проход закрывает границу кодировок. Оркестратор теперь
|
||||||
читает конфигурацию и секреты как байты с fatal UTF-8 decoding, сохраняет BOM
|
читает конфигурацию и секреты как байты с fatal UTF-8 decoding, сохраняет BOM
|
||||||
как U+FEFF до явного транспортного отказа и проверяет исходный EnvironmentFile
|
как U+FEFF до явного транспортного отказа и проверяет исходный EnvironmentFile
|
||||||
|
|||||||
@@ -749,7 +749,7 @@ hy2xs-orchestrator reconfigure \
|
|||||||
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
|
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
|
||||||
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
|
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
|
||||||
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
|
| `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 перед публикацией логов |
|
| `hy2xs-orchestrator redact-config` | Отредактировать секреты в env/yaml перед публикацией логов |
|
||||||
|
|
||||||
`repair` без `--allow-partial-state` работает только поверх полностью успешной
|
`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-каталог после упаковки удаляется.
|
||||||
|
|
||||||
## Проверка безопасности после установки
|
## Проверка безопасности после установки
|
||||||
|
|
||||||
Минимальный набор проверок:
|
Минимальный набор проверок:
|
||||||
|
|||||||
@@ -349,9 +349,12 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
|
|||||||
(`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при
|
(`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при
|
||||||
неудачной установке или реконфигурации. Бандл предназначен для передачи наружу,
|
неудачной установке или реконфигурации. Бандл предназначен для передачи наружу,
|
||||||
поэтому попадающие в него `hy2xs.env`, `post-install.env` и `config.yaml`
|
поэтому попадающие в него `hy2xs.env`, `post-install.env` и `config.yaml`
|
||||||
редактируются перед упаковкой.
|
редактируются перед упаковкой. Готовый архив находится в
|
||||||
|
`/var/lib/hy2xs/diagnostics`, принадлежит `root:root` и имеет режим `0600`.
|
||||||
|
Сам каталог имеет режим `0700`; не ослабляйте его права и не переносите bundle
|
||||||
|
в `HY2XS_LOG_DIR`, доступный сервисному пользователю.
|
||||||
|
|
||||||
При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не
|
При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не
|
||||||
собирается: его сбор сам создал бы каталоги в `/var/log/hy2xs` на сервере,
|
собирается: его сбор сам создал бы каталог в `/var/lib/hy2xs/diagnostics` на сервере,
|
||||||
который мы обещали не трогать.
|
который мы обещали не трогать.
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ preflight-ошибка со словом `nftables` приводила к отк
|
|||||||
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
|
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
|
||||||
случае system rollback не выполняется, `install-state.json` не пишется,
|
случае system rollback не выполняется, `install-state.json` не пишется,
|
||||||
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
||||||
`/var/log/hy2xs`).
|
`/var/lib/hy2xs/diagnostics`).
|
||||||
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
||||||
развернула эти unit-файлы**.
|
развернула эти unit-файлы**.
|
||||||
|
|
||||||
@@ -695,7 +695,15 @@ Diagnostics не копирует env/YAML и не перенаправляет
|
|||||||
редакцию и лишь затем записываются с режимом `0600`. Некорректный UTF-8 в
|
редакцию и лишь затем записываются с режимом `0600`. Некорректный UTF-8 в
|
||||||
конфигурационном файле даёт безопасный маркер пропуска без исходных байтов.
|
конфигурационном файле даёт безопасный маркер пропуска без исходных байтов.
|
||||||
Вывод каждой внешней команды ограничен 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*(.*)` подставляло маркер в
|
Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в
|
||||||
заголовок mapping'а и оставляло нетронутым вложенный
|
заголовок mapping'а и оставляло нетронутым вложенный
|
||||||
|
|||||||
@@ -406,6 +406,15 @@
|
|||||||
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
|
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
|
||||||
редактировались env и YAML, а `journal-admin.log` копировался как есть.
|
редактировались 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)
|
## A7. Machine token в журналах (unit)
|
||||||
|
|
||||||
`apps/middleware/log_test.go` — запрос
|
`apps/middleware/log_test.go` — запрос
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { join } from "node:path";
|
|||||||
import type { CommonOptions } from "../types/context";
|
import type { CommonOptions } from "../types/context";
|
||||||
import { InvalidUtf8Error, readText, writeTextAtomic } from "../lib/fs";
|
import { InvalidUtf8Error, readText, writeTextAtomic } from "../lib/fs";
|
||||||
import { info, setOperationContext } from "../lib/log";
|
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";
|
import { redactEnv, redactLogText, redactYaml } from "../lib/redaction";
|
||||||
|
import {
|
||||||
function shellEscapeSingleQuotes(value: string): string {
|
cleanupDiagnosticsWorkspace,
|
||||||
return value.replaceAll("'", "'\\''");
|
createDiagnosticsWorkspace,
|
||||||
}
|
discardDiagnosticsArchive,
|
||||||
|
finalizeDiagnosticsArchive
|
||||||
|
} from "../lib/diagnosticsStorage";
|
||||||
|
|
||||||
const unavailable = (reason: string): string =>
|
const unavailable = (reason: string): string =>
|
||||||
`[HY2XS: источник не включён в диагностику: ${reason}]\n`;
|
`[HY2XS: источник не включён в диагностику: ${reason}]\n`;
|
||||||
@@ -68,29 +70,61 @@ export async function prepareDiagnosticFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function diagnosticsCollect(_options: CommonOptions): Promise<void> {
|
export async function diagnosticsCollect(_options: CommonOptions): Promise<void> {
|
||||||
const opId = `diag-${Date.now().toString(36)}`;
|
const workspace = await createDiagnosticsWorkspace();
|
||||||
setOperationContext(opId);
|
setOperationContext(workspace.operationId);
|
||||||
|
let archiveComplete = false;
|
||||||
|
let collectionFailed = false;
|
||||||
|
|
||||||
const outDir = `/var/log/hy2xs/diagnostics/${opId}`;
|
try {
|
||||||
const archive = `/var/log/hy2xs/diagnostics/${opId}.tar.gz`;
|
await collectCommand(workspace.outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]);
|
||||||
await runMutating`install -d -m 0700 ${outDir}`;
|
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 collectFile(workspace.outDir, "os-release.txt", "/etc/os-release", redactLogText);
|
||||||
await collectCommand(outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]);
|
await collectFile(workspace.outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv);
|
||||||
await collectCommand(outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]);
|
await collectFile(workspace.outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv);
|
||||||
await collectCommand(outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]);
|
await collectFile(workspace.outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml);
|
||||||
await collectCommand(outDir, "nftables.ruleset", ["nft", "list", "ruleset"]);
|
await collectFile(workspace.outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText);
|
||||||
await collectCommand(outDir, "uname.txt", ["uname", "-a"]);
|
|
||||||
await collectCommand(outDir, "ss-ltnup.txt", ["ss", "-ltnup"]);
|
|
||||||
|
|
||||||
await collectFile(outDir, "os-release.txt", "/etc/os-release", redactLogText);
|
// Archive path уже создан через open("wx") внутри доверенного root-only
|
||||||
await collectFile(outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv);
|
// namespace. argv-форма не запускает shell и сохраняет границы аргументов.
|
||||||
await collectFile(outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv);
|
await runMutatingArgv(["tar", "-czf", workspace.archive, "-C", workspace.outDir, "."]);
|
||||||
await collectFile(outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml);
|
await finalizeDiagnosticsArchive(workspace.archive);
|
||||||
await collectFile(outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText);
|
archiveComplete = true;
|
||||||
|
} catch (error) {
|
||||||
await runMutating`sh -c ${`umask 077; tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`;
|
collectionFailed = true;
|
||||||
await runMutating`chmod 0600 ${archive}`;
|
throw error;
|
||||||
|
} finally {
|
||||||
info(`diagnostics bundle collected: ${archive}`);
|
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)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info(`diagnostics bundle collected: ${workspace.archive}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Stats | null> {
|
||||||
|
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<DiagnosticsStorageOptions> {
|
||||||
|
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<string> {
|
||||||
|
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<DiagnosticsWorkspace> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
assertMutationAllowed("finalizeDiagnosticsArchive");
|
||||||
|
await chmod(path, ARCHIVE_MODE);
|
||||||
|
await assertDiagnosticsArchive(path, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupDiagnosticsWorkspace(
|
||||||
|
workspace: DiagnosticsWorkspace,
|
||||||
|
options: DiagnosticsStorageOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
assertMutationAllowed("cleanupDiagnosticsWorkspace");
|
||||||
|
assertWorkspacePaths(workspace, options);
|
||||||
|
await rm(workspace.outDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discardDiagnosticsArchive(
|
||||||
|
workspace: DiagnosticsWorkspace,
|
||||||
|
options: DiagnosticsStorageOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
assertMutationAllowed("discardDiagnosticsArchive");
|
||||||
|
assertWorkspacePaths(workspace, options);
|
||||||
|
await unlink(workspace.archive).catch((error) => {
|
||||||
|
if (errnoCode(error) !== "ENOENT") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
import { InvalidUtf8Error, readText } from "./fs";
|
import { InvalidUtf8Error, readText } from "./fs";
|
||||||
import { runReadOnly } from "./process";
|
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<string> {
|
export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return (await readText(`${packageDir}/metadata/${file}`)).trim();
|
return (await readText(`${packageDir}/metadata/${file}`)).trim();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Отсутствующая metadata допускает fallback, повреждённая — нет: иначе
|
// Только отсутствие необязательного файла означает fallback. Ошибки прав,
|
||||||
// битый пакет выдаёт себя за пакет с корректным запасным значением.
|
// ввода-вывода, неверного типа и повреждённый UTF-8 описывают битый либо
|
||||||
if (error instanceof InvalidUtf8Error) {
|
// недоступный пакет и не имеют права маскироваться запасным значением.
|
||||||
throw error;
|
if (isMissingPackageMetadataError(error)) {
|
||||||
}
|
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readInstalledHysteriaVersion(): Promise<string> {
|
export async function readInstalledHysteriaVersion(): Promise<string> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { InstallContext } from "../types/context";
|
import type { InstallContext } from "../types/context";
|
||||||
import { runMutating } from "../lib/process";
|
import { runMutating } from "../lib/process";
|
||||||
|
import { ensureDiagnosticsStorageRoot } from "../lib/diagnosticsStorage";
|
||||||
|
|
||||||
async function userExists(user: string): Promise<boolean> {
|
async function userExists(user: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -58,5 +59,6 @@ export async function prepareFilesystem(context: InstallContext): Promise<void>
|
|||||||
await ensureDir(context.config.installDir, "0755", "root:root");
|
await ensureDir(context.config.installDir, "0755", "root:root");
|
||||||
await ensureDir("/usr/local/lib/hy2xs", "0755", "root:root");
|
await ensureDir("/usr/local/lib/hy2xs", "0755", "root:root");
|
||||||
await ensureDir("/etc/nftables.d", "0755", "root:root");
|
await ensureDir("/etc/nftables.d", "0755", "root:root");
|
||||||
|
await ensureDiagnosticsStorageRoot();
|
||||||
await runMutating`chmod -R go-w ${context.config.installDir}`;
|
await runMutating`chmod -R go-w ${context.config.installDir}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { InvalidUtf8Error } from "../src/lib/fs";
|
import { InvalidUtf8Error } from "../src/lib/fs";
|
||||||
import { readPackageValue } from "../src/lib/packageMeta";
|
import { isMissingPackageMetadataError, readPackageValue } from "../src/lib/packageMeta";
|
||||||
|
|
||||||
const directories: string[] = [];
|
const directories: string[] = [];
|
||||||
|
|
||||||
@@ -19,6 +19,14 @@ async function packageDir(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("metadata пакета", () => {
|
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 () => {
|
test("отсутствующее необязательное значение использует fallback", async () => {
|
||||||
const directory = await packageDir();
|
const directory = await packageDir();
|
||||||
expect(await readPackageValue(directory, "missing", "unknown")).toBe("unknown");
|
expect(await readPackageValue(directory, "missing", "unknown")).toBe("unknown");
|
||||||
@@ -31,4 +39,10 @@ describe("metadata пакета", () => {
|
|||||||
InvalidUtf8Error
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1514,6 +1514,39 @@ run_single_owner_acceptance() {
|
|||||||
}
|
}
|
||||||
' || fail "acceptance: a diagnostics failure must never cancel the rollback"
|
' || 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"
|
log_step "Acceptance: persisting the failure state never blocks the rollback"
|
||||||
# Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше.
|
# Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше.
|
||||||
# Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она
|
# Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она
|
||||||
|
|||||||
Reference in New Issue
Block a user