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:
Jason Woltje
2026-02-05 18:21:43 -06:00
parent e747c8db04
commit 3f16bbeca1
3 changed files with 496 additions and 12 deletions

View File

@@ -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"));
});
});
});
});