import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { resolveGatewayProbeAuth } from "../gateway/probe-auth.js"; import { probeGateway } from "../gateway/probe.js"; import { collectChannelSecurityFindings } from "./audit-channel.js"; import { collectAttackSurfaceSummaryFindings, collectExposureMatrixFindings, collectGatewayHttpSessionKeyOverrideFindings, collectHooksHardeningFindings, collectIncludeFilePermFindings, collectInstalledSkillsCodeSafetyFindings, collectMinimalProfileOverrideFindings, collectModelHygieneFindings, collectNodeDenyCommandPatternFindings, collectSmallModelRiskFindings, collectSandboxDangerousConfigFindings, collectSandboxDockerNoopFindings, collectPluginsTrustFindings, collectSecretsInConfigFindings, collectPluginsCodeSafetyFindings, collectStateDeepFilesystemFindings, collectSyncedFolderFindings, readConfigSnapshotForAudit, } from "./audit-extra.js"; import { formatPermissionDetail, formatPermissionRemediation, inspectPathPermissions, } from "./audit-fs.js"; import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js"; import type { ExecFn } from "./windows-acl.js"; export type SecurityAuditSeverity = "info" | "warn" | "critical"; export type SecurityAuditFinding = { checkId: string; severity: SecurityAuditSeverity; title: string; detail: string; remediation?: string; }; export type SecurityAuditSummary = { critical: number; warn: number; info: number; }; export type SecurityAuditReport = { ts: number; summary: SecurityAuditSummary; findings: SecurityAuditFinding[]; deep?: { gateway?: { attempted: boolean; url: string | null; ok: boolean; error: string | null; close?: { code: number; reason: string } | null; }; }; }; export type SecurityAuditOptions = { config: OpenClawConfig; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; deep?: boolean; includeFilesystem?: boolean; includeChannelSecurity?: boolean; /** Override where to check state (default: resolveStateDir()). */ stateDir?: string; /** Override config path check (default: resolveConfigPath()). */ configPath?: string; /** Time limit for deep gateway probe. */ deepTimeoutMs?: number; /** Dependency injection for tests. */ plugins?: ReturnType; /** Dependency injection for tests. */ probeGatewayFn?: typeof probeGateway; /** Dependency injection for tests (Windows ACL checks). */ execIcacls?: ExecFn; }; function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { let critical = 0; let warn = 0; let info = 0; for (const f of findings) { if (f.severity === "critical") { critical += 1; } else if (f.severity === "warn") { warn += 1; } else { info += 1; } } return { critical, warn, info }; } function normalizeAllowFromList(list: Array | undefined | null): string[] { if (!Array.isArray(list)) { return []; } return list.map((v) => String(v).trim()).filter(Boolean); } async function collectFilesystemFindings(params: { stateDir: string; configPath: string; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; execIcacls?: ExecFn; }): Promise { const findings: SecurityAuditFinding[] = []; const stateDirPerms = await inspectPathPermissions(params.stateDir, { env: params.env, platform: params.platform, exec: params.execIcacls, }); if (stateDirPerms.ok) { if (stateDirPerms.isSymlink) { findings.push({ checkId: "fs.state_dir.symlink", severity: "warn", title: "State dir is a symlink", detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`, }); } if (stateDirPerms.worldWritable) { findings.push({ checkId: "fs.state_dir.perms_world_writable", severity: "critical", title: "State dir is world-writable", detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; other users can write into your OpenClaw state.`, remediation: formatPermissionRemediation({ targetPath: params.stateDir, perms: stateDirPerms, isDir: true, posixMode: 0o700, env: params.env, }), }); } else if (stateDirPerms.groupWritable) { findings.push({ checkId: "fs.state_dir.perms_group_writable", severity: "warn", title: "State dir is group-writable", detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; group users can write into your OpenClaw state.`, remediation: formatPermissionRemediation({ targetPath: params.stateDir, perms: stateDirPerms, isDir: true, posixMode: 0o700, env: params.env, }), }); } else if (stateDirPerms.groupReadable || stateDirPerms.worldReadable) { findings.push({ checkId: "fs.state_dir.perms_readable", severity: "warn", title: "State dir is readable by others", detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; consider restricting to 700.`, remediation: formatPermissionRemediation({ targetPath: params.stateDir, perms: stateDirPerms, isDir: true, posixMode: 0o700, env: params.env, }), }); } } const configPerms = await inspectPathPermissions(params.configPath, { env: params.env, platform: params.platform, exec: params.execIcacls, }); if (configPerms.ok) { if (configPerms.isSymlink) { findings.push({ checkId: "fs.config.symlink", severity: "warn", title: "Config file is a symlink", detail: `${params.configPath} is a symlink; make sure you trust its target.`, }); } if (configPerms.worldWritable || configPerms.groupWritable) { findings.push({ checkId: "fs.config.perms_writable", severity: "critical", title: "Config file is writable by others", detail: `${formatPermissionDetail(params.configPath, configPerms)}; another user could change gateway/auth/tool policies.`, remediation: formatPermissionRemediation({ targetPath: params.configPath, perms: configPerms, isDir: false, posixMode: 0o600, env: params.env, }), }); } else if (configPerms.worldReadable) { findings.push({ checkId: "fs.config.perms_world_readable", severity: "critical", title: "Config file is world-readable", detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`, remediation: formatPermissionRemediation({ targetPath: params.configPath, perms: configPerms, isDir: false, posixMode: 0o600, env: params.env, }), }); } else if (configPerms.groupReadable) { findings.push({ checkId: "fs.config.perms_group_readable", severity: "warn", title: "Config file is group-readable", detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`, remediation: formatPermissionRemediation({ targetPath: params.configPath, perms: configPerms, isDir: false, posixMode: 0o600, env: params.env, }), }); } } return findings; } function collectGatewayConfigFindings( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, ): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env }); const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false; const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) ? cfg.gateway.trustedProxies : []; const hasToken = typeof auth.token === "string" && auth.token.trim().length > 0; const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0; const hasSharedSecret = (auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword); const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve"; const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth; // HTTP /tools/invoke is intended for narrow automation, not session orchestration/admin operations. // If operators opt-in to re-enabling these tools over HTTP, warn loudly so the choice is explicit. const gatewayToolsAllowRaw = Array.isArray(cfg.gateway?.tools?.allow) ? cfg.gateway?.tools?.allow : []; const gatewayToolsAllow = new Set( gatewayToolsAllowRaw .map((v) => (typeof v === "string" ? v.trim().toLowerCase() : "")) .filter(Boolean), ); const reenabledOverHttp = DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) => gatewayToolsAllow.has(name), ); if (reenabledOverHttp.length > 0) { const extraRisk = bind !== "loopback" || tailscaleMode === "funnel"; findings.push({ checkId: "gateway.tools_invoke_http.dangerous_allow", severity: extraRisk ? "critical" : "warn", title: "Gateway HTTP /tools/invoke re-enables dangerous tools", detail: `gateway.tools.allow includes ${reenabledOverHttp.join(", ")} which removes them from the default HTTP deny list. ` + "This can allow remote session spawning / control-plane actions via HTTP and increases RCE blast radius if the gateway is reachable.", remediation: "Remove these entries from gateway.tools.allow (recommended). " + "If you keep them enabled, keep gateway.bind loopback-only (or tailnet-only), restrict network exposure, and treat the gateway token/password as full-admin.", }); } if (bind !== "loopback" && !hasSharedSecret && auth.mode !== "trusted-proxy") { findings.push({ checkId: "gateway.bind_no_auth", severity: "critical", title: "Gateway binds beyond loopback without auth", detail: `gateway.bind="${bind}" but no gateway.auth token/password is configured.`, remediation: `Set gateway.auth (token recommended) or bind to loopback.`, }); } if (bind === "loopback" && controlUiEnabled && trustedProxies.length === 0) { findings.push({ checkId: "gateway.trusted_proxies_missing", severity: "warn", title: "Reverse proxy headers are not trusted", detail: "gateway.bind is loopback and gateway.trustedProxies is empty. " + "If you expose the Control UI through a reverse proxy, configure trusted proxies " + "so local-client checks cannot be spoofed.", remediation: "Set gateway.trustedProxies to your proxy IPs or keep the Control UI local-only.", }); } if (bind === "loopback" && controlUiEnabled && !hasGatewayAuth) { findings.push({ checkId: "gateway.loopback_no_auth", severity: "critical", title: "Gateway auth missing on loopback", detail: "gateway.bind is loopback but no gateway auth secret is configured. " + "If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.", remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.", }); } if (tailscaleMode === "funnel") { findings.push({ checkId: "gateway.tailscale_funnel", severity: "critical", title: "Tailscale Funnel exposure enabled", detail: `gateway.tailscale.mode="funnel" exposes the Gateway publicly; keep auth strict and treat it as internet-facing.`, remediation: `Prefer tailscale.mode="serve" (tailnet-only) or set tailscale.mode="off".`, }); } else if (tailscaleMode === "serve") { findings.push({ checkId: "gateway.tailscale_serve", severity: "info", title: "Tailscale Serve exposure enabled", detail: `gateway.tailscale.mode="serve" exposes the Gateway to your tailnet (loopback behind Tailscale).`, }); } if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { findings.push({ checkId: "gateway.control_ui.insecure_auth", severity: "critical", title: "Control UI allows insecure HTTP auth", detail: "gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.", remediation: "Disable it or switch to HTTPS (Tailscale Serve) or localhost.", }); } if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) { findings.push({ checkId: "gateway.control_ui.device_auth_disabled", severity: "critical", title: "DANGEROUS: Control UI device auth disabled", detail: "gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.", remediation: "Disable it unless you are in a short-lived break-glass scenario.", }); } const token = typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null; if (auth.mode === "token" && token && token.length < 24) { findings.push({ checkId: "gateway.token_too_short", severity: "warn", title: "Gateway token looks short", detail: `gateway auth token is ${token.length} chars; prefer a long random token.`, }); } if (auth.mode === "trusted-proxy") { const trustedProxies = cfg.gateway?.trustedProxies ?? []; const trustedProxyConfig = cfg.gateway?.auth?.trustedProxy; findings.push({ checkId: "gateway.trusted_proxy_auth", severity: "critical", title: "Trusted-proxy auth mode enabled", detail: 'gateway.auth.mode="trusted-proxy" delegates authentication to a reverse proxy. ' + "Ensure your proxy (Pomerium, Caddy, nginx) handles auth correctly and that gateway.trustedProxies " + "only contains IPs of your actual proxy servers.", remediation: "Verify: (1) Your proxy terminates TLS and authenticates users. " + "(2) gateway.trustedProxies is restricted to proxy IPs only. " + "(3) Direct access to the Gateway port is blocked by firewall. " + "See /gateway/trusted-proxy-auth for setup guidance.", }); if (trustedProxies.length === 0) { findings.push({ checkId: "gateway.trusted_proxy_no_proxies", severity: "critical", title: "Trusted-proxy auth enabled but no trusted proxies configured", detail: 'gateway.auth.mode="trusted-proxy" but gateway.trustedProxies is empty. ' + "All requests will be rejected.", remediation: "Set gateway.trustedProxies to the IP(s) of your reverse proxy.", }); } if (!trustedProxyConfig?.userHeader) { findings.push({ checkId: "gateway.trusted_proxy_no_user_header", severity: "critical", title: "Trusted-proxy auth missing userHeader config", detail: 'gateway.auth.mode="trusted-proxy" but gateway.auth.trustedProxy.userHeader is not configured.', remediation: "Set gateway.auth.trustedProxy.userHeader to the header name your proxy uses " + '(e.g., "x-forwarded-user", "x-pomerium-claim-email").', }); } const allowUsers = trustedProxyConfig?.allowUsers ?? []; if (allowUsers.length === 0) { findings.push({ checkId: "gateway.trusted_proxy_no_allowlist", severity: "warn", title: "Trusted-proxy auth allows all authenticated users", detail: "gateway.auth.trustedProxy.allowUsers is empty, so any user authenticated by your proxy can access the Gateway.", remediation: "Consider setting gateway.auth.trustedProxy.allowUsers to restrict access to specific users " + '(e.g., ["nick@example.com"]).', }); } } if (bind !== "loopback" && auth.mode !== "trusted-proxy" && !cfg.gateway?.auth?.rateLimit) { findings.push({ checkId: "gateway.auth_no_rate_limit", severity: "warn", title: "No auth rate limiting configured", detail: "gateway.bind is not loopback but no gateway.auth.rateLimit is configured. " + "Without rate limiting, brute-force auth attacks are not mitigated.", remediation: "Set gateway.auth.rateLimit (e.g. { maxAttempts: 10, windowMs: 60000, lockoutMs: 300000 }).", }); } return findings; } function collectBrowserControlFindings( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, ): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; let resolved: ReturnType; try { resolved = resolveBrowserConfig(cfg.browser, cfg); } catch (err) { findings.push({ checkId: "browser.control_invalid_config", severity: "warn", title: "Browser control config looks invalid", detail: String(err), remediation: `Fix browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("openclaw security audit --deep")}".`, }); return findings; } if (!resolved.enabled) { return findings; } const browserAuth = resolveBrowserControlAuth(cfg, env); if (!browserAuth.token && !browserAuth.password) { findings.push({ checkId: "browser.control_no_auth", severity: "critical", title: "Browser control has no auth", detail: "Browser control HTTP routes are enabled but no gateway.auth token/password is configured. " + "Any local process (or SSRF to loopback) can call browser control endpoints.", remediation: "Set gateway.auth.token (recommended) or gateway.auth.password so browser control HTTP routes require authentication. Restarting the gateway will auto-generate gateway.auth.token when browser control is enabled.", }); } for (const name of Object.keys(resolved.profiles)) { const profile = resolveProfile(resolved, name); if (!profile || profile.cdpIsLoopback) { continue; } let url: URL; try { url = new URL(profile.cdpUrl); } catch { continue; } if (url.protocol === "http:") { findings.push({ checkId: "browser.remote_cdp_http", severity: "warn", title: "Remote CDP uses HTTP", detail: `browser profile "${name}" uses http CDP (${profile.cdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`, remediation: `Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP.`, }); } } return findings; } function collectLoggingFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const redact = cfg.logging?.redactSensitive; if (redact !== "off") { return []; } return [ { checkId: "logging.redact_off", severity: "warn", title: "Tool summary redaction is disabled", detail: `logging.redactSensitive="off" can leak secrets into logs and status output.`, remediation: `Set logging.redactSensitive="tools".`, }, ]; } function collectElevatedFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const enabled = cfg.tools?.elevated?.enabled; const allowFrom = cfg.tools?.elevated?.allowFrom ?? {}; const anyAllowFromKeys = Object.keys(allowFrom).length > 0; if (enabled === false) { return findings; } if (!anyAllowFromKeys) { return findings; } for (const [provider, list] of Object.entries(allowFrom)) { const normalized = normalizeAllowFromList(list); if (normalized.includes("*")) { findings.push({ checkId: `tools.elevated.allowFrom.${provider}.wildcard`, severity: "critical", title: "Elevated exec allowlist contains wildcard", detail: `tools.elevated.allowFrom.${provider} includes "*" which effectively approves everyone on that channel for elevated mode.`, }); } else if (normalized.length > 25) { findings.push({ checkId: `tools.elevated.allowFrom.${provider}.large`, severity: "warn", title: "Elevated exec allowlist is large", detail: `tools.elevated.allowFrom.${provider} has ${normalized.length} entries; consider tightening elevated access.`, }); } } return findings; } async function maybeProbeGateway(params: { cfg: OpenClawConfig; timeoutMs: number; probe: typeof probeGateway; }): Promise { const connection = buildGatewayConnectionDetails({ config: params.cfg }); const url = connection.url; const isRemoteMode = params.cfg.gateway?.mode === "remote"; const remoteUrlRaw = typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url.trim() : ""; const remoteUrlMissing = isRemoteMode && !remoteUrlRaw; const auth = !isRemoteMode || remoteUrlMissing ? resolveGatewayProbeAuth({ cfg: params.cfg, mode: "local" }) : resolveGatewayProbeAuth({ cfg: params.cfg, mode: "remote" }); const res = await params.probe({ url, auth, timeoutMs: params.timeoutMs }).catch((err) => ({ ok: false, url, connectLatencyMs: null, error: String(err), close: null, health: null, status: null, presence: null, configSnapshot: null, })); return { gateway: { attempted: true, url, ok: res.ok, error: res.ok ? null : res.error, close: res.close ? { code: res.close.code, reason: res.close.reason } : null, }, }; } export async function runSecurityAudit(opts: SecurityAuditOptions): Promise { const findings: SecurityAuditFinding[] = []; const cfg = opts.config; const env = opts.env ?? process.env; const platform = opts.platform ?? process.platform; const execIcacls = opts.execIcacls; const stateDir = opts.stateDir ?? resolveStateDir(env); const configPath = opts.configPath ?? resolveConfigPath(env, stateDir); findings.push(...collectAttackSurfaceSummaryFindings(cfg)); findings.push(...collectSyncedFolderFindings({ stateDir, configPath })); findings.push(...collectGatewayConfigFindings(cfg, env)); findings.push(...collectBrowserControlFindings(cfg, env)); findings.push(...collectLoggingFindings(cfg)); findings.push(...collectElevatedFindings(cfg)); 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)); findings.push(...collectModelHygieneFindings(cfg)); findings.push(...collectSmallModelRiskFindings({ cfg, env })); findings.push(...collectExposureMatrixFindings(cfg)); const configSnapshot = opts.includeFilesystem !== false ? await readConfigSnapshotForAudit({ env, configPath }).catch(() => null) : null; if (opts.includeFilesystem !== false) { findings.push( ...(await collectFilesystemFindings({ stateDir, configPath, env, platform, execIcacls, })), ); if (configSnapshot) { findings.push( ...(await collectIncludeFilePermFindings({ configSnapshot, env, platform, execIcacls })), ); } findings.push( ...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir, platform, execIcacls })), ); findings.push(...(await collectPluginsTrustFindings({ cfg, stateDir }))); if (opts.deep === true) { findings.push(...(await collectPluginsCodeSafetyFindings({ stateDir }))); findings.push(...(await collectInstalledSkillsCodeSafetyFindings({ cfg, stateDir }))); } } if (opts.includeChannelSecurity !== false) { const plugins = opts.plugins ?? listChannelPlugins(); findings.push(...(await collectChannelSecurityFindings({ cfg, plugins }))); } const deep = opts.deep === true ? await maybeProbeGateway({ cfg, timeoutMs: Math.max(250, opts.deepTimeoutMs ?? 5000), probe: opts.probeGatewayFn ?? probeGateway, }) : undefined; if (deep?.gateway?.attempted && !deep.gateway.ok) { findings.push({ checkId: "gateway.probe_failed", severity: "warn", title: "Gateway probe failed (deep)", detail: deep.gateway.error ?? "gateway unreachable", remediation: `Run "${formatCliCommand("openclaw status --all")}" to debug connectivity/auth, then re-run "${formatCliCommand("openclaw security audit --deep")}".`, }); } const summary = countBySeverity(findings); return { ts: Date.now(), summary, findings, deep }; }