import { spawn, spawnSync } from "node:child_process"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterAll, describe, expect, it } from "vitest"; import { MediaCommandBuilder } from "./command"; import { collectProbe } from "./probe"; import { sandboxEnvironment } from "./profile"; import { helperPath, sandboxCapability, spawnSandbox, type SandboxChild, type SandboxOptions } from "./runtime"; const work = mkdtempSync(join(tmpdir(), "media-security-tests-")); const rootfs = join(work, "rootfs"); for (const dir of ["upload", "scratch", "tmp", "proc", "dev", "usr/bin"]) mkdirSync(join(rootfs, dir), { recursive: true }); writeFileSync(join(rootfs, "dev/null"), ""); const built = spawnSync("cc", ["-static", "-O0", "-Wall", "-Wextra", "-Werror", resolve(__dirname, "../native/test-child.c"), "-o", join(rootfs, "usr/bin/ffprobe")]); if (built.status === 0) spawnSync("cp", [join(rootfs, "usr/bin/ffprobe"), join(rootfs, "usr/bin/ffmpeg")]); const options = { rootfs, cgroupRoot: process.env.MEDIA_SANDBOX_CGROUP_ROOT ?? "/sys/fs/cgroup" }; const capability = built.status === 0 ? sandboxCapability(options) : { available: false, reason: "static C test fixture compiler unavailable" }; if (!capability.available) console.warn(`SKIP sandbox integration and sandbox canaries: ${capability.reason}`); afterAll(() => rmSync(work, { recursive: true, force: true })); const builder = new MediaCommandBuilder(); let sequence = 0; function job(action: string, limits?: SandboxOptions["limits"]): SandboxChild { const id = (++sequence).toString(16).padStart(64, "0"); const file = join(work, id); writeFileSync(file, action); return spawnSandbox(builder.probe(id, "wav"), { ...options, limits, asset: { id, resolve: () => file } }, { stdio: ["ignore", "pipe", "pipe"] }); } async function output(child: SandboxChild): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> { let stdout = "", stderr = ""; child.child.stdout?.on("data", chunk => { stdout += String(chunk); }); child.child.stderr?.on("data", chunk => { stderr += String(chunk); }); return { ...await child.exited, stdout, stderr }; } function ready(child: SandboxChild): Promise { return new Promise((done, reject) => { child.child.stdout!.once("data", () => done()); child.child.once("exit", () => reject(new Error("sandbox failed before ready"))); }); } describe.skipIf(!capability.available)("rootless sandbox confinement", () => { it("cannot read host/vault/DB/other-job files; root is read-only and scratch works; UIDs differ", async () => { const secret = join(work, "vault-db-sibling-secret"); writeFileSync(secret, "private"); const first = await output(job(`isolation\n${secret}\n`)); const second = await output(job(`isolation\n${secret}\n`)); expect(first.code, first.stderr).toBe(0); expect(second.code, second.stderr).toBe(0); expect(first.stdout).toContain("isolated uid="); expect(first.stdout).not.toEqual(second.stdout); }); it("blocks socket creation, DNS, loopback, internal CIDRs and metadata", async () => { const result = await output(job("network\n")); expect(result.code, result.stderr).toBe(0); expect(result.stdout).toContain("network denied"); }); it("kills memory/time offenders without affecting a sibling", async () => { const sibling = job("sleep\n", { wallTimeMs: 30_000 }); try { await ready(sibling); const memory = await output(job("memory\n", { memoryBytes: 16 * 1024 * 1024, wallTimeMs: 5000 })); expect(memory.stdout).toContain("ready"); expect(memory.signal).toBe("SIGKILL"); expect(sibling.child.exitCode).toBeNull(); expect(sibling.child.signalCode).toBeNull(); const timed = await output(job("sleep\n", { wallTimeMs: 1000 })); expect(timed.stdout).toContain("ready"); expect(timed.signal).toBe("SIGKILL"); expect(sibling.child.exitCode).toBeNull(); expect(sibling.child.signalCode).toBeNull(); } finally { sibling.kill(); await sibling.exited; } }, 15_000); it("enforces pids and file-size caps without affecting siblings", async () => { const sibling = job("sleep\n", { wallTimeMs: 30_000 }); try { await ready(sibling); const pids = await output(job("pids\n", { pids: 12 })); expect(pids.code, pids.stderr).toBe(0); expect(pids.stdout).toContain("pids denied"); const size = await output(job("size\n", { fileSizeBytes: 8192 })); expect(size.code).not.toBe(0); expect(sibling.child.exitCode).toBeNull(); expect(sibling.child.signalCode).toBeNull(); } finally { sibling.kill(); await sibling.exited; } }, 15_000); it("caps and validates hostile probe output", async () => { expect((await collectProbe(job("probe-good\n"))).streams).toHaveLength(1); await expect(collectProbe(job("probe-big\n"), 4096)).rejects.toThrow(/limit exceeded/); await expect(collectProbe(job("probe-invalid\n"))).rejects.toThrow(/invalid probe/); }); it("canary key/token/passphrase stay absent from every process cmdline/environ and captured output/log", async () => { const canaries = ["stream-key-canary-95c70", "oauth-token-canary-d7921", "srt-passphrase-canary-f1f12"]; const names = ["CANARY_STREAM_KEY", "CANARY_OAUTH_TOKEN", "CANARY_SRT_PASSPHRASE"]; const before = names.map(name => process.env[name]); names.forEach((name, i) => { process.env[name] = canaries[i]; }); try { const child = job("metadata\n"); const captured = output(child); // The host-visible wrapper stays alive while the child inspects its final /proc metadata itself. await new Promise(done => setTimeout(done, 100)); const metadata = [readFileSync(`/proc/${child.child.pid}/cmdline`), readFileSync(`/proc/${child.child.pid}/environ`)]; const result = await captured; expect(result.code).toBe(7); expect(result.stdout).toContain("PATH=/usr/bin:/bin"); expect(result.stderr).toContain("forced authentication/connection failure"); for (const canary of canaries) for (const text of [...metadata.map(v => v.toString()), result.stdout, result.stderr]) expect(text).not.toContain(canary); } finally { names.forEach((name, i) => { if (before[i] === undefined) delete process.env[name]; else process.env[name] = before[i]; }); } }); }); it("probe collector caps output and never logs hostile stderr, even without sandbox capability", async () => { const canary = "stream-key-canary-oversized"; const logs: string[] = []; const child = spawn(process.execPath, ["-e", 'process.stdin.once("data",b=>{process.stderr.write(b);process.stdout.write("x".repeat(50000))})'], { env: sandboxEnvironment(), stdio: ["pipe", "pipe", "pipe"] }); const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(done => child.once("close", (code, signal) => done({ code, signal }))); const collected = collectProbe({ child, exited, kill: () => { child.kill("SIGKILL"); } }, 4096, line => logs.push(line)); child.stdin!.end(canary); await expect(collected).rejects.toThrow(/limit exceeded/); expect(logs.length).toBeGreaterThan(0); expect(logs.join("\n")).not.toContain(canary); }); it("installs the real seccomp profile and no-new-privileges without needing userns", () => { const result = spawnSync(helperPath(), ["--seccomp-check"], { env: sandboxEnvironment(), timeout: 5000 }); expect(result.status, String(result.stderr)).toBe(0); });