chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* macOS Seatbelt 沙箱集成测试
|
||||
*
|
||||
* 使用与生产完全一致的 seatbelt profile 格式(无 network*),
|
||||
* 验证危险操作被正确拦截。
|
||||
*
|
||||
* 运行条件:
|
||||
* - platform === darwin
|
||||
* - /usr/bin/sandbox-exec 存在
|
||||
*/
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { runInSeatbelt, expectBlocked, expectAllowed } from "./shared-integration-utils";
|
||||
|
||||
const testOnMacOS = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec") ? describe : describe.skip;
|
||||
|
||||
const WORKSPACE_DIR = "/tmp/test-seatbelt-workspace";
|
||||
const WORKSPACE_DIR_REALPATH = "/private/tmp/test-seatbelt-workspace";
|
||||
|
||||
// 确保 workspace 目录存在(宿主上)
|
||||
beforeAll(() => {
|
||||
try {
|
||||
mkdirSync(WORKSPACE_DIR, { recursive: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
const SEATBELT_PROFILE_NO_NETWORK = `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*)
|
||||
(allow process-exec (regex #"^/usr/bin/"))
|
||||
(allow process-exec (regex #"^/bin/"))
|
||||
(allow process-exec (regex #"^/usr/lib/"))
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(allow sysctl-read)
|
||||
(allow mach-lookup)
|
||||
(allow ipc-posix*)
|
||||
(allow file-lock)
|
||||
(allow file-write* (subpath "${WORKSPACE_DIR}"))
|
||||
(allow file-write* (subpath "${WORKSPACE_DIR_REALPATH}"))
|
||||
(allow file-write* (literal "/dev/null"))
|
||||
(allow file-write* (literal "/dev/dtracehelper"))
|
||||
(allow file-write* (literal "/dev/urandom"))
|
||||
`;
|
||||
|
||||
const SEATBELT_PROFILE_WITH_NETWORK = SEATBELT_PROFILE_NO_NETWORK + "(allow network*)\n";
|
||||
|
||||
testOnMacOS("macOS seatbelt integration tests", () => {
|
||||
// ==================== 文件写入测试 ====================
|
||||
describe("file write operations", () => {
|
||||
it("should BLOCK writing to /etc/hosts", async () => {
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "echo test >> /etc/hosts");
|
||||
expectBlocked(result);
|
||||
});
|
||||
|
||||
it("should BLOCK writing to /etc/passwd", async () => {
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "echo test >> /etc/passwd");
|
||||
expectBlocked(result);
|
||||
});
|
||||
|
||||
it("should BLOCK writing to /usr/bin/", async () => {
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "touch /usr/bin/evil-binary");
|
||||
expectBlocked(result);
|
||||
});
|
||||
|
||||
it("should BLOCK writing to ~/Documents/", async () => {
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "touch ~/Documents/exfil.txt");
|
||||
expectBlocked(result);
|
||||
});
|
||||
|
||||
it("should BLOCK writing to /tmp/ (not in writablePaths)", async () => {
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "touch /tmp/attacker-file.txt");
|
||||
expectBlocked(result);
|
||||
});
|
||||
|
||||
it("should ALLOW writing inside workspace", async () => {
|
||||
// Workspace dir is pre-created by beforeAll on host.
|
||||
// Only the write operation runs inside sandbox (mkdir already succeeded on host).
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "echo safe > /tmp/test-seatbelt-workspace/safe.txt");
|
||||
expectAllowed(result);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 文件删除测试 ====================
|
||||
describe("file delete operations", () => {
|
||||
it("should BLOCK deleting /etc/resolv.conf", async () => {
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "rm /etc/resolv.conf 2>&1 || true");
|
||||
expectBlocked(result);
|
||||
});
|
||||
|
||||
it("should BLOCK deleting /usr/bin/ls", async () => {
|
||||
// Note: On SIP-protected systems, /usr/bin/ls can't be deleted anyway (SIP, not sandbox)
|
||||
// We still expect blocked for other reasons
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "rm /usr/bin/ls 2>&1 || true");
|
||||
// Just verify some error occurred (either sandbox or system)
|
||||
const blocked = result.exitCode !== 0 || /permission|denied|not allowed|no such file/i.test(result.stderr + result.stdout);
|
||||
expect(blocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 提权测试 ====================
|
||||
describe("privilege escalation", () => {
|
||||
it("should BLOCK sudo id - sandbox denies exec", async () => {
|
||||
// Without || true, exit code reflects sandbox failure
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "sudo id 2>&1");
|
||||
// sandbox denies exec of sudo → "Operation not permitted"
|
||||
const blocked = result.exitCode !== 0 ||
|
||||
/permission|denied|not allowed|operation not permitted/i.test(result.stderr + result.stdout);
|
||||
expect(blocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 系统命令测试 ====================
|
||||
describe("system commands", () => {
|
||||
it("should BLOCK shutdown - permission denied by sandbox", async () => {
|
||||
// Without || true, the actual exit code will be captured
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "/sbin/shutdown -h now 2>&1");
|
||||
// sandbox blocks exec of /sbin/shutdown → "Operation not permitted"
|
||||
const blocked = result.exitCode !== 0 ||
|
||||
/permission|denied|not allowed|operation not permitted/i.test(result.stderr + result.stdout);
|
||||
expect(blocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 网络测试 ====================
|
||||
describe("network access", () => {
|
||||
it("should BLOCK network access (no network* profile)", async () => {
|
||||
// Use curl without -s to see error messages; no || true so exitCode reflects curl
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_NO_NETWORK, "curl --max-time 5 http://example.com 2>&1");
|
||||
// curl exit 6 = Could not resolve host (DNS blocked by sandbox)
|
||||
const blocked = result.exitCode !== 0 ||
|
||||
/could not resolve|failed|connection|network|permission/i.test(result.stderr + result.stdout);
|
||||
expect(blocked).toBe(true);
|
||||
});
|
||||
|
||||
it("should ALLOW network access (with network* profile)", async () => {
|
||||
// With network* allowed, curl error would be non-permission (e.g., exit 6 = DNS fail)
|
||||
const result = await runInSeatbelt(SEATBELT_PROFILE_WITH_NETWORK, "curl --max-time 10 http://example.com 2>&1");
|
||||
// If exit is non-zero, it should NOT be due to permission denied by sandbox
|
||||
if (result.exitCode !== 0) {
|
||||
expect(/permission|denied.*network|operation not permitted/i.test(result.stderr + result.stdout)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface ExecResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
const TIMEOUT_MS = 10000;
|
||||
|
||||
/**
|
||||
* 在 macOS seatbelt profile 下执行命令
|
||||
*/
|
||||
export async function runInSeatbelt(
|
||||
profileContent: string,
|
||||
shellCommand: string,
|
||||
): Promise<ExecResult> {
|
||||
const tmpProfile = `/tmp/qiming-test-${Date.now()}.sb`;
|
||||
const cmd = `echo '${profileContent.replace(/'/g, "'\\''")}' > ${tmpProfile} && sandbox-exec -f ${tmpProfile} /bin/sh -c '${shellCommand.replace(/'/g, "'\\''")}' ; rm -f ${tmpProfile}`;
|
||||
try {
|
||||
const { stdout, stderr, signal, code } = await execAsync(cmd, {
|
||||
timeout: TIMEOUT_MS,
|
||||
shell: "/bin/sh",
|
||||
});
|
||||
return {
|
||||
exitCode: code ?? 0,
|
||||
stdout: stdout ?? "",
|
||||
stderr: stderr ?? "",
|
||||
timedOut: signal === "SIGTERM",
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
exitCode: e.code ?? 1,
|
||||
stdout: e.stdout ?? "",
|
||||
stderr: e.stderr ?? e.message ?? "",
|
||||
timedOut: e.signal === "SIGTERM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 bwrap 下执行命令
|
||||
*/
|
||||
export async function runInBwrap(
|
||||
bwrapArgs: string[],
|
||||
shellCommand: string,
|
||||
): Promise<ExecResult> {
|
||||
try {
|
||||
const { stdout, stderr, signal, code } = await execAsync(
|
||||
`bwrap ${bwrapArgs.join(" ")} /bin/sh -c '${shellCommand.replace(/'/g, "'\\''")}'`,
|
||||
{ timeout: TIMEOUT_MS, shell: "/bin/sh" }
|
||||
);
|
||||
return {
|
||||
exitCode: code ?? 0,
|
||||
stdout: stdout ?? "",
|
||||
stderr: stderr ?? "",
|
||||
timedOut: signal === "SIGTERM",
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
exitCode: e.code ?? 1,
|
||||
stdout: e.stdout ?? "",
|
||||
stderr: e.stderr ?? e.message ?? "",
|
||||
timedOut: e.signal === "SIGTERM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function expectBlocked(result: ExecResult): void {
|
||||
// exitCode !== 0 或 stdout/stderr 包含 permission/operation not permitted 等关键字
|
||||
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 operation to be blocked, but it succeeded.\nExit code: ${result.exitCode}\nStdout: ${result.stdout}\nStderr: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function expectAllowed(result: ExecResult): void {
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Expected operation to be allowed, but it failed.\nExit code: ${result.exitCode}\nStdout: ${result.stdout}\nStderr: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user