From 887b209db47f1f9322fead241a1c0b043fd38339 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 03:03:55 +0100 Subject: [PATCH] fix(security): harden sandbox docker config validation --- CHANGELOG.md | 1 + src/agents/sandbox-create-args.e2e.test.ts | 115 +++++++++- src/agents/sandbox/docker.ts | 4 + .../sandbox/validate-sandbox-security.test.ts | 146 ++++++++++++ .../sandbox/validate-sandbox-security.ts | 208 ++++++++++++++++++ src/config/config.sandbox-docker.test.ts | 51 ++++- src/config/zod-schema.agent-runtime.ts | 28 +++ src/security/audit-extra.sync.ts | 99 +++++++++ src/security/audit-extra.ts | 1 + src/security/audit.test.ts | 42 ++++ src/security/audit.ts | 2 + 11 files changed, 691 insertions(+), 6 deletions(-) create mode 100644 src/agents/sandbox/validate-sandbox-security.test.ts create mode 100644 src/agents/sandbox/validate-sandbox-security.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5517ddd5b60..1682823d232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent. - Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras. - Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx. - Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model. diff --git a/src/agents/sandbox-create-args.e2e.test.ts b/src/agents/sandbox-create-args.e2e.test.ts index 5200572c86e..ccb9b3395ad 100644 --- a/src/agents/sandbox-create-args.e2e.test.ts +++ b/src/agents/sandbox-create-args.e2e.test.ts @@ -94,7 +94,7 @@ describe("buildSandboxCreateArgs", () => { ); }); - it("emits -v flags for custom binds", () => { + it("emits -v flags for safe custom binds", () => { const cfg: SandboxDockerConfig = { image: "openclaw-sandbox:bookworm-slim", containerPrefix: "openclaw-sbx-", @@ -103,7 +103,7 @@ describe("buildSandboxCreateArgs", () => { tmpfs: [], network: "none", capDrop: [], - binds: ["/home/user/source:/source:rw", "/var/run/docker.sock:/var/run/docker.sock"], + binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"], }; const args = buildSandboxCreateArgs({ @@ -124,7 +124,116 @@ describe("buildSandboxCreateArgs", () => { } } expect(vFlags).toContain("/home/user/source:/source:rw"); - expect(vFlags).toContain("/var/run/docker.sock:/var/run/docker.sock"); + expect(vFlags).toContain("/var/data/myapp:/data:ro"); + }); + + it("throws on dangerous bind mounts (Docker socket)", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: ["/var/run/docker.sock:/var/run/docker.sock"], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-dangerous", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/blocked path/); + }); + + it("throws on dangerous bind mounts (parent path)", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: ["/run:/run"], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-dangerous-parent", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/blocked path/); + }); + + it("throws on network host mode", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "host", + capDrop: [], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-host", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/network mode "host" is blocked/); + }); + + it("throws on seccomp unconfined", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + seccompProfile: "unconfined", + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-seccomp", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/seccomp profile "unconfined" is blocked/); + }); + + it("throws on apparmor unconfined", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + apparmorProfile: "unconfined", + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-apparmor", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/apparmor profile "unconfined" is blocked/); }); it("omits -v flags when binds is empty or undefined", () => { diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index f79885d8a13..f87f7d5f5b4 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -111,6 +111,7 @@ import { computeSandboxConfigHash } from "./config-hash.js"; import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { readRegistry, updateRegistry } from "./registry.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; +import { validateSandboxSecurity } from "./validate-sandbox-security.js"; const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; @@ -240,6 +241,9 @@ export function buildSandboxCreateArgs(params: { labels?: Record; configHash?: string; }) { + // Runtime security validation: blocks dangerous bind mounts, network modes, and profiles. + validateSandboxSecurity(params.cfg); + const createdAtMs = params.createdAtMs ?? Date.now(); const args = ["create", "--name", params.name]; args.push("--label", "openclaw.sandbox=1"); diff --git a/src/agents/sandbox/validate-sandbox-security.test.ts b/src/agents/sandbox/validate-sandbox-security.test.ts new file mode 100644 index 00000000000..91668c74e76 --- /dev/null +++ b/src/agents/sandbox/validate-sandbox-security.test.ts @@ -0,0 +1,146 @@ +import { mkdtempSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + getBlockedBindReasonStringOnly, + validateBindMounts, + validateNetworkMode, + validateSeccompProfile, + validateApparmorProfile, + validateSandboxSecurity, +} from "./validate-sandbox-security.js"; + +describe("getBlockedBindReasonStringOnly", () => { + it("blocks ancestor mounts that would expose the Docker socket", () => { + expect(getBlockedBindReasonStringOnly("/run:/run")).toEqual( + expect.objectContaining({ kind: "covers" }), + ); + expect(getBlockedBindReasonStringOnly("/var/run:/var/run:ro")).toEqual( + expect.objectContaining({ kind: "covers" }), + ); + expect(getBlockedBindReasonStringOnly("/var:/var")).toEqual( + expect.objectContaining({ kind: "covers" }), + ); + }); +}); + +describe("validateBindMounts", () => { + it("allows legitimate project directory mounts", () => { + expect(() => + validateBindMounts([ + "/home/user/source:/source:rw", + "/home/user/projects:/projects:ro", + "/var/data/myapp:/data", + "/opt/myapp/config:/config:ro", + ]), + ).not.toThrow(); + }); + + it("allows undefined or empty binds", () => { + expect(() => validateBindMounts(undefined)).not.toThrow(); + expect(() => validateBindMounts([])).not.toThrow(); + }); + + it("blocks /etc mount", () => { + expect(() => validateBindMounts(["/etc/passwd:/mnt/passwd:ro"])).toThrow( + /blocked path "\/etc"/, + ); + }); + + it("blocks /proc mount", () => { + expect(() => validateBindMounts(["/proc:/proc:ro"])).toThrow(/blocked path "\/proc"/); + }); + + it("blocks Docker socket mounts (/var/run + /run)", () => { + expect(() => validateBindMounts(["/var/run/docker.sock:/var/run/docker.sock"])).toThrow( + /docker\.sock/, + ); + expect(() => validateBindMounts(["/run/docker.sock:/run/docker.sock"])).toThrow(/docker\.sock/); + }); + + it("blocks parent mounts that would expose the Docker socket", () => { + expect(() => validateBindMounts(["/run:/run"])).toThrow(/blocked path/); + expect(() => validateBindMounts(["/var/run:/var/run"])).toThrow(/blocked path/); + expect(() => validateBindMounts(["/var:/var"])).toThrow(/blocked path/); + }); + + it("blocks paths with .. traversal to dangerous directories", () => { + expect(() => validateBindMounts(["/home/user/../../etc/shadow:/mnt/shadow"])).toThrow( + /blocked path "\/etc"/, + ); + }); + + it("blocks paths with double slashes normalizing to dangerous dirs", () => { + expect(() => validateBindMounts(["//etc//passwd:/mnt/passwd"])).toThrow(/blocked path "\/etc"/); + }); + + it("blocks symlink escapes into blocked directories", () => { + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const link = join(dir, "etc-link"); + symlinkSync("/etc", link); + expect(() => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`])).toThrow(/blocked path/); + }); + + it("rejects non-absolute source paths (relative or named volumes)", () => { + expect(() => validateBindMounts(["../etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/); + expect(() => validateBindMounts(["etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/); + expect(() => validateBindMounts(["myvol:/mnt"])).toThrow(/non-absolute/); + }); +}); + +describe("validateNetworkMode", () => { + it("allows bridge/none/custom/undefined", () => { + expect(() => validateNetworkMode("bridge")).not.toThrow(); + expect(() => validateNetworkMode("none")).not.toThrow(); + expect(() => validateNetworkMode("my-custom-network")).not.toThrow(); + expect(() => validateNetworkMode(undefined)).not.toThrow(); + }); + + it("blocks host mode (case-insensitive)", () => { + expect(() => validateNetworkMode("host")).toThrow(/network mode "host" is blocked/); + expect(() => validateNetworkMode("HOST")).toThrow(/network mode "HOST" is blocked/); + }); +}); + +describe("validateSeccompProfile", () => { + it("allows custom profile paths/undefined", () => { + expect(() => validateSeccompProfile("/tmp/seccomp.json")).not.toThrow(); + expect(() => validateSeccompProfile(undefined)).not.toThrow(); + }); + + it("blocks unconfined (case-insensitive)", () => { + expect(() => validateSeccompProfile("unconfined")).toThrow( + /seccomp profile "unconfined" is blocked/, + ); + expect(() => validateSeccompProfile("Unconfined")).toThrow( + /seccomp profile "Unconfined" is blocked/, + ); + }); +}); + +describe("validateApparmorProfile", () => { + it("allows named profile/undefined", () => { + expect(() => validateApparmorProfile("openclaw-sandbox")).not.toThrow(); + expect(() => validateApparmorProfile(undefined)).not.toThrow(); + }); + + it("blocks unconfined (case-insensitive)", () => { + expect(() => validateApparmorProfile("unconfined")).toThrow( + /apparmor profile "unconfined" is blocked/, + ); + }); +}); + +describe("validateSandboxSecurity", () => { + it("passes with safe config", () => { + expect(() => + validateSandboxSecurity({ + binds: ["/home/user/src:/src:rw"], + network: "none", + seccompProfile: "/tmp/seccomp.json", + apparmorProfile: "openclaw-sandbox", + }), + ).not.toThrow(); + }); +}); diff --git a/src/agents/sandbox/validate-sandbox-security.ts b/src/agents/sandbox/validate-sandbox-security.ts new file mode 100644 index 00000000000..57498e54401 --- /dev/null +++ b/src/agents/sandbox/validate-sandbox-security.ts @@ -0,0 +1,208 @@ +/** + * Sandbox security validation — blocks dangerous Docker configurations. + * + * Threat model: local-trusted config, but protect against foot-guns and config injection. + * Enforced at runtime when creating sandbox containers. + */ + +import { existsSync, realpathSync } from "node:fs"; +import { posix } from "node:path"; + +// Targeted denylist: host paths that should never be exposed inside sandbox containers. +// Exported for reuse in security audit collectors. +export const BLOCKED_HOST_PATHS = [ + "/etc", + "/private/etc", + "/proc", + "/sys", + "/dev", + "/root", + "/boot", + "/var/run/docker.sock", + "/private/var/run/docker.sock", + "/run/docker.sock", +]; + +const BLOCKED_NETWORK_MODES = new Set(["host"]); +const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]); +const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]); + +export type BlockedBindReason = + | { kind: "targets"; blockedPath: string } + | { kind: "covers"; blockedPath: string } + | { kind: "non_absolute"; sourcePath: string }; + +/** + * Parse the host/source path from a Docker bind mount string. + * Format: `source:target[:mode]` + */ +export function parseBindSourcePath(bind: string): string { + const trimmed = bind.trim(); + const firstColon = trimmed.indexOf(":"); + if (firstColon <= 0) { + // No colon or starts with colon — treat as source. + return trimmed; + } + return trimmed.slice(0, firstColon); +} + +/** + * Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`. + */ +export function normalizeHostPath(raw: string): string { + const trimmed = raw.trim(); + return posix.normalize(trimmed).replace(/\/+$/, "") || "/"; +} + +/** + * String-only blocked-path check (no filesystem I/O). + * Blocks: + * - binds that target blocked paths (equal or under) + * - binds that cover blocked paths (ancestor mounts like /run or /var) + * - non-absolute source paths (relative / volume names) because they are hard to validate safely + */ +export function getBlockedBindReasonStringOnly(bind: string): BlockedBindReason | null { + const sourceRaw = parseBindSourcePath(bind); + if (!sourceRaw.startsWith("/")) { + return { kind: "non_absolute", sourcePath: sourceRaw }; + } + + const normalized = normalizeHostPath(sourceRaw); + + for (const blocked of BLOCKED_HOST_PATHS) { + if (normalized === blocked || normalized.startsWith(blocked + "/")) { + return { kind: "targets", blockedPath: blocked }; + } + // Ancestor mounts: mounting /run exposes /run/docker.sock. + if (normalized === "/") { + return { kind: "covers", blockedPath: blocked }; + } + if (blocked.startsWith(normalized + "/")) { + return { kind: "covers", blockedPath: blocked }; + } + } + + return null; +} + +function tryRealpathAbsolute(path: string): string { + if (!path.startsWith("/")) { + return path; + } + if (!existsSync(path)) { + return path; + } + try { + // Use native when available (keeps platform semantics); normalize for prefix checks. + return normalizeHostPath(realpathSync.native(path)); + } catch { + return path; + } +} + +function formatBindBlockedError(params: { bind: string; reason: BlockedBindReason }): Error { + if (params.reason.kind === "non_absolute") { + return new Error( + `Sandbox security: bind mount "${params.bind}" uses a non-absolute source path ` + + `"${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`, + ); + } + const verb = params.reason.kind === "covers" ? "covers" : "targets"; + return new Error( + `Sandbox security: bind mount "${params.bind}" ${verb} blocked path "${params.reason.blockedPath}". ` + + "Mounting system directories (or Docker socket paths) into sandbox containers is not allowed. " + + "Use project-specific paths instead (e.g. /home/user/myproject).", + ); +} + +/** + * Validate bind mounts — throws if any source path is dangerous. + * Includes a symlink/realpath pass when the source path exists. + */ +export function validateBindMounts(binds: string[] | undefined): void { + if (!binds?.length) { + return; + } + + for (const rawBind of binds) { + const bind = rawBind.trim(); + if (!bind) { + continue; + } + + // Fast string-only check (covers .., //, ancestor/descendant logic). + const blocked = getBlockedBindReasonStringOnly(bind); + if (blocked) { + throw formatBindBlockedError({ bind, reason: blocked }); + } + + // Symlink escape hardening: resolve existing absolute paths and re-check. + const sourceRaw = parseBindSourcePath(bind); + const sourceNormalized = normalizeHostPath(sourceRaw); + const sourceReal = tryRealpathAbsolute(sourceNormalized); + if (sourceReal !== sourceNormalized) { + for (const blockedPath of BLOCKED_HOST_PATHS) { + if (sourceReal === blockedPath || sourceReal.startsWith(blockedPath + "/")) { + throw formatBindBlockedError({ + bind, + reason: { kind: "targets", blockedPath }, + }); + } + if (sourceReal === "/") { + throw formatBindBlockedError({ + bind, + reason: { kind: "covers", blockedPath }, + }); + } + if (blockedPath.startsWith(sourceReal + "/")) { + throw formatBindBlockedError({ + bind, + reason: { kind: "covers", blockedPath }, + }); + } + } + } + } +} + +export function validateNetworkMode(network: string | undefined): void { + if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) { + throw new Error( + `Sandbox security: network mode "${network}" is blocked. ` + + 'Network "host" mode bypasses container network isolation. ' + + 'Use "bridge" or "none" instead.', + ); + } +} + +export function validateSeccompProfile(profile: string | undefined): void { + if (profile && BLOCKED_SECCOMP_PROFILES.has(profile.trim().toLowerCase())) { + throw new Error( + `Sandbox security: seccomp profile "${profile}" is blocked. ` + + "Disabling seccomp removes syscall filtering and weakens sandbox isolation. " + + "Use a custom seccomp profile file or omit this setting.", + ); + } +} + +export function validateApparmorProfile(profile: string | undefined): void { + if (profile && BLOCKED_APPARMOR_PROFILES.has(profile.trim().toLowerCase())) { + throw new Error( + `Sandbox security: apparmor profile "${profile}" is blocked. ` + + "Disabling AppArmor removes mandatory access controls and weakens sandbox isolation. " + + "Use a named AppArmor profile or omit this setting.", + ); + } +} + +export function validateSandboxSecurity(cfg: { + binds?: string[]; + network?: string; + seccompProfile?: string; + apparmorProfile?: string; +}): void { + validateBindMounts(cfg.binds); + validateNetworkMode(cfg.network); + validateSeccompProfile(cfg.seccompProfile); + validateApparmorProfile(cfg.apparmorProfile); +} diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index 92903ff32f7..7add1d3c293 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -3,13 +3,13 @@ import { resolveSandboxBrowserConfig } from "../agents/sandbox/config.js"; import { validateConfigObject } from "./config.js"; describe("sandbox docker config", () => { - it("accepts binds array in sandbox.docker config", () => { + it("accepts safe binds array in sandbox.docker config", () => { const res = validateConfigObject({ agents: { defaults: { sandbox: { docker: { - binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"], + binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"], }, }, }, @@ -29,8 +29,8 @@ describe("sandbox docker config", () => { expect(res.ok).toBe(true); if (res.ok) { expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([ - "/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw", + "/var/data/myapp:/data:ro", ]); expect(res.config.agents?.list?.[0]?.sandbox?.docker?.binds).toEqual([ "/home/user/projects:/projects:ro", @@ -38,6 +38,51 @@ describe("sandbox docker config", () => { } }); + it("rejects network host mode via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + network: "host", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("rejects seccomp unconfined via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + seccompProfile: "unconfined", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("rejects apparmor unconfined via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + apparmorProfile: "unconfined", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + it("rejects non-string values in binds array", () => { const res = validateConfigObject({ agents: { diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 197f29368ca..e84bb38d787 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -125,6 +125,34 @@ export const SandboxDockerSchema = z binds: z.array(z.string()).optional(), }) .strict() + .superRefine((data, ctx) => { + if (data.network?.trim().toLowerCase() === "host") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["network"], + message: + 'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.', + }); + } + if (data.seccompProfile?.trim().toLowerCase() === "unconfined") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["seccompProfile"], + message: + 'Sandbox security: seccomp profile "unconfined" is blocked. ' + + "Use a custom seccomp profile file or omit this setting.", + }); + } + if (data.apparmorProfile?.trim().toLowerCase() === "unconfined") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["apparmorProfile"], + message: + 'Sandbox security: apparmor profile "unconfined" is blocked. ' + + "Use a named AppArmor profile or omit this setting.", + }); + } + }) .optional(); export const SandboxBrowserSchema = z diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 1ccc45d99c5..ea347ee2e6f 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -11,6 +11,7 @@ import { resolveSandboxConfigForAgent, resolveSandboxToolPolicyForAgent, } from "../agents/sandbox.js"; +import { getBlockedBindReasonStringOnly } from "../agents/sandbox/validate-sandbox-security.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { resolveBrowserConfig } from "../browser/config.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -584,6 +585,104 @@ export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityA return findings; } +export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + + const configs: Array<{ source: string; docker: Record }> = []; + const defaultDocker = cfg.agents?.defaults?.sandbox?.docker; + if (defaultDocker && typeof defaultDocker === "object") { + configs.push({ + source: "agents.defaults.sandbox.docker", + docker: defaultDocker as Record, + }); + } + for (const entry of agents) { + if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + continue; + } + const agentDocker = entry.sandbox?.docker; + if (agentDocker && typeof agentDocker === "object") { + configs.push({ + source: `agents.list.${entry.id}.sandbox.docker`, + docker: agentDocker as Record, + }); + } + } + + for (const { source, docker } of configs) { + const binds = Array.isArray(docker.binds) ? docker.binds : []; + for (const bind of binds) { + if (typeof bind !== "string") { + continue; + } + const blocked = getBlockedBindReasonStringOnly(bind); + if (!blocked) { + continue; + } + if (blocked.kind === "non_absolute") { + findings.push({ + checkId: "sandbox.bind_mount_non_absolute", + severity: "warn", + title: "Sandbox bind mount uses a non-absolute source path", + detail: + `${source}.binds contains "${bind}" which uses source path "${blocked.sourcePath}". ` + + "Non-absolute bind sources are hard to validate safely and may resolve unexpectedly.", + remediation: `Rewrite "${bind}" to use an absolute host path (for example: /home/user/project:/project:ro).`, + }); + continue; + } + const verb = blocked.kind === "covers" ? "covers" : "targets"; + findings.push({ + checkId: "sandbox.dangerous_bind_mount", + severity: "critical", + title: "Dangerous bind mount in sandbox config", + detail: + `${source}.binds contains "${bind}" which ${verb} blocked path "${blocked.blockedPath}". ` + + "This can expose host system directories or the Docker socket to sandbox containers.", + remediation: `Remove "${bind}" from ${source}.binds. Use project-specific paths instead.`, + }); + } + + const network = typeof docker.network === "string" ? docker.network : undefined; + if (network && network.trim().toLowerCase() === "host") { + findings.push({ + checkId: "sandbox.dangerous_network_mode", + severity: "critical", + title: "Network host mode in sandbox config", + detail: `${source}.network is "host" which bypasses container network isolation entirely.`, + remediation: `Set ${source}.network to "bridge" or "none".`, + }); + } + + const seccompProfile = + typeof docker.seccompProfile === "string" ? docker.seccompProfile : undefined; + if (seccompProfile && seccompProfile.trim().toLowerCase() === "unconfined") { + findings.push({ + checkId: "sandbox.dangerous_seccomp_profile", + severity: "critical", + title: "Seccomp unconfined in sandbox config", + detail: `${source}.seccompProfile is "unconfined" which disables syscall filtering.`, + remediation: `Remove ${source}.seccompProfile or use a custom seccomp profile file.`, + }); + } + + const apparmorProfile = + typeof docker.apparmorProfile === "string" ? docker.apparmorProfile : undefined; + if (apparmorProfile && apparmorProfile.trim().toLowerCase() === "unconfined") { + findings.push({ + checkId: "sandbox.dangerous_apparmor_profile", + severity: "critical", + title: "AppArmor unconfined in sandbox config", + detail: `${source}.apparmorProfile is "unconfined" which disables AppArmor enforcement.`, + remediation: `Remove ${source}.apparmorProfile or use a named AppArmor profile.`, + }); + } + } + + return findings; +} + export function collectNodeDenyCommandPatternFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const denyListRaw = cfg.gateway?.nodes?.denyCommands; diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index 1b507d6ae99..abd9efa0979 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -16,6 +16,7 @@ export { collectMinimalProfileOverrideFindings, collectModelHygieneFindings, collectNodeDenyCommandPatternFindings, + collectSandboxDangerousConfigFindings, collectSandboxDockerNoopFindings, collectSecretsInConfigFindings, collectSmallModelRiskFindings, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 5ee796d531a..09a82a31658 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -486,6 +486,48 @@ describe("security audit", () => { expect(res.findings.some((f) => f.checkId === "sandbox.docker_config_mode_off")).toBe(false); }); + it("flags dangerous sandbox docker config (binds/network/seccomp/apparmor)", async () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + docker: { + binds: ["/etc/passwd:/mnt/passwd:ro", "/run:/run"], + network: "host", + seccompProfile: "unconfined", + apparmorProfile: "unconfined", + }, + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "sandbox.dangerous_bind_mount", severity: "critical" }), + expect.objectContaining({ + checkId: "sandbox.dangerous_network_mode", + severity: "critical", + }), + expect.objectContaining({ + checkId: "sandbox.dangerous_seccomp_profile", + severity: "critical", + }), + expect.objectContaining({ + checkId: "sandbox.dangerous_apparmor_profile", + severity: "critical", + }), + ]), + ); + }); + it("flags ineffective gateway.nodes.denyCommands entries", async () => { const cfg: OpenClawConfig = { gateway: { diff --git a/src/security/audit.ts b/src/security/audit.ts index 4487f53a9cb..7f08620ffe8 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -21,6 +21,7 @@ import { collectModelHygieneFindings, collectNodeDenyCommandPatternFindings, collectSmallModelRiskFindings, + collectSandboxDangerousConfigFindings, collectSandboxDockerNoopFindings, collectPluginsTrustFindings, collectSecretsInConfigFindings, @@ -621,6 +622,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise