Spaces:
Paused
Paused
| import type { ChannelId } from "../channels/plugins/types.js"; | |
| import type { OpenClawConfig } from "../config/config.js"; | |
| import type { ExecFn } from "./windows-acl.js"; | |
| import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; | |
| import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; | |
| import { listChannelPlugins } from "../channels/plugins/index.js"; | |
| import { formatCliCommand } from "../cli/command-format.js"; | |
| import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; | |
| import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; | |
| import { resolveGatewayAuth } from "../gateway/auth.js"; | |
| import { buildGatewayConnectionDetails } from "../gateway/call.js"; | |
| import { probeGateway } from "../gateway/probe.js"; | |
| import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; | |
| import { | |
| collectAttackSurfaceSummaryFindings, | |
| collectExposureMatrixFindings, | |
| collectHooksHardeningFindings, | |
| collectIncludeFilePermFindings, | |
| collectModelHygieneFindings, | |
| collectSmallModelRiskFindings, | |
| collectPluginsTrustFindings, | |
| collectSecretsInConfigFindings, | |
| collectStateDeepFilesystemFindings, | |
| collectSyncedFolderFindings, | |
| readConfigSnapshotForAudit, | |
| } from "./audit-extra.js"; | |
| import { | |
| formatPermissionDetail, | |
| formatPermissionRemediation, | |
| inspectPathPermissions, | |
| } from "./audit-fs.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<typeof listChannelPlugins>; | |
| /** 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<string | number> | undefined | null): string[] { | |
| if (!Array.isArray(list)) { | |
| return []; | |
| } | |
| return list.map((v) => String(v).trim()).filter(Boolean); | |
| } | |
| function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity { | |
| const s = message.toLowerCase(); | |
| if ( | |
| s.includes("dms: open") || | |
| s.includes('grouppolicy="open"') || | |
| s.includes('dmpolicy="open"') | |
| ) { | |
| return "critical"; | |
| } | |
| if (s.includes("allows any") || s.includes("anyone can dm") || s.includes("public")) { | |
| return "critical"; | |
| } | |
| if (s.includes("locked") || s.includes("disabled")) { | |
| return "info"; | |
| } | |
| return "warn"; | |
| } | |
| 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; | |
| if (bind !== "loopback" && !hasSharedSecret) { | |
| 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.`, | |
| }); | |
| } | |
| return findings; | |
| } | |
| function collectBrowserControlFindings(cfg: OpenClawConfig): 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; | |
| } | |
| 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 collectChannelSecurityFindings(params: { | |
| cfg: OpenClawConfig; | |
| plugins: ReturnType<typeof listChannelPlugins>; | |
| }): Promise<SecurityAuditFinding[]> { | |
| const findings: SecurityAuditFinding[] = []; | |
| const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => { | |
| if (value === true) { | |
| return true; | |
| } | |
| if (value === false) { | |
| return false; | |
| } | |
| if (value === "auto") { | |
| return "auto"; | |
| } | |
| return undefined; | |
| }; | |
| const warnDmPolicy = async (input: { | |
| label: string; | |
| provider: ChannelId; | |
| dmPolicy: string; | |
| allowFrom?: Array<string | number> | null; | |
| policyPath?: string; | |
| allowFromPath: string; | |
| normalizeEntry?: (raw: string) => string; | |
| }) => { | |
| const policyPath = input.policyPath ?? `${input.allowFromPath}policy`; | |
| const configAllowFrom = normalizeAllowFromList(input.allowFrom); | |
| const hasWildcard = configAllowFrom.includes("*"); | |
| const dmScope = params.cfg.session?.dmScope ?? "main"; | |
| const storeAllowFrom = await readChannelAllowFromStore(input.provider).catch(() => []); | |
| const normalizeEntry = input.normalizeEntry ?? ((value: string) => value); | |
| const normalizedCfg = configAllowFrom | |
| .filter((value) => value !== "*") | |
| .map((value) => normalizeEntry(value)) | |
| .map((value) => value.trim()) | |
| .filter(Boolean); | |
| const normalizedStore = storeAllowFrom | |
| .map((value) => normalizeEntry(value)) | |
| .map((value) => value.trim()) | |
| .filter(Boolean); | |
| const allowCount = Array.from(new Set([...normalizedCfg, ...normalizedStore])).length; | |
| const isMultiUserDm = hasWildcard || allowCount > 1; | |
| if (input.dmPolicy === "open") { | |
| const allowFromKey = `${input.allowFromPath}allowFrom`; | |
| findings.push({ | |
| checkId: `channels.${input.provider}.dm.open`, | |
| severity: "critical", | |
| title: `${input.label} DMs are open`, | |
| detail: `${policyPath}="open" allows anyone to DM the bot.`, | |
| remediation: `Use pairing/allowlist; if you really need open DMs, ensure ${allowFromKey} includes "*".`, | |
| }); | |
| if (!hasWildcard) { | |
| findings.push({ | |
| checkId: `channels.${input.provider}.dm.open_invalid`, | |
| severity: "warn", | |
| title: `${input.label} DM config looks inconsistent`, | |
| detail: `"open" requires ${allowFromKey} to include "*".`, | |
| }); | |
| } | |
| } | |
| if (input.dmPolicy === "disabled") { | |
| findings.push({ | |
| checkId: `channels.${input.provider}.dm.disabled`, | |
| severity: "info", | |
| title: `${input.label} DMs are disabled`, | |
| detail: `${policyPath}="disabled" ignores inbound DMs.`, | |
| }); | |
| return; | |
| } | |
| if (dmScope === "main" && isMultiUserDm) { | |
| findings.push({ | |
| checkId: `channels.${input.provider}.dm.scope_main_multiuser`, | |
| severity: "warn", | |
| title: `${input.label} DMs share the main session`, | |
| detail: | |
| "Multiple DM senders currently share the main session, which can leak context across users.", | |
| remediation: | |
| 'Set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate DM sessions per sender.', | |
| }); | |
| } | |
| }; | |
| for (const plugin of params.plugins) { | |
| if (!plugin.security) { | |
| continue; | |
| } | |
| const accountIds = plugin.config.listAccountIds(params.cfg); | |
| const defaultAccountId = resolveChannelDefaultAccountId({ | |
| plugin, | |
| cfg: params.cfg, | |
| accountIds, | |
| }); | |
| const account = plugin.config.resolveAccount(params.cfg, defaultAccountId); | |
| const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, params.cfg) : true; | |
| if (!enabled) { | |
| continue; | |
| } | |
| const configured = plugin.config.isConfigured | |
| ? await plugin.config.isConfigured(account, params.cfg) | |
| : true; | |
| if (!configured) { | |
| continue; | |
| } | |
| if (plugin.id === "discord") { | |
| const discordCfg = | |
| (account as { config?: Record<string, unknown> } | null)?.config ?? | |
| ({} as Record<string, unknown>); | |
| const nativeEnabled = resolveNativeCommandsEnabled({ | |
| providerId: "discord", | |
| providerSetting: coerceNativeSetting( | |
| (discordCfg.commands as { native?: unknown } | undefined)?.native, | |
| ), | |
| globalSetting: params.cfg.commands?.native, | |
| }); | |
| const nativeSkillsEnabled = resolveNativeSkillsEnabled({ | |
| providerId: "discord", | |
| providerSetting: coerceNativeSetting( | |
| (discordCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills, | |
| ), | |
| globalSetting: params.cfg.commands?.nativeSkills, | |
| }); | |
| const slashEnabled = nativeEnabled || nativeSkillsEnabled; | |
| if (slashEnabled) { | |
| const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy; | |
| const groupPolicy = | |
| (discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist"; | |
| const guildEntries = (discordCfg.guilds as Record<string, unknown> | undefined) ?? {}; | |
| const guildsConfigured = Object.keys(guildEntries).length > 0; | |
| const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => { | |
| if (!guild || typeof guild !== "object") { | |
| return false; | |
| } | |
| const g = guild as Record<string, unknown>; | |
| if (Array.isArray(g.users) && g.users.length > 0) { | |
| return true; | |
| } | |
| const channels = g.channels; | |
| if (!channels || typeof channels !== "object") { | |
| return false; | |
| } | |
| return Object.values(channels as Record<string, unknown>).some((channel) => { | |
| if (!channel || typeof channel !== "object") { | |
| return false; | |
| } | |
| const c = channel as Record<string, unknown>; | |
| return Array.isArray(c.users) && c.users.length > 0; | |
| }); | |
| }); | |
| const dmAllowFromRaw = (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom; | |
| const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : []; | |
| const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []); | |
| const ownerAllowFromConfigured = | |
| normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0; | |
| const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; | |
| if ( | |
| !useAccessGroups && | |
| groupPolicy !== "disabled" && | |
| guildsConfigured && | |
| !hasAnyUserAllowlist | |
| ) { | |
| findings.push({ | |
| checkId: "channels.discord.commands.native.unrestricted", | |
| severity: "critical", | |
| title: "Discord slash commands are unrestricted", | |
| detail: | |
| "commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.", | |
| remediation: | |
| "Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).", | |
| }); | |
| } else if ( | |
| useAccessGroups && | |
| groupPolicy !== "disabled" && | |
| guildsConfigured && | |
| !ownerAllowFromConfigured && | |
| !hasAnyUserAllowlist | |
| ) { | |
| findings.push({ | |
| checkId: "channels.discord.commands.native.no_allowlists", | |
| severity: "warn", | |
| title: "Discord slash commands have no allowlists", | |
| detail: | |
| "Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.", | |
| remediation: | |
| "Add your user id to channels.discord.dm.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.", | |
| }); | |
| } | |
| } | |
| } | |
| if (plugin.id === "slack") { | |
| const slackCfg = | |
| (account as { config?: Record<string, unknown>; dm?: Record<string, unknown> } | null) | |
| ?.config ?? ({} as Record<string, unknown>); | |
| const nativeEnabled = resolveNativeCommandsEnabled({ | |
| providerId: "slack", | |
| providerSetting: coerceNativeSetting( | |
| (slackCfg.commands as { native?: unknown } | undefined)?.native, | |
| ), | |
| globalSetting: params.cfg.commands?.native, | |
| }); | |
| const nativeSkillsEnabled = resolveNativeSkillsEnabled({ | |
| providerId: "slack", | |
| providerSetting: coerceNativeSetting( | |
| (slackCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills, | |
| ), | |
| globalSetting: params.cfg.commands?.nativeSkills, | |
| }); | |
| const slashCommandEnabled = | |
| nativeEnabled || | |
| nativeSkillsEnabled || | |
| (slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true; | |
| if (slashCommandEnabled) { | |
| const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; | |
| if (!useAccessGroups) { | |
| findings.push({ | |
| checkId: "channels.slack.commands.slash.useAccessGroups_off", | |
| severity: "critical", | |
| title: "Slack slash commands bypass access groups", | |
| detail: | |
| "Slack slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted /… command execution from channels/users you didn't explicitly authorize.", | |
| remediation: "Set commands.useAccessGroups=true (recommended).", | |
| }); | |
| } else { | |
| const dmAllowFromRaw = (account as { dm?: { allowFrom?: unknown } } | null)?.dm | |
| ?.allowFrom; | |
| const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : []; | |
| const storeAllowFrom = await readChannelAllowFromStore("slack").catch(() => []); | |
| const ownerAllowFromConfigured = | |
| normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0; | |
| const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {}; | |
| const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => { | |
| if (!value || typeof value !== "object") { | |
| return false; | |
| } | |
| const channel = value as Record<string, unknown>; | |
| return Array.isArray(channel.users) && channel.users.length > 0; | |
| }); | |
| if (!ownerAllowFromConfigured && !hasAnyChannelUsersAllowlist) { | |
| findings.push({ | |
| checkId: "channels.slack.commands.slash.no_allowlists", | |
| severity: "warn", | |
| title: "Slack slash commands have no allowlists", | |
| detail: | |
| "Slack slash/native commands are enabled, but neither an owner allowFrom list nor any channels.<id>.users allowlist is configured; /… commands will be rejected for everyone.", | |
| remediation: | |
| "Approve yourself via pairing (recommended), or set channels.slack.dm.allowFrom and/or channels.slack.channels.<id>.users.", | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| const dmPolicy = plugin.security.resolveDmPolicy?.({ | |
| cfg: params.cfg, | |
| accountId: defaultAccountId, | |
| account, | |
| }); | |
| if (dmPolicy) { | |
| await warnDmPolicy({ | |
| label: plugin.meta.label ?? plugin.id, | |
| provider: plugin.id, | |
| dmPolicy: dmPolicy.policy, | |
| allowFrom: dmPolicy.allowFrom, | |
| policyPath: dmPolicy.policyPath, | |
| allowFromPath: dmPolicy.allowFromPath, | |
| normalizeEntry: dmPolicy.normalizeEntry, | |
| }); | |
| } | |
| if (plugin.security.collectWarnings) { | |
| const warnings = await plugin.security.collectWarnings({ | |
| cfg: params.cfg, | |
| accountId: defaultAccountId, | |
| account, | |
| }); | |
| for (const message of warnings ?? []) { | |
| const trimmed = String(message).trim(); | |
| if (!trimmed) { | |
| continue; | |
| } | |
| findings.push({ | |
| checkId: `channels.${plugin.id}.warning.${findings.length + 1}`, | |
| severity: classifyChannelWarningSeverity(trimmed), | |
| title: `${plugin.meta.label ?? plugin.id} security warning`, | |
| detail: trimmed.replace(/^-\s*/, ""), | |
| }); | |
| } | |
| } | |
| if (plugin.id === "telegram") { | |
| const allowTextCommands = params.cfg.commands?.text !== false; | |
| if (!allowTextCommands) { | |
| continue; | |
| } | |
| const telegramCfg = | |
| (account as { config?: Record<string, unknown> } | null)?.config ?? | |
| ({} as Record<string, unknown>); | |
| const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy; | |
| const groupPolicy = | |
| (telegramCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist"; | |
| const groups = telegramCfg.groups as Record<string, unknown> | undefined; | |
| const groupsConfigured = Boolean(groups) && Object.keys(groups ?? {}).length > 0; | |
| const groupAccessPossible = | |
| groupPolicy === "open" || (groupPolicy === "allowlist" && groupsConfigured); | |
| if (!groupAccessPossible) { | |
| continue; | |
| } | |
| const storeAllowFrom = await readChannelAllowFromStore("telegram").catch(() => []); | |
| const storeHasWildcard = storeAllowFrom.some((v) => String(v).trim() === "*"); | |
| const groupAllowFrom = Array.isArray(telegramCfg.groupAllowFrom) | |
| ? telegramCfg.groupAllowFrom | |
| : []; | |
| const groupAllowFromHasWildcard = groupAllowFrom.some((v) => String(v).trim() === "*"); | |
| const anyGroupOverride = Boolean( | |
| groups && | |
| Object.values(groups).some((value) => { | |
| if (!value || typeof value !== "object") { | |
| return false; | |
| } | |
| const group = value as Record<string, unknown>; | |
| const allowFrom = Array.isArray(group.allowFrom) ? group.allowFrom : []; | |
| if (allowFrom.length > 0) { | |
| return true; | |
| } | |
| const topics = group.topics; | |
| if (!topics || typeof topics !== "object") { | |
| return false; | |
| } | |
| return Object.values(topics as Record<string, unknown>).some((topicValue) => { | |
| if (!topicValue || typeof topicValue !== "object") { | |
| return false; | |
| } | |
| const topic = topicValue as Record<string, unknown>; | |
| const topicAllow = Array.isArray(topic.allowFrom) ? topic.allowFrom : []; | |
| return topicAllow.length > 0; | |
| }); | |
| }), | |
| ); | |
| const hasAnySenderAllowlist = | |
| storeAllowFrom.length > 0 || groupAllowFrom.length > 0 || anyGroupOverride; | |
| if (storeHasWildcard || groupAllowFromHasWildcard) { | |
| findings.push({ | |
| checkId: "channels.telegram.groups.allowFrom.wildcard", | |
| severity: "critical", | |
| title: "Telegram group allowlist contains wildcard", | |
| detail: | |
| 'Telegram group sender allowlist contains "*", which allows any group member to run /… commands and control directives.', | |
| remediation: | |
| 'Remove "*" from channels.telegram.groupAllowFrom and pairing store; prefer explicit user ids/usernames.', | |
| }); | |
| continue; | |
| } | |
| if (!hasAnySenderAllowlist) { | |
| const providerSetting = (telegramCfg.commands as { nativeSkills?: unknown } | undefined) | |
| ?.nativeSkills as any; | |
| const skillsEnabled = resolveNativeSkillsEnabled({ | |
| providerId: "telegram", | |
| providerSetting, | |
| globalSetting: params.cfg.commands?.nativeSkills, | |
| }); | |
| findings.push({ | |
| checkId: "channels.telegram.groups.allowFrom.missing", | |
| severity: "critical", | |
| title: "Telegram group commands have no sender allowlist", | |
| detail: | |
| `Telegram group access is enabled but no sender allowlist is configured; this allows any group member to invoke /… commands` + | |
| (skillsEnabled ? " (including skill commands)." : "."), | |
| remediation: | |
| "Approve yourself via pairing (recommended), or set channels.telegram.groupAllowFrom (or per-group groups.<id>.allowFrom).", | |
| }); | |
| } | |
| } | |
| } | |
| 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 resolveAuth = (mode: "local" | "remote") => { | |
| const authToken = params.cfg.gateway?.auth?.token; | |
| const authPassword = params.cfg.gateway?.auth?.password; | |
| const remote = params.cfg.gateway?.remote; | |
| const token = | |
| mode === "remote" | |
| ? typeof remote?.token === "string" && remote.token.trim() | |
| ? remote.token.trim() | |
| : undefined | |
| : process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || | |
| (typeof authToken === "string" && authToken.trim() ? authToken.trim() : undefined); | |
| const password = | |
| process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || | |
| (mode === "remote" | |
| ? typeof remote?.password === "string" && remote.password.trim() | |
| ? remote.password.trim() | |
| : undefined | |
| : typeof authPassword === "string" && authPassword.trim() | |
| ? authPassword.trim() | |
| : undefined); | |
| return { token, password }; | |
| }; | |
| const auth = !isRemoteMode || remoteUrlMissing ? resolveAuth("local") : resolveAuth("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)); | |
| findings.push(...collectLoggingFindings(cfg)); | |
| findings.push(...collectElevatedFindings(cfg)); | |
| findings.push(...collectHooksHardeningFindings(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.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 }; | |
| } | |