Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
103 lines
7.3 KiB
TypeScript
103 lines
7.3 KiB
TypeScript
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<void> {
|
|
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);
|
|
});
|