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 { 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user