146 lines
5.3 KiB
TypeScript
146 lines
5.3 KiB
TypeScript
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");
|
|
});
|
|
});
|