diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb1b36ca..a33b77ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 2026.1.14 (unreleased) ### Changes -- Security: add `clawdbot security audit` (`--deep`) and surface it in `status --all` and `doctor`. +- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`. - Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. - Docs: expand gateway security hardening guidance and incident response checklist. - Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. diff --git a/docs/cli/index.md b/docs/cli/index.md index 2f587e4f9..a39f1a4bf 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -48,6 +48,8 @@ clawdbot [--dev] [--profile ] onboard configure (alias: config) doctor + security + audit reset uninstall update @@ -180,6 +182,12 @@ clawdbot [--dev] [--profile ] Note: plugins can add additional top-level commands (for example `clawdbot voicecall`). +## Security + +- `clawdbot security audit` — audit config + local state for common security foot-guns. +- `clawdbot security audit --deep` — best-effort live Gateway probe. +- `clawdbot security audit --fix` — tighten safe defaults and chmod state/config. + ## Plugins Manage extensions and their config: diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index e8f686b3d..95d10360d 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -4,11 +4,13 @@ import type { Command } from "commander"; import { loadConfig } from "../config/config.js"; import { defaultRuntime } from "../runtime.js"; import { runSecurityAudit } from "../security/audit.js"; +import { fixSecurityFootguns } from "../security/fix.js"; import { isRich, theme } from "../terminal/theme.js"; type SecurityAuditOptions = { json?: boolean; deep?: boolean; + fix?: boolean; }; function formatSummary(summary: { critical: number; warn: number; info: number }): string { @@ -30,8 +32,11 @@ export function registerSecurityCli(program: Command) { .command("audit") .description("Audit config + local state for common security foot-guns") .option("--deep", "Attempt live Gateway probe (best-effort)", false) + .option("--fix", "Apply safe fixes (tighten defaults + chmod state/config)", false) .option("--json", "Print JSON", false) .action(async (opts: SecurityAuditOptions) => { + const fixResult = opts.fix ? await fixSecurityFootguns().catch((_err) => null) : null; + const cfg = loadConfig(); const report = await runSecurityAudit({ config: cfg, @@ -41,7 +46,9 @@ export function registerSecurityCli(program: Command) { }); if (opts.json) { - defaultRuntime.log(JSON.stringify(report, null, 2)); + defaultRuntime.log( + JSON.stringify(fixResult ? { fix: fixResult, report } : report, null, 2), + ); return; } @@ -54,6 +61,34 @@ export function registerSecurityCli(program: Command) { lines.push(muted(`Summary: ${formatSummary(report.summary)}`)); lines.push(muted(`Run deeper: clawdbot security audit --deep`)); + if (opts.fix) { + lines.push(muted(`Fix: clawdbot security audit --fix`)); + if (!fixResult) { + lines.push(muted("Fixes: failed to apply (unexpected error)")); + } else if ( + fixResult.errors.length === 0 && + fixResult.changes.length === 0 && + fixResult.actions.every((a) => a.ok === false) + ) { + lines.push(muted("Fixes: no changes applied")); + } else { + lines.push(""); + lines.push(heading("FIX")); + for (const change of fixResult.changes) { + lines.push(muted(` ${change}`)); + } + for (const action of fixResult.actions) { + const mode = action.mode.toString(8).padStart(3, "0"); + if (action.ok) lines.push(muted(` chmod ${mode} ${action.path}`)); + else if (action.skipped) lines.push(muted(` skip chmod ${mode} ${action.path} (${action.skipped})`)); + else if (action.error) lines.push(muted(` chmod ${mode} ${action.path} failed: ${action.error}`)); + } + if (fixResult.errors.length > 0) { + for (const err of fixResult.errors) lines.push(muted(` error: ${err}`)); + } + } + } + const bySeverity = (sev: "critical" | "warn" | "info") => report.findings.filter((f) => f.severity === sev); diff --git a/src/security/audit.ts b/src/security/audit.ts index 589b4e113..54f1ce38f 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -4,11 +4,10 @@ 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 { CONFIG_PATH_CLAWDBOT } from "../config/config.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 { CONFIG_DIR } from "../utils.js"; export type SecurityAuditSeverity = "info" | "warn" | "critical"; @@ -497,8 +496,8 @@ async function maybeProbeGateway(params: { export async function runSecurityAudit(opts: SecurityAuditOptions): Promise { const findings: SecurityAuditFinding[] = []; const cfg = opts.config; - const stateDir = opts.stateDir ?? CONFIG_DIR; - const configPath = opts.configPath ?? CONFIG_PATH_CLAWDBOT; + const stateDir = opts.stateDir ?? resolveStateDir(); + const configPath = opts.configPath ?? resolveConfigPath(); findings.push(...collectGatewayConfigFindings(cfg)); findings.push(...collectLoggingFindings(cfg)); diff --git a/src/security/fix.test.ts b/src/security/fix.test.ts new file mode 100644 index 000000000..0c9588ba3 --- /dev/null +++ b/src/security/fix.test.ts @@ -0,0 +1,200 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { fixSecurityFootguns } from "./fix.js"; + +describe("security fix", () => { + it("tightens groupPolicy + filesystem perms", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-fix-")); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.chmod(stateDir, 0o755); + + const configPath = path.join(stateDir, "clawdbot.json"); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + channels: { + telegram: { groupPolicy: "open" }, + whatsapp: { groupPolicy: "open" }, + discord: { groupPolicy: "open" }, + signal: { groupPolicy: "open" }, + imessage: { groupPolicy: "open" }, + }, + logging: { redactSensitive: "off" }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + await fs.chmod(configPath, 0o644); + + const credsDir = path.join(stateDir, "credentials"); + await fs.mkdir(credsDir, { recursive: true }); + await fs.writeFile( + path.join(credsDir, "whatsapp-allowFrom.json"), + `${JSON.stringify({ version: 1, allowFrom: [" +15551234567 "] }, null, 2)}\n`, + "utf-8", + ); + + const env = { + ...process.env, + CLAWDBOT_STATE_DIR: stateDir, + CLAWDBOT_CONFIG_PATH: "", + }; + + const res = await fixSecurityFootguns({ env }); + expect(res.ok).toBe(true); + expect(res.configWritten).toBe(true); + expect(res.changes).toEqual( + expect.arrayContaining([ + "channels.telegram.groupPolicy=open -> allowlist", + "channels.whatsapp.groupPolicy=open -> allowlist", + "channels.discord.groupPolicy=open -> allowlist", + "channels.signal.groupPolicy=open -> allowlist", + "channels.imessage.groupPolicy=open -> allowlist", + 'logging.redactSensitive=off -> "tools"', + ]), + ); + + const stateMode = (await fs.stat(stateDir)).mode & 0o777; + expect(stateMode).toBe(0o700); + + const configMode = (await fs.stat(configPath)).mode & 0o777; + expect(configMode).toBe(0o600); + + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; + const channels = parsed.channels as Record>; + expect(channels.telegram.groupPolicy).toBe("allowlist"); + expect(channels.whatsapp.groupPolicy).toBe("allowlist"); + expect(channels.discord.groupPolicy).toBe("allowlist"); + expect(channels.signal.groupPolicy).toBe("allowlist"); + expect(channels.imessage.groupPolicy).toBe("allowlist"); + + expect(channels.whatsapp.groupAllowFrom).toEqual(["+15551234567"]); + }); + + it("applies allowlist per-account and seeds WhatsApp groupAllowFrom from store", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-fix-")); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true }); + + const configPath = path.join(stateDir, "clawdbot.json"); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + channels: { + whatsapp: { + accounts: { + a1: { groupPolicy: "open" }, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const credsDir = path.join(stateDir, "credentials"); + await fs.mkdir(credsDir, { recursive: true }); + await fs.writeFile( + path.join(credsDir, "whatsapp-allowFrom.json"), + `${JSON.stringify({ version: 1, allowFrom: ["+15550001111"] }, null, 2)}\n`, + "utf-8", + ); + + const env = { + ...process.env, + CLAWDBOT_STATE_DIR: stateDir, + CLAWDBOT_CONFIG_PATH: "", + }; + + const res = await fixSecurityFootguns({ env }); + expect(res.ok).toBe(true); + + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; + const channels = parsed.channels as Record>; + const whatsapp = channels.whatsapp as Record; + const accounts = whatsapp.accounts as Record>; + + expect(accounts.a1.groupPolicy).toBe("allowlist"); + expect(accounts.a1.groupAllowFrom).toEqual(["+15550001111"]); + }); + + it("does not seed WhatsApp groupAllowFrom if allowFrom is set", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-fix-")); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true }); + + const configPath = path.join(stateDir, "clawdbot.json"); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + channels: { + whatsapp: { groupPolicy: "open", allowFrom: ["+15552223333"] }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const credsDir = path.join(stateDir, "credentials"); + await fs.mkdir(credsDir, { recursive: true }); + await fs.writeFile( + path.join(credsDir, "whatsapp-allowFrom.json"), + `${JSON.stringify({ version: 1, allowFrom: ["+15550001111"] }, null, 2)}\n`, + "utf-8", + ); + + const env = { + ...process.env, + CLAWDBOT_STATE_DIR: stateDir, + CLAWDBOT_CONFIG_PATH: "", + }; + + const res = await fixSecurityFootguns({ env }); + expect(res.ok).toBe(true); + + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; + const channels = parsed.channels as Record>; + expect(channels.whatsapp.groupPolicy).toBe("allowlist"); + expect(channels.whatsapp.groupAllowFrom).toBeUndefined(); + }); + + it("returns ok=false for invalid config but still tightens perms", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-fix-")); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.chmod(stateDir, 0o755); + + const configPath = path.join(stateDir, "clawdbot.json"); + await fs.writeFile(configPath, "{ this is not json }\n", "utf-8"); + await fs.chmod(configPath, 0o644); + + const env = { + ...process.env, + CLAWDBOT_STATE_DIR: stateDir, + CLAWDBOT_CONFIG_PATH: "", + }; + + const res = await fixSecurityFootguns({ env }); + expect(res.ok).toBe(false); + + const stateMode = (await fs.stat(stateDir)).mode & 0o777; + expect(stateMode).toBe(0o700); + + const configMode = (await fs.stat(configPath)).mode & 0o777; + expect(configMode).toBe(0o600); + }); +}); diff --git a/src/security/fix.ts b/src/security/fix.ts new file mode 100644 index 000000000..297e2594d --- /dev/null +++ b/src/security/fix.ts @@ -0,0 +1,235 @@ +import fs from "node:fs/promises"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { createConfigIO } from "../config/config.js"; +import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; +import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; + +export type SecurityFixChmodAction = { + kind: "chmod"; + path: string; + mode: number; + ok: boolean; + skipped?: string; + error?: string; +}; + +export type SecurityFixResult = { + ok: boolean; + stateDir: string; + configPath: string; + configWritten: boolean; + changes: string[]; + actions: SecurityFixChmodAction[]; + errors: string[]; +}; + +async function safeChmod(params: { + path: string; + mode: number; + require: "dir" | "file"; +}): Promise { + try { + const st = await fs.lstat(params.path); + if (st.isSymbolicLink()) { + return { + kind: "chmod", + path: params.path, + mode: params.mode, + ok: false, + skipped: "symlink", + }; + } + if (params.require === "dir" && !st.isDirectory()) { + return { + kind: "chmod", + path: params.path, + mode: params.mode, + ok: false, + skipped: "not-a-directory", + }; + } + if (params.require === "file" && !st.isFile()) { + return { + kind: "chmod", + path: params.path, + mode: params.mode, + ok: false, + skipped: "not-a-file", + }; + } + const current = st.mode & 0o777; + if (current === params.mode) { + return { + kind: "chmod", + path: params.path, + mode: params.mode, + ok: false, + skipped: "already", + }; + } + await fs.chmod(params.path, params.mode); + return { kind: "chmod", path: params.path, mode: params.mode, ok: true }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return { + kind: "chmod", + path: params.path, + mode: params.mode, + ok: false, + skipped: "missing", + }; + } + return { + kind: "chmod", + path: params.path, + mode: params.mode, + ok: false, + error: String(err), + }; + } +} + +function setGroupPolicyAllowlist(params: { + cfg: ClawdbotConfig; + channel: string; + changes: string[]; + policyFlips: Set; +}): void { + if (!params.cfg.channels) return; + const section = params.cfg.channels[params.channel as keyof ClawdbotConfig["channels"]] as + | Record + | undefined; + if (!section || typeof section !== "object") return; + + const topPolicy = section.groupPolicy; + if (topPolicy === "open") { + section.groupPolicy = "allowlist"; + params.changes.push(`channels.${params.channel}.groupPolicy=open -> allowlist`); + params.policyFlips.add(`channels.${params.channel}.`); + } + + const accounts = section.accounts; + if (!accounts || typeof accounts !== "object") return; + for (const [accountId, accountValue] of Object.entries(accounts)) { + if (!accountId) continue; + if (!accountValue || typeof accountValue !== "object") continue; + const account = accountValue as Record; + if (account.groupPolicy === "open") { + account.groupPolicy = "allowlist"; + params.changes.push( + `channels.${params.channel}.accounts.${accountId}.groupPolicy=open -> allowlist`, + ); + params.policyFlips.add(`channels.${params.channel}.accounts.${accountId}.`); + } + } +} + +function setWhatsAppGroupAllowFromFromStore(params: { + cfg: ClawdbotConfig; + storeAllowFrom: string[]; + changes: string[]; + policyFlips: Set; +}): void { + const section = params.cfg.channels?.whatsapp as Record | undefined; + if (!section || typeof section !== "object") return; + if (params.storeAllowFrom.length === 0) return; + + const maybeApply = (prefix: string, obj: Record) => { + if (!params.policyFlips.has(prefix)) return; + const allowFrom = Array.isArray(obj.allowFrom) ? obj.allowFrom : []; + const groupAllowFrom = Array.isArray(obj.groupAllowFrom) ? obj.groupAllowFrom : []; + if (allowFrom.length > 0) return; + if (groupAllowFrom.length > 0) return; + obj.groupAllowFrom = params.storeAllowFrom; + params.changes.push(`${prefix}groupAllowFrom=pairing-store`); + }; + + maybeApply("channels.whatsapp.", section); + + const accounts = section.accounts; + if (!accounts || typeof accounts !== "object") return; + for (const [accountId, accountValue] of Object.entries(accounts)) { + if (!accountValue || typeof accountValue !== "object") continue; + const account = accountValue as Record; + maybeApply(`channels.whatsapp.accounts.${accountId}.`, account); + } +} + +function applyConfigFixes(params: { + cfg: ClawdbotConfig; + env: NodeJS.ProcessEnv; +}): { cfg: ClawdbotConfig; changes: string[]; policyFlips: Set } { + const next = structuredClone(params.cfg ?? {}); + const changes: string[] = []; + const policyFlips = new Set(); + + if (next.logging?.redactSensitive === "off") { + next.logging = { ...next.logging, redactSensitive: "tools" }; + changes.push('logging.redactSensitive=off -> "tools"'); + } + + for (const channel of ["telegram", "whatsapp", "discord", "signal", "imessage", "slack", "msteams"]) { + setGroupPolicyAllowlist({ cfg: next, channel, changes, policyFlips }); + } + + return { cfg: next, changes, policyFlips }; +} + +export async function fixSecurityFootguns(opts?: { + env?: NodeJS.ProcessEnv; + stateDir?: string; + configPath?: string; +}): Promise { + const env = opts?.env ?? process.env; + const stateDir = opts?.stateDir ?? resolveStateDir(env); + const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir); + const actions: SecurityFixChmodAction[] = []; + const errors: string[] = []; + + const io = createConfigIO({ env, configPath }); + const snap = await io.readConfigFileSnapshot(); + if (!snap.valid) { + errors.push(...snap.issues.map((i) => `${i.path}: ${i.message}`)); + } + + let configWritten = false; + let changes: string[] = []; + if (snap.valid) { + const fixed = applyConfigFixes({ cfg: snap.config, env }); + changes = fixed.changes; + + const whatsappStoreAllowFrom = await readChannelAllowFromStore("whatsapp", env).catch(() => []); + if (whatsappStoreAllowFrom.length > 0) { + setWhatsAppGroupAllowFromFromStore({ + cfg: fixed.cfg, + storeAllowFrom: whatsappStoreAllowFrom, + changes, + policyFlips: fixed.policyFlips, + }); + } + + if (changes.length > 0) { + try { + await io.writeConfigFile(fixed.cfg); + configWritten = true; + } catch (err) { + errors.push(`writeConfigFile failed: ${String(err)}`); + } + } + } + + actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" })); + actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" })); + + return { + ok: errors.length === 0, + stateDir, + configPath, + configWritten, + changes, + actions, + errors, + }; +}