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 { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Docker from "dockerode";
import { DockerSandboxOptions, ContainerCreateResult } from "./types/docker-sandbox.types";
import {
DockerSandboxOptions,
ContainerCreateResult,
DockerSecurityOptions,
LinuxCapability,
} from "./types/docker-sandbox.types";
/**
* Default whitelist of allowed environment variable names/patterns for Docker containers.
@@ -28,6 +33,22 @@ export const DEFAULT_ENV_WHITELIST: readonly string[] = [
"MOSAIC_AGENT_TYPE",
] as const;
/**
* Default security hardening options for Docker containers.
* These settings follow security best practices:
* - Drop all Linux capabilities (principle of least privilege)
* - Read-only root filesystem (agents write to mounted /workspace volume)
* - PID limit to prevent fork bombs
* - No new privileges to prevent privilege escalation
*/
export const DEFAULT_SECURITY_OPTIONS: Required<DockerSecurityOptions> = {
capDrop: ["ALL"],
capAdd: [],
readonlyRootfs: true,
pidsLimit: 100,
noNewPrivileges: true,
} as const;
/**
* Service for managing Docker container isolation for agents
* Provides secure sandboxing with resource limits and cleanup
@@ -42,6 +63,7 @@ export class DockerSandboxService {
private readonly defaultCpuLimit: number;
private readonly defaultNetworkMode: string;
private readonly envWhitelist: readonly string[];
private readonly defaultSecurityOptions: Required<DockerSecurityOptions>;
constructor(
private readonly configService: ConfigService,
@@ -80,9 +102,40 @@ export class DockerSandboxService {
const customWhitelist = this.configService.get<string[]>("orchestrator.sandbox.envWhitelist");
this.envWhitelist = customWhitelist ?? DEFAULT_ENV_WHITELIST;
// Load security options from config, merging with secure defaults
const configCapDrop = this.configService.get<LinuxCapability[]>(
"orchestrator.sandbox.security.capDrop"
);
const configCapAdd = this.configService.get<LinuxCapability[]>(
"orchestrator.sandbox.security.capAdd"
);
const configReadonlyRootfs = this.configService.get<boolean>(
"orchestrator.sandbox.security.readonlyRootfs"
);
const configPidsLimit = this.configService.get<number>(
"orchestrator.sandbox.security.pidsLimit"
);
const configNoNewPrivileges = this.configService.get<boolean>(
"orchestrator.sandbox.security.noNewPrivileges"
);
this.defaultSecurityOptions = {
capDrop: configCapDrop ?? DEFAULT_SECURITY_OPTIONS.capDrop,
capAdd: configCapAdd ?? DEFAULT_SECURITY_OPTIONS.capAdd,
readonlyRootfs: configReadonlyRootfs ?? DEFAULT_SECURITY_OPTIONS.readonlyRootfs,
pidsLimit: configPidsLimit ?? DEFAULT_SECURITY_OPTIONS.pidsLimit,
noNewPrivileges: configNoNewPrivileges ?? DEFAULT_SECURITY_OPTIONS.noNewPrivileges,
};
this.logger.log(
`DockerSandboxService initialized (enabled: ${this.sandboxEnabled.toString()}, socket: ${socketPath})`
);
this.logger.log(
`Security hardening: capDrop=${this.defaultSecurityOptions.capDrop.join(",") || "none"}, ` +
`readonlyRootfs=${this.defaultSecurityOptions.readonlyRootfs.toString()}, ` +
`pidsLimit=${this.defaultSecurityOptions.pidsLimit.toString()}, ` +
`noNewPrivileges=${this.defaultSecurityOptions.noNewPrivileges.toString()}`
);
if (!this.sandboxEnabled) {
this.logger.warn(
@@ -111,6 +164,9 @@ export class DockerSandboxService {
const cpuLimit = options?.cpuLimit ?? this.defaultCpuLimit;
const networkMode = options?.networkMode ?? this.defaultNetworkMode;
// Merge security options with defaults
const security = this.mergeSecurityOptions(options?.security);
// Convert memory from MB to bytes
const memoryBytes = memoryMB * 1024 * 1024;
@@ -143,18 +199,33 @@ export class DockerSandboxService {
`Creating container for agent ${agentId} (image: ${image}, memory: ${memoryMB.toString()}MB, cpu: ${cpuLimit.toString()})`
);
// Build HostConfig with security hardening
const hostConfig: Docker.HostConfig = {
Memory: memoryBytes,
NanoCpus: nanoCpus,
NetworkMode: networkMode,
Binds: [`${workspacePath}:/workspace`],
AutoRemove: false, // Manual cleanup for audit trail
ReadonlyRootfs: security.readonlyRootfs,
PidsLimit: security.pidsLimit > 0 ? security.pidsLimit : undefined,
SecurityOpt: security.noNewPrivileges ? ["no-new-privileges:true"] : undefined,
};
// Add capability dropping if configured
if (security.capDrop.length > 0) {
hostConfig.CapDrop = security.capDrop;
}
// Add capabilities back if configured (useful when dropping ALL first)
if (security.capAdd.length > 0) {
hostConfig.CapAdd = security.capAdd;
}
const container = await this.docker.createContainer({
Image: image,
name: containerName,
User: "node:node", // Non-root user for security
HostConfig: {
Memory: memoryBytes,
NanoCpus: nanoCpus,
NetworkMode: networkMode,
Binds: [`${workspacePath}:/workspace`],
AutoRemove: false, // Manual cleanup for audit trail
ReadonlyRootfs: false, // Allow writes within container
},
HostConfig: hostConfig,
WorkingDir: "/workspace",
Env: env,
});
@@ -326,4 +397,31 @@ export class DockerSandboxService {
private isEnvVarAllowed(varName: string): boolean {
return this.envWhitelist.includes(varName);
}
/**
* Get the current default security options
* @returns The configured security options
*/
getSecurityOptions(): Required<DockerSecurityOptions> {
return { ...this.defaultSecurityOptions };
}
/**
* Merge provided security options with defaults
* @param options Optional security options to merge
* @returns Complete security options with all fields
*/
private mergeSecurityOptions(options?: DockerSecurityOptions): Required<DockerSecurityOptions> {
if (!options) {
return { ...this.defaultSecurityOptions };
}
return {
capDrop: options.capDrop ?? this.defaultSecurityOptions.capDrop,
capAdd: options.capAdd ?? this.defaultSecurityOptions.capAdd,
readonlyRootfs: options.readonlyRootfs ?? this.defaultSecurityOptions.readonlyRootfs,
pidsLimit: options.pidsLimit ?? this.defaultSecurityOptions.pidsLimit,
noNewPrivileges: options.noNewPrivileges ?? this.defaultSecurityOptions.noNewPrivileges,
};
}
}