196 lines
7.0 KiB
TypeScript
196 lines
7.0 KiB
TypeScript
/**
|
||
* Linux bwrap 沙箱集成测试
|
||
*
|
||
* 使用 --ro-bind / / --tmpfs /tmp --unshare-net(无网络),
|
||
* 验证危险操作被正确拦截。
|
||
*
|
||
* 运行条件:
|
||
* - platform === linux
|
||
* - bwrap 命令可用
|
||
*/
|
||
import { exec } from "child_process";
|
||
import { existsSync } from "fs";
|
||
import { describe, it, expect, beforeAll } from "vitest";
|
||
|
||
const testOnLinux = process.platform === "linux" ? describe : describe.skip;
|
||
|
||
let bwrapAvailable = false;
|
||
let workspaceDir = "/tmp/test-bwrap-workspace";
|
||
|
||
beforeAll(async () => {
|
||
try {
|
||
const { stdout } = await exec("which bwrap");
|
||
bwrapAvailable = stdout.trim().length > 0;
|
||
} catch {
|
||
bwrapAvailable = false;
|
||
}
|
||
// 创建测试工作区
|
||
await exec(`mkdir -p ${workspaceDir}`).catch(() => {});
|
||
});
|
||
|
||
const BASE_BWRAP_ARGS_NO_NET = [
|
||
"--die-with-parent",
|
||
"--new-session",
|
||
"--unshare-user-try",
|
||
"--unshare-pid",
|
||
"--unshare-uts",
|
||
"--unshare-cgroup-try",
|
||
"--unshare-net",
|
||
"--dev-bind", "/dev", "/dev",
|
||
"--proc", "/proc",
|
||
"--tmpfs", "/tmp",
|
||
"--ro-bind", "/", "/",
|
||
"--bind", workspaceDir, workspaceDir,
|
||
"--chdir", workspaceDir,
|
||
];
|
||
|
||
testOnLinux("Linux bwrap integration tests", () => {
|
||
// ==================== 文件写入测试 ====================
|
||
describe("file write operations", () => {
|
||
it("should BLOCK writing to /etc/hosts", async () => {
|
||
const result = await runBwrapCommand(`echo test >> /etc/hosts`);
|
||
expectBlocked(result);
|
||
});
|
||
|
||
it("should BLOCK writing to /etc/passwd", async () => {
|
||
const result = await runBwrapCommand(`echo test >> /etc/passwd`);
|
||
expectBlocked(result);
|
||
});
|
||
|
||
it("should BLOCK writing to /usr/bin/", async () => {
|
||
const result = await runBwrapCommand(`touch /usr/bin/evil-binary`);
|
||
expectBlocked(result);
|
||
});
|
||
|
||
it("should BLOCK writing to /home/", async () => {
|
||
const result = await runBwrapCommand(`touch /home/exfil.txt`);
|
||
expectBlocked(result);
|
||
});
|
||
|
||
it("should ALLOW writing inside workspace", async () => {
|
||
const result = await runBwrapCommand(`echo safe > ${workspaceDir}/safe.txt`);
|
||
expectAllowed(result);
|
||
});
|
||
|
||
it("should ALLOW writing to /tmp/ (tmpfs)", async () => {
|
||
const result = await runBwrapCommand(`echo tmpfs > /tmp/bwrap-tmp-test.txt && cat /tmp/bwrap-tmp-test.txt`);
|
||
expectAllowed(result);
|
||
});
|
||
|
||
it("should verify /tmp/ write is in tmpfs not host", async () => {
|
||
// 验证 /tmp 是 tmpfs,宿主 /tmp 不会有这个文件
|
||
await runBwrapCommand(`echo isolated > /tmp/unique-bwrap-test.txt`);
|
||
const { stdout } = await exec(`cat /tmp/unique-bwrap-test.txt 2>&1 || echo "FILE_NOT_FOUND"`);
|
||
// 宿主 /tmp 不应有此文件(因为是 tmpfs)
|
||
expect(stdout.trim()).toBe("FILE_NOT_FOUND");
|
||
});
|
||
});
|
||
|
||
// ==================== 文件删除测试 ====================
|
||
describe("file delete operations", () => {
|
||
it("should BLOCK deleting /etc/hostname", async () => {
|
||
const result = await runBwrapCommand(`rm /etc/hostname 2>&1 || true`);
|
||
expectBlocked(result);
|
||
});
|
||
|
||
it("should BLOCK deleting /bin/sh", async () => {
|
||
const result = await runBwrapCommand(`rm /bin/sh 2>&1 || true`);
|
||
expectBlocked(result);
|
||
});
|
||
});
|
||
|
||
// ==================== 网络测试 ====================
|
||
describe("network access", () => {
|
||
it("should BLOCK external network (--unshare-net)", async () => {
|
||
const result = await runBwrapCommand(`curl -s --max-time 5 http://example.com 2>&1 || true`);
|
||
expectBlocked(result);
|
||
});
|
||
|
||
it("should ALLOW loopback (localhost still accessible)", async () => {
|
||
// --unshare-net 后 lo 仍可用,localhost 应该能工作
|
||
const result = await runBwrapCommand(`ping -c 1 127.0.0.1 2>&1 || true`);
|
||
// ping 可能有其他限制,只要不是 "network unreachable" 就行
|
||
const blocked = /network.unreachable|no.route|operation.not.permitted/i.test(result.stderr + result.stdout);
|
||
// 注意:某些环境下 ping 需要 CAP_NET_RAW,可能被阻止
|
||
// 宽松处理:网络操作失败是预期的
|
||
expect(result.exitCode !== 0 || blocked).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ==================== 提权测试 ====================
|
||
describe("privilege escalation", () => {
|
||
it("should BLOCK sudo id", async () => {
|
||
const result = await runBwrapCommand(`sudo id 2>&1 || true`);
|
||
expect(result.exitCode !== 0 || /permission|denied/i.test(result.stderr + result.stdout)).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ==================== 系统命令测试 ====================
|
||
describe("system commands", () => {
|
||
it("should BLOCK reboot", async () => {
|
||
const result = await runBwrapCommand(`reboot 2>&1 || true`);
|
||
expectBlocked(result);
|
||
});
|
||
|
||
it("should BLOCK halt", async () => {
|
||
const result = await runBwrapCommand(`halt 2>&1 || true`);
|
||
expectBlocked(result);
|
||
});
|
||
});
|
||
|
||
// ==================== /dev gap 测试 ====================
|
||
describe("/dev access gap", () => {
|
||
it("should BLOCK mknod", async () => {
|
||
const result = await runBwrapCommand(`mknod /dev/test-device c 1 5 2>&1 || true`);
|
||
expectBlocked(result);
|
||
});
|
||
|
||
it("should BLOCK writing to /dev/sda", async () => {
|
||
const result = await runBwrapCommand(`echo evil > /dev/sda 2>&1 || true`);
|
||
expectBlocked(result);
|
||
});
|
||
});
|
||
|
||
// ==================== PID 命名空间测试 ====================
|
||
describe("PID namespace isolation", () => {
|
||
it("should show few processes (PID namespace isolated)", async () => {
|
||
const result = await runBwrapCommand(`ps aux | wc -l`);
|
||
const count = parseInt(result.stdout.trim(), 10);
|
||
// 在隔离的 PID 命名空间中,进程数应该很少(远少于宿主机的进程数)
|
||
expect(count).toBeLessThan(20);
|
||
});
|
||
});
|
||
});
|
||
|
||
// Helper
|
||
async function runBwrapCommand(command: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||
const { exec } = await import("child_process");
|
||
const { promisify } = await import("util");
|
||
const execAsync = promisify(exec);
|
||
|
||
try {
|
||
const { stdout, stderr, code } = await execAsync(
|
||
`bwrap ${BASE_BWRAP_ARGS_NO_NET.join(" ")} /bin/sh -c '${command.replace(/'/g, "'\\''")}'`,
|
||
{ timeout: 10000 }
|
||
);
|
||
return { exitCode: code ?? 0, stdout: stdout ?? "", stderr: stderr ?? "" };
|
||
} catch (e: any) {
|
||
return { exitCode: e.code ?? 1, stdout: e.stdout ?? "", stderr: e.stderr ?? e.message };
|
||
}
|
||
}
|
||
|
||
// 从 shared-imports 复用 expectBlocked/expectAllowed
|
||
function expectBlocked(result: { exitCode: number; stdout: string; stderr: string }): void {
|
||
const blocked = result.exitCode !== 0 ||
|
||
/permission|operation not permitted|denied|not allowed|read-only/i.test(result.stderr) ||
|
||
/permission|operation not permitted|denied|not allowed|read-only/i.test(result.stdout);
|
||
if (!blocked) {
|
||
throw new Error(`Expected blocked, got exitCode=${result.exitCode}`);
|
||
}
|
||
}
|
||
|
||
function expectAllowed(result: { exitCode: number; stdout: string; stderr: string }): void {
|
||
if (result.exitCode !== 0) {
|
||
throw new Error(`Expected allowed, got exitCode=${result.exitCode}: ${result.stderr}`);
|
||
}
|
||
} |