| import type { OpenClawConfig } from "../config/config.js"; |
| import type { ExecFn } from "./windows-acl.js"; |
| 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 { 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"; |
|
|
| 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; |
| |
| stateDir?: string; |
| |
| configPath?: string; |
| |
| deepTimeoutMs?: number; |
| |
| plugins?: ReturnType<typeof listChannelPlugins>; |
| |
| probeGatewayFn?: typeof probeGateway; |
| |
| 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<string | number> | 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<SecurityAuditFinding[]> { |
| 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; |
|
|
| |
| |
| 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<typeof resolveBrowserConfig>; |
| 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<SecurityAuditReport["deep"]> { |
| 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<SecurityAuditReport> { |
| 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 }; |
| } |
|
|