fix(security): harden diagnostics storage boundary

This commit is contained in:
2026-09-07 19:52:11 +05:00
parent af9f476658
commit a3445f79c5
12 changed files with 562 additions and 37 deletions
@@ -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");
});
});
+15 -1
View File
@@ -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<string> {
}
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();
});
});