mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 23:31:24 +00:00
fix(security): harden sandbox docker config validation
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
collectMinimalProfileOverrideFindings,
|
||||
collectModelHygieneFindings,
|
||||
collectNodeDenyCommandPatternFindings,
|
||||
collectSandboxDangerousConfigFindings,
|
||||
collectSandboxDockerNoopFindings,
|
||||
collectSecretsInConfigFindings,
|
||||
collectSmallModelRiskFindings,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user