import { listChannelPlugins } from "../channels/plugins/index.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveBrowserConfig } from "../browser/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { formatCliCommand } from "../cli/command-format.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { probeGateway } from "../gateway/probe.js"; import { collectAttackSurfaceSummaryFindings, collectExposureMatrixFindings, collectHooksHardeningFindings, collectIncludeFilePermFindings, collectModelHygieneFindings, collectSmallModelRiskFindings, collectPluginsTrustFindings, collectSecretsInConfigFindings, collectStateDeepFilesystemFindings, collectSyncedFolderFindings, readConfigSnapshotForAudit, } from "./audit-extra.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; import { formatOctal, isGroupReadable, isGroupWritable, isWorldReadable, isWorldWritable, modeBits, safeStat, } 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: ClawdbotConfig; 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; }; 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); } 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; }): Promise { const findings: SecurityAuditFinding[] = []; const stateDirStat = await safeStat(params.stateDir); if (stateDirStat.ok) { const bits = modeBits(stateDirStat.mode); if (stateDirStat.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 (isWorldWritable(bits)) { findings.push({ checkId: "fs.state_dir.perms_world_writable", severity: "critical", title: "State dir is world-writable", detail: `${params.stateDir} mode=${formatOctal(bits)}; other users can write into your Clawdbot state.`, remediation: `chmod 700 ${params.stateDir}`, }); } else if (isGroupWritable(bits)) { findings.push({ checkId: "fs.state_dir.perms_group_writable", severity: "warn", title: "State dir is group-writable", detail: `${params.stateDir} mode=${formatOctal(bits)}; group users can write into your Clawdbot state.`, remediation: `chmod 700 ${params.stateDir}`, }); } else if (isGroupReadable(bits) || isWorldReadable(bits)) { findings.push({ checkId: "fs.state_dir.perms_readable", severity: "warn", title: "State dir is readable by others", detail: `${params.stateDir} mode=${formatOctal(bits)}; consider restricting to 700.`, remediation: `chmod 700 ${params.stateDir}`, }); } } const configStat = await safeStat(params.configPath); if (configStat.ok) { const bits = modeBits(configStat.mode); if (configStat.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 (isWorldWritable(bits) || isGroupWritable(bits)) { findings.push({ checkId: "fs.config.perms_writable", severity: "critical", title: "Config file is writable by others", detail: `${params.configPath} mode=${formatOctal(bits)}; another user could change gateway/auth/tool policies.`, remediation: `chmod 600 ${params.configPath}`, }); } else if (isWorldReadable(bits)) { findings.push({ checkId: "fs.config.perms_world_readable", severity: "critical", title: "Config file is world-readable", detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`, remediation: `chmod 600 ${params.configPath}`, }); } else if (isGroupReadable(bits)) { findings.push({ checkId: "fs.config.perms_group_readable", severity: "warn", title: "Config file is group-readable", detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`, remediation: `chmod 600 ${params.configPath}`, }); } } return findings; } function collectGatewayConfigFindings(cfg: ClawdbotConfig): 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 }); if (bind !== "loopback" && auth.mode === "none") { 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 (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).`, }); } 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 isLoopbackClientHost(hostname: string): boolean { const h = hostname.trim().toLowerCase(); return h === "localhost" || h === "127.0.0.1" || h === "::1"; } function collectBrowserControlFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; let resolved: ReturnType; try { resolved = resolveBrowserConfig(cfg.browser); } catch (err) { findings.push({ checkId: "browser.control_invalid_config", severity: "warn", title: "Browser control config looks invalid", detail: String(err), remediation: `Fix browser.controlUrl/browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("clawdbot security audit --deep")}".`, }); return findings; } if (!resolved.enabled) return findings; const url = new URL(resolved.controlUrl); const isLoopback = isLoopbackClientHost(url.hostname); const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim(); const controlToken = (envToken || resolved.controlToken)?.trim() || null; if (!isLoopback) { if (!controlToken) { findings.push({ checkId: "browser.control_remote_no_token", severity: "critical", title: "Remote browser control is missing an auth token", detail: `browser.controlUrl is non-loopback (${resolved.controlUrl}) but no browser.controlToken (or CLAWDBOT_BROWSER_CONTROL_TOKEN) is configured.`, remediation: "Set browser.controlToken (or export CLAWDBOT_BROWSER_CONTROL_TOKEN) and prefer serving over Tailscale Serve or HTTPS reverse proxy.", }); } if (url.protocol === "http:") { findings.push({ checkId: "browser.control_remote_http", severity: "warn", title: "Remote browser control uses HTTP", detail: `browser.controlUrl=${resolved.controlUrl} is http; this is OK only if it's tailnet-only (Tailscale) or behind another encrypted tunnel.`, remediation: `Prefer HTTPS termination (Tailscale Serve) and keep the endpoint tailnet-only.`, }); } if (controlToken && controlToken.length < 24) { findings.push({ checkId: "browser.control_token_too_short", severity: "warn", title: "Browser control token looks short", detail: `browser control token is ${controlToken.length} chars; prefer a long random token.`, }); } const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const gatewayAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode }); const gatewayToken = gatewayAuth.mode === "token" && typeof gatewayAuth.token === "string" && gatewayAuth.token.trim() ? gatewayAuth.token.trim() : null; if (controlToken && gatewayToken && controlToken === gatewayToken) { findings.push({ checkId: "browser.control_token_reuse_gateway_token", severity: "warn", title: "Browser control token reuses the Gateway token", detail: `browser.controlToken matches gateway.auth token; compromise of browser control expands blast radius to the Gateway API.`, remediation: `Use a separate browser.controlToken dedicated to browser control.`, }); } } return findings; } function collectLoggingFindings(cfg: ClawdbotConfig): 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: ClawdbotConfig): 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: ClawdbotConfig; plugins: ReturnType; }): Promise { 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 | 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" 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 } | null)?.config ?? ({} as Record); 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 | 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; 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).some((channel) => { if (!channel || typeof channel !== "object") return false; const c = channel as Record; 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..users (or channels.discord.guilds..channels..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..users.", }); } } } if (plugin.id === "slack") { const slackCfg = (account as { config?: Record; dm?: Record } | null) ?.config ?? ({} as Record); 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 | undefined) ?? {}; const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => { if (!value || typeof value !== "object") return false; const channel = value as Record; 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..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..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 } | null)?.config ?? ({} as Record); const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy; const groupPolicy = (telegramCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist"; const groups = telegramCfg.groups as Record | 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; 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).some((topicValue) => { if (!topicValue || typeof topicValue !== "object") return false; const topic = topicValue as Record; 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..allowFrom).", }); } } } return findings; } async function maybeProbeGateway(params: { cfg: ClawdbotConfig; 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 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.CLAWDBOT_GATEWAY_TOKEN?.trim() || (typeof authToken === "string" && authToken.trim() ? authToken.trim() : undefined); const password = process.env.CLAWDBOT_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 { const findings: SecurityAuditFinding[] = []; const cfg = opts.config; const env = process.env; 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)); 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 }))); if (configSnapshot) { findings.push(...(await collectIncludeFilePermFindings({ configSnapshot }))); } findings.push(...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir }))); 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 === false) { findings.push({ checkId: "gateway.probe_failed", severity: "warn", title: "Gateway probe failed (deep)", detail: deep.gateway.error ?? "gateway unreachable", remediation: `Run "${formatCliCommand("clawdbot status --all")}" to debug connectivity/auth, then re-run "${formatCliCommand("clawdbot security audit --deep")}".`, }); } const summary = countBySeverity(findings); return { ts: Date.now(), summary, findings, deep }; }