fix(security): harden sandbox docker config validation

This commit is contained in:
Peter Steinberger
2026-02-16 03:03:55 +01:00
parent d4bdcda324
commit 887b209db4
11 changed files with 691 additions and 6 deletions

View File

@@ -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<string, unknown> }> = [];
const defaultDocker = cfg.agents?.defaults?.sandbox?.docker;
if (defaultDocker && typeof defaultDocker === "object") {
configs.push({
source: "agents.defaults.sandbox.docker",
docker: defaultDocker as Record<string, unknown>,
});
}
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<string, unknown>,
});
}
}
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;

View File

@@ -16,6 +16,7 @@ export {
collectMinimalProfileOverrideFindings,
collectModelHygieneFindings,
collectNodeDenyCommandPatternFindings,
collectSandboxDangerousConfigFindings,
collectSandboxDockerNoopFindings,
collectSecretsInConfigFindings,
collectSmallModelRiskFindings,

View File

@@ -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: {

View File

@@ -21,6 +21,7 @@ import {
collectModelHygieneFindings,
collectNodeDenyCommandPatternFindings,
collectSmallModelRiskFindings,
collectSandboxDangerousConfigFindings,
collectSandboxDockerNoopFindings,
collectPluginsTrustFindings,
collectSecretsInConfigFindings,
@@ -621,6 +622,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
findings.push(...collectHooksHardeningFindings(cfg, env));
findings.push(...collectGatewayHttpSessionKeyOverrideFindings(cfg));
findings.push(...collectSandboxDockerNoopFindings(cfg));
findings.push(...collectSandboxDangerousConfigFindings(cfg));
findings.push(...collectNodeDenyCommandPatternFindings(cfg));
findings.push(...collectMinimalProfileOverrideFindings(cfg));
findings.push(...collectSecretsInConfigFindings(cfg));