fix(#338): Add Docker security hardening (CapDrop, ReadonlyRootfs, PidsLimit)
- Drop all Linux capabilities by default (CapDrop: ALL) - Enable read-only root filesystem (agents write to mounted /workspace volume) - Limit process count to 100 to prevent fork bombs (PidsLimit) - Add no-new-privileges security option to prevent privilege escalation - Add DockerSecurityOptions type with configurable security settings - All options are configurable via config but secure by default - Add comprehensive tests for security hardening options (20+ new tests) Refs #338 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { DockerSandboxService, DEFAULT_ENV_WHITELIST } from "./docker-sandbox.service";
|
||||
import {
|
||||
DockerSandboxService,
|
||||
DEFAULT_ENV_WHITELIST,
|
||||
DEFAULT_SECURITY_OPTIONS,
|
||||
} from "./docker-sandbox.service";
|
||||
import { DockerSecurityOptions, LinuxCapability } from "./types/docker-sandbox.types";
|
||||
import Docker from "dockerode";
|
||||
|
||||
describe("DockerSandboxService", () => {
|
||||
@@ -59,7 +64,7 @@ describe("DockerSandboxService", () => {
|
||||
});
|
||||
|
||||
describe("createContainer", () => {
|
||||
it("should create a container with default configuration", async () => {
|
||||
it("should create a container with default configuration and security hardening", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
@@ -80,7 +85,10 @@ describe("DockerSandboxService", () => {
|
||||
NetworkMode: "bridge",
|
||||
Binds: [`${workspacePath}:/workspace`],
|
||||
AutoRemove: false,
|
||||
ReadonlyRootfs: false,
|
||||
ReadonlyRootfs: true, // Security hardening: read-only root filesystem
|
||||
PidsLimit: 100, // Security hardening: prevent fork bombs
|
||||
SecurityOpt: ["no-new-privileges:true"], // Security hardening: prevent privilege escalation
|
||||
CapDrop: ["ALL"], // Security hardening: drop all capabilities
|
||||
},
|
||||
WorkingDir: "/workspace",
|
||||
Env: [`AGENT_ID=${agentId}`, `TASK_ID=${taskId}`],
|
||||
@@ -596,4 +604,295 @@ describe("DockerSandboxService", () => {
|
||||
expect(DEFAULT_ENV_WHITELIST).not.toContain("ANTHROPIC_API_KEY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("security hardening options", () => {
|
||||
describe("DEFAULT_SECURITY_OPTIONS", () => {
|
||||
it("should drop all Linux capabilities by default", () => {
|
||||
expect(DEFAULT_SECURITY_OPTIONS.capDrop).toEqual(["ALL"]);
|
||||
});
|
||||
|
||||
it("should not add any capabilities back by default", () => {
|
||||
expect(DEFAULT_SECURITY_OPTIONS.capAdd).toEqual([]);
|
||||
});
|
||||
|
||||
it("should enable read-only root filesystem by default", () => {
|
||||
expect(DEFAULT_SECURITY_OPTIONS.readonlyRootfs).toBe(true);
|
||||
});
|
||||
|
||||
it("should limit PIDs to 100 by default", () => {
|
||||
expect(DEFAULT_SECURITY_OPTIONS.pidsLimit).toBe(100);
|
||||
});
|
||||
|
||||
it("should disable new privileges by default", () => {
|
||||
expect(DEFAULT_SECURITY_OPTIONS.noNewPrivileges).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSecurityOptions", () => {
|
||||
it("should return default security options when none configured", () => {
|
||||
const options = service.getSecurityOptions();
|
||||
|
||||
expect(options.capDrop).toEqual(["ALL"]);
|
||||
expect(options.capAdd).toEqual([]);
|
||||
expect(options.readonlyRootfs).toBe(true);
|
||||
expect(options.pidsLimit).toBe(100);
|
||||
expect(options.noNewPrivileges).toBe(true);
|
||||
});
|
||||
|
||||
it("should return custom security options when configured", () => {
|
||||
const customConfigService = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.docker.socketPath": "/var/run/docker.sock",
|
||||
"orchestrator.sandbox.enabled": true,
|
||||
"orchestrator.sandbox.defaultImage": "node:20-alpine",
|
||||
"orchestrator.sandbox.defaultMemoryMB": 512,
|
||||
"orchestrator.sandbox.defaultCpuLimit": 1.0,
|
||||
"orchestrator.sandbox.networkMode": "bridge",
|
||||
"orchestrator.sandbox.security.capDrop": ["NET_RAW", "SYS_ADMIN"],
|
||||
"orchestrator.sandbox.security.capAdd": ["CHOWN"],
|
||||
"orchestrator.sandbox.security.readonlyRootfs": false,
|
||||
"orchestrator.sandbox.security.pidsLimit": 200,
|
||||
"orchestrator.sandbox.security.noNewPrivileges": false,
|
||||
};
|
||||
return config[key] !== undefined ? config[key] : defaultValue;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
const customService = new DockerSandboxService(customConfigService, mockDocker);
|
||||
const options = customService.getSecurityOptions();
|
||||
|
||||
expect(options.capDrop).toEqual(["NET_RAW", "SYS_ADMIN"]);
|
||||
expect(options.capAdd).toEqual(["CHOWN"]);
|
||||
expect(options.readonlyRootfs).toBe(false);
|
||||
expect(options.pidsLimit).toBe(200);
|
||||
expect(options.noNewPrivileges).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createContainer with security options", () => {
|
||||
it("should apply CapDrop to container HostConfig", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.CapDrop).toEqual(["ALL"]);
|
||||
});
|
||||
|
||||
it("should apply custom CapDrop when specified in options", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
security: {
|
||||
capDrop: ["NET_RAW", "SYS_ADMIN"] as LinuxCapability[],
|
||||
},
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.CapDrop).toEqual(["NET_RAW", "SYS_ADMIN"]);
|
||||
});
|
||||
|
||||
it("should apply CapAdd when specified in options", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
security: {
|
||||
capAdd: ["CHOWN", "SETUID"] as LinuxCapability[],
|
||||
},
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.CapAdd).toEqual(["CHOWN", "SETUID"]);
|
||||
});
|
||||
|
||||
it("should not include CapAdd when empty", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.CapAdd).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should apply ReadonlyRootfs to container HostConfig", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.ReadonlyRootfs).toBe(true);
|
||||
});
|
||||
|
||||
it("should disable ReadonlyRootfs when specified in options", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
security: {
|
||||
readonlyRootfs: false,
|
||||
},
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.ReadonlyRootfs).toBe(false);
|
||||
});
|
||||
|
||||
it("should apply PidsLimit to container HostConfig", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.PidsLimit).toBe(100);
|
||||
});
|
||||
|
||||
it("should apply custom PidsLimit when specified in options", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
security: {
|
||||
pidsLimit: 50,
|
||||
},
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.PidsLimit).toBe(50);
|
||||
});
|
||||
|
||||
it("should not set PidsLimit when set to 0 (unlimited)", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
security: {
|
||||
pidsLimit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.PidsLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should apply no-new-privileges security option", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.SecurityOpt).toContain("no-new-privileges:true");
|
||||
});
|
||||
|
||||
it("should not apply no-new-privileges when disabled in options", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
security: {
|
||||
noNewPrivileges: false,
|
||||
},
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.SecurityOpt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should merge partial security options with defaults", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
security: {
|
||||
pidsLimit: 200, // Override just this one
|
||||
} as DockerSecurityOptions,
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
// Overridden
|
||||
expect(callArgs.HostConfig?.PidsLimit).toBe(200);
|
||||
// Defaults still applied
|
||||
expect(callArgs.HostConfig?.CapDrop).toEqual(["ALL"]);
|
||||
expect(callArgs.HostConfig?.ReadonlyRootfs).toBe(true);
|
||||
expect(callArgs.HostConfig?.SecurityOpt).toContain("no-new-privileges:true");
|
||||
});
|
||||
|
||||
it("should not include CapDrop when empty array specified", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
security: {
|
||||
capDrop: [] as LinuxCapability[],
|
||||
},
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
const callArgs = (mockDocker.createContainer as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as Docker.ContainerCreateOptions;
|
||||
expect(callArgs.HostConfig?.CapDrop).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("security hardening logging", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should log security hardening configuration on initialization", () => {
|
||||
new DockerSandboxService(mockConfigService, mockDocker);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Security hardening:"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("capDrop=ALL"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("readonlyRootfs=true"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("pidsLimit=100"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("noNewPrivileges=true"));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user