feat: add security audit + onboarding checkpoint
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.15
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Security: add `clawdbot security audit` (`--deep`) 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.
|
||||||
|
|
||||||
## 2026.1.14
|
## 2026.1.14
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code:
|
|||||||
- Prefer explicit `plugins.allow` allowlists.
|
- Prefer explicit `plugins.allow` allowlists.
|
||||||
- Review plugin config before enabling.
|
- Review plugin config before enabling.
|
||||||
- Restart the Gateway after plugin changes.
|
- Restart the Gateway after plugin changes.
|
||||||
|
- If you install plugins from npm (`clawdbot plugins install <npm-spec>`), treat it like running untrusted code:
|
||||||
|
- The install path is `~/.clawdbot/extensions/<pluginId>/` (or `$CLAWDBOT_STATE_DIR/extensions/<pluginId>/`).
|
||||||
|
- Clawdbot uses `npm pack` and then runs `npm install --omit=dev` in that directory (npm lifecycle scripts can execute code during install).
|
||||||
|
- Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling.
|
||||||
|
|
||||||
|
Details: [Plugins](/plugin)
|
||||||
|
|
||||||
## DM access model (pairing / allowlist / open / disabled)
|
## DM access model (pairing / allowlist / open / disabled)
|
||||||
|
|
||||||
@@ -120,6 +126,21 @@ Keep config + state private on the gateway host:
|
|||||||
|
|
||||||
`clawdbot doctor` can warn and offer to tighten these permissions.
|
`clawdbot doctor` can warn and offer to tighten these permissions.
|
||||||
|
|
||||||
|
### 0.4) Network exposure (bind + port + firewall)
|
||||||
|
|
||||||
|
The Gateway multiplexes **WebSocket + HTTP** on a single port:
|
||||||
|
- Default: `18789`
|
||||||
|
- Config/flags/env: `gateway.port`, `--port`, `CLAWDBOT_GATEWAY_PORT`
|
||||||
|
|
||||||
|
Bind mode controls where the Gateway listens:
|
||||||
|
- `gateway.bind: "loopback"` (default): only local clients can connect.
|
||||||
|
- Non-loopback binds (`"lan"`, `"tailnet"`, `"auto"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall.
|
||||||
|
|
||||||
|
Rules of thumb:
|
||||||
|
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access).
|
||||||
|
- If you must bind to LAN, firewall the port to a tight allowlist of source IPs; do not port-forward it broadly.
|
||||||
|
- Never expose the Gateway unauthenticated on `0.0.0.0`.
|
||||||
|
|
||||||
### 0.5) Lock down the Gateway WebSocket (local auth)
|
### 0.5) Lock down the Gateway WebSocket (local auth)
|
||||||
|
|
||||||
Gateway auth is **only** enforced when you set `gateway.auth`. If it’s unset,
|
Gateway auth is **only** enforced when you set `gateway.auth`. If it’s unset,
|
||||||
@@ -145,6 +166,16 @@ Doctor can generate one for you: `clawdbot doctor --generate-gateway-token`.
|
|||||||
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
||||||
protect local WS access.
|
protect local WS access.
|
||||||
|
|
||||||
|
Auth modes:
|
||||||
|
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
||||||
|
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
|
||||||
|
|
||||||
|
Rotation checklist (token/password):
|
||||||
|
1. Generate/set a new secret (`gateway.auth.token` or `CLAWDBOT_GATEWAY_PASSWORD`).
|
||||||
|
2. Restart the Gateway (or restart the macOS app if it supervises the Gateway).
|
||||||
|
3. Update any remote clients (`gateway.remote.token` / `.password` on machines that call into the Gateway).
|
||||||
|
4. Verify you can no longer connect with the old credentials.
|
||||||
|
|
||||||
### 0.6) Tailscale Serve identity headers
|
### 0.6) Tailscale Serve identity headers
|
||||||
|
|
||||||
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
|
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
|
||||||
@@ -159,6 +190,36 @@ you terminate TLS or proxy in front of the gateway, disable
|
|||||||
|
|
||||||
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
||||||
|
|
||||||
|
### 0.7) Secrets on disk (what’s sensitive)
|
||||||
|
|
||||||
|
Assume anything under `~/.clawdbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data:
|
||||||
|
|
||||||
|
- `clawdbot.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists.
|
||||||
|
- `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports.
|
||||||
|
- `agents/<agentId>/agent/auth-profiles.json`: API keys + OAuth tokens (imported from legacy `credentials/oauth.json`).
|
||||||
|
- `agents/<agentId>/sessions/**`: session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output.
|
||||||
|
- `extensions/**`: installed plugins (plus their `node_modules/`).
|
||||||
|
- `sandboxes/**`: tool sandbox workspaces; can accumulate copies of files you read/write inside the sandbox.
|
||||||
|
|
||||||
|
Hardening tips:
|
||||||
|
- Keep permissions tight (`700` on dirs, `600` on files).
|
||||||
|
- Use full-disk encryption on the gateway host.
|
||||||
|
- Prefer a dedicated OS user account for the Gateway if the host is shared.
|
||||||
|
|
||||||
|
### 0.8) Logs + transcripts (redaction + retention)
|
||||||
|
|
||||||
|
Logs and transcripts can leak sensitive info even when access controls are correct:
|
||||||
|
- Gateway logs may include tool summaries, errors, and URLs.
|
||||||
|
- Session transcripts can include pasted secrets, file contents, command output, and links.
|
||||||
|
|
||||||
|
Recommendations:
|
||||||
|
- Keep tool summary redaction on (`logging.redactSensitive: "tools"`; default).
|
||||||
|
- Add custom patterns for your environment via `logging.redactPatterns` (tokens, hostnames, internal URLs).
|
||||||
|
- When sharing diagnostics, prefer `clawdbot status --all` (pasteable, secrets redacted) over raw logs.
|
||||||
|
- Prune old session transcripts and log files if you don’t need long retention.
|
||||||
|
|
||||||
|
Details: [Logging](/gateway/logging)
|
||||||
|
|
||||||
### 1) DMs: pairing by default
|
### 1) DMs: pairing by default
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -205,6 +266,29 @@ You can already build a read-only profile by combining:
|
|||||||
|
|
||||||
We may add a single `readOnlyMode` flag later to simplify this configuration.
|
We may add a single `readOnlyMode` flag later to simplify this configuration.
|
||||||
|
|
||||||
|
### 5) Secure baseline (copy/paste)
|
||||||
|
|
||||||
|
One “safe default” config that keeps the Gateway private, requires DM pairing, and avoids always-on group bots:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
bind: "loopback",
|
||||||
|
port: 18789,
|
||||||
|
auth: { mode: "token", token: "your-long-random-token" }
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
dmPolicy: "pairing",
|
||||||
|
groups: { "*": { requireMention: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want “safer by default” tool execution too, add a sandbox + deny dangerous tools for any non-owner agent (example below under “Per-agent access profiles”).
|
||||||
|
|
||||||
## Sandboxing (recommended)
|
## Sandboxing (recommended)
|
||||||
|
|
||||||
Dedicated doc: [Sandboxing](/gateway/sandboxing)
|
Dedicated doc: [Sandboxing](/gateway/sandboxing)
|
||||||
@@ -233,6 +317,9 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
|
|||||||
- Prefer a dedicated profile for the agent (the default `clawd` profile).
|
- Prefer a dedicated profile for the agent (the default `clawd` profile).
|
||||||
- Avoid pointing the agent at your personal daily-driver profile.
|
- Avoid pointing the agent at your personal daily-driver profile.
|
||||||
- Keep host browser control disabled for sandboxed agents unless you trust them.
|
- Keep host browser control disabled for sandboxed agents unless you trust them.
|
||||||
|
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
|
||||||
|
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
|
||||||
|
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
|
||||||
|
|
||||||
## Per-agent access profiles (multi-agent)
|
## Per-agent access profiles (multi-agent)
|
||||||
|
|
||||||
@@ -301,7 +388,7 @@ Common use cases:
|
|||||||
workspaceAccess: "none"
|
workspaceAccess: "none"
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "whatsapp", "telegram", "slack", "discord", "gateway"],
|
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "whatsapp", "telegram", "slack", "discord"],
|
||||||
deny: ["read", "write", "edit", "apply_patch", "exec", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
|
deny: ["read", "write", "edit", "apply_patch", "exec", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,11 +414,30 @@ Include security guidelines in your agent's system prompt:
|
|||||||
|
|
||||||
If your AI does something bad:
|
If your AI does something bad:
|
||||||
|
|
||||||
1. **Stop it:** stop the macOS app (if it’s supervising the Gateway) or terminate your `clawdbot gateway` process
|
### Contain
|
||||||
2. **Check logs:** `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or your configured `logging.file`)
|
|
||||||
3. **Review session:** Check `~/.clawdbot/agents/<agentId>/sessions/` for what happened
|
1. **Stop it:** stop the macOS app (if it supervises the Gateway) or terminate your `clawdbot gateway` process.
|
||||||
4. **Rotate secrets:** If credentials were exposed
|
2. **Close exposure:** set `gateway.bind: "loopback"` (or disable Tailscale Funnel/Serve) until you understand what happened.
|
||||||
5. **Update rules:** Add to your security prompt
|
3. **Freeze access:** switch risky DMs/groups to `dmPolicy: "disabled"` / require mentions, and remove `"*"` allow-all entries if you had them.
|
||||||
|
|
||||||
|
### Rotate (assume compromise if secrets leaked)
|
||||||
|
|
||||||
|
1. Rotate Gateway auth (`gateway.auth.token` / `CLAWDBOT_GATEWAY_PASSWORD`) and restart.
|
||||||
|
2. Rotate remote client secrets (`gateway.remote.token` / `.password`) on any machine that can call the Gateway.
|
||||||
|
3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`).
|
||||||
|
|
||||||
|
### Audit
|
||||||
|
|
||||||
|
1. Check Gateway logs: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or `logging.file`).
|
||||||
|
2. Review the relevant transcript(s): `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
|
||||||
|
3. Review recent config changes (anything that could have widened access: `gateway.bind`, `gateway.auth`, dm/group policies, `tools.elevated`, plugin changes).
|
||||||
|
|
||||||
|
### Collect for a report
|
||||||
|
|
||||||
|
- Timestamp, gateway host OS + Clawdbot version
|
||||||
|
- The session transcript(s) + a short log tail (after redacting)
|
||||||
|
- What the attacker sent + what the agent did
|
||||||
|
- Whether the Gateway was exposed beyond loopback (LAN/Tailscale Funnel/Serve)
|
||||||
|
|
||||||
## The Trust Hierarchy
|
## The Trust Hierarchy
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export function registerOnboardCommand(program: Command) {
|
|||||||
.option("--workspace <dir>", "Agent workspace directory (default: ~/clawd)")
|
.option("--workspace <dir>", "Agent workspace directory (default: ~/clawd)")
|
||||||
.option("--reset", "Reset config + credentials + sessions + workspace before running wizard")
|
.option("--reset", "Reset config + credentials + sessions + workspace before running wizard")
|
||||||
.option("--non-interactive", "Run without prompts", false)
|
.option("--non-interactive", "Run without prompts", false)
|
||||||
|
.option(
|
||||||
|
"--accept-risk",
|
||||||
|
"Acknowledge that agents are powerful and full system access is risky (required for --non-interactive)",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.option("--flow <flow>", "Wizard flow: quickstart|advanced")
|
.option("--flow <flow>", "Wizard flow: quickstart|advanced")
|
||||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||||
.option(
|
.option(
|
||||||
@@ -90,6 +95,7 @@ export function registerOnboardCommand(program: Command) {
|
|||||||
{
|
{
|
||||||
workspace: opts.workspace as string | undefined,
|
workspace: opts.workspace as string | undefined,
|
||||||
nonInteractive: Boolean(opts.nonInteractive),
|
nonInteractive: Boolean(opts.nonInteractive),
|
||||||
|
acceptRisk: Boolean(opts.acceptRisk),
|
||||||
flow: opts.flow as "quickstart" | "advanced" | undefined,
|
flow: opts.flow as "quickstart" | "advanced" | undefined,
|
||||||
mode: opts.mode as "local" | "remote" | undefined,
|
mode: opts.mode as "local" | "remote" | undefined,
|
||||||
authChoice: opts.authChoice as AuthChoice | undefined,
|
authChoice: opts.authChoice as AuthChoice | undefined,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { registerNodesCli } from "../nodes-cli.js";
|
|||||||
import { registerPairingCli } from "../pairing-cli.js";
|
import { registerPairingCli } from "../pairing-cli.js";
|
||||||
import { registerPluginsCli } from "../plugins-cli.js";
|
import { registerPluginsCli } from "../plugins-cli.js";
|
||||||
import { registerSandboxCli } from "../sandbox-cli.js";
|
import { registerSandboxCli } from "../sandbox-cli.js";
|
||||||
|
import { registerSecurityCli } from "../security-cli.js";
|
||||||
import { registerSkillsCli } from "../skills-cli.js";
|
import { registerSkillsCli } from "../skills-cli.js";
|
||||||
import { registerTuiCli } from "../tui-cli.js";
|
import { registerTuiCli } from "../tui-cli.js";
|
||||||
import { registerUpdateCli } from "../update-cli.js";
|
import { registerUpdateCli } from "../update-cli.js";
|
||||||
@@ -35,6 +36,7 @@ export function registerSubCliCommands(program: Command) {
|
|||||||
registerPairingCli(program);
|
registerPairingCli(program);
|
||||||
registerPluginsCli(program);
|
registerPluginsCli(program);
|
||||||
registerChannelsCli(program);
|
registerChannelsCli(program);
|
||||||
|
registerSecurityCli(program);
|
||||||
registerSkillsCli(program);
|
registerSkillsCli(program);
|
||||||
registerUpdateCli(program);
|
registerUpdateCli(program);
|
||||||
registerPluginCliCommands(program, loadConfig());
|
registerPluginCliCommands(program, loadConfig());
|
||||||
|
|||||||
91
src/cli/security-cli.ts
Normal file
91
src/cli/security-cli.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { runSecurityAudit } from "../security/audit.js";
|
||||||
|
import { isRich, theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
|
type SecurityAuditOptions = {
|
||||||
|
json?: boolean;
|
||||||
|
deep?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatSummary(summary: { critical: number; warn: number; info: number }): string {
|
||||||
|
const rich = isRich();
|
||||||
|
const c = summary.critical;
|
||||||
|
const w = summary.warn;
|
||||||
|
const i = summary.info;
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(rich ? theme.error(`${c} critical`) : `${c} critical`);
|
||||||
|
parts.push(rich ? theme.warn(`${w} warn`) : `${w} warn`);
|
||||||
|
parts.push(rich ? theme.muted(`${i} info`) : `${i} info`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSecurityCli(program: Command) {
|
||||||
|
const security = program.command("security").description("Security tools (audit)");
|
||||||
|
|
||||||
|
security
|
||||||
|
.command("audit")
|
||||||
|
.description("Audit config + local state for common security foot-guns")
|
||||||
|
.option("--deep", "Attempt live Gateway probe (best-effort)", false)
|
||||||
|
.option("--json", "Print JSON", false)
|
||||||
|
.action(async (opts: SecurityAuditOptions) => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const report = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
deep: Boolean(opts.deep),
|
||||||
|
includeFilesystem: true,
|
||||||
|
includeChannelSecurity: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(report, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rich = isRich();
|
||||||
|
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
||||||
|
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(heading("Clawdbot security audit"));
|
||||||
|
lines.push(muted(`Summary: ${formatSummary(report.summary)}`));
|
||||||
|
lines.push(muted(`Run deeper: clawdbot security audit --deep`));
|
||||||
|
|
||||||
|
const bySeverity = (sev: "critical" | "warn" | "info") =>
|
||||||
|
report.findings.filter((f) => f.severity === sev);
|
||||||
|
|
||||||
|
const render = (sev: "critical" | "warn" | "info") => {
|
||||||
|
const list = bySeverity(sev);
|
||||||
|
if (list.length === 0) return;
|
||||||
|
const label =
|
||||||
|
sev === "critical"
|
||||||
|
? rich
|
||||||
|
? theme.error("CRITICAL")
|
||||||
|
: "CRITICAL"
|
||||||
|
: sev === "warn"
|
||||||
|
? rich
|
||||||
|
? theme.warn("WARN")
|
||||||
|
: "WARN"
|
||||||
|
: rich
|
||||||
|
? theme.muted("INFO")
|
||||||
|
: "INFO";
|
||||||
|
lines.push("");
|
||||||
|
lines.push(heading(label));
|
||||||
|
for (const f of list) {
|
||||||
|
lines.push(`${chalk.gray(f.checkId)} ${f.title}`);
|
||||||
|
lines.push(` ${f.detail}`);
|
||||||
|
if (f.remediation?.trim()) lines.push(` ${muted(`Fix: ${f.remediation.trim()}`)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render("critical");
|
||||||
|
render("warn");
|
||||||
|
render("info");
|
||||||
|
|
||||||
|
defaultRuntime.log(lines.join("\n"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import { note } from "../terminal/note.js";
|
|||||||
|
|
||||||
export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
|
export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
const auditHint = `- Run: clawdbot security audit --deep`;
|
||||||
|
|
||||||
const warnDmPolicy = async (params: {
|
const warnDmPolicy = async (params: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -100,7 +101,7 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (warnings.length > 0) {
|
const lines = warnings.length > 0 ? warnings : ["- No channel security warnings detected."];
|
||||||
note(warnings.join("\n"), "Security");
|
lines.push(auditHint);
|
||||||
}
|
note(lines.join("\n"), "Security");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export type OnboardOptions = {
|
|||||||
flow?: "quickstart" | "advanced";
|
flow?: "quickstart" | "advanced";
|
||||||
workspace?: string;
|
workspace?: string;
|
||||||
nonInteractive?: boolean;
|
nonInteractive?: boolean;
|
||||||
|
/** Required for non-interactive onboarding; skips the interactive risk prompt when true. */
|
||||||
|
acceptRisk?: boolean;
|
||||||
reset?: boolean;
|
reset?: boolean;
|
||||||
authChoice?: AuthChoice;
|
authChoice?: AuthChoice;
|
||||||
/** Used when `authChoice=token` in non-interactive mode. */
|
/** Used when `authChoice=token` in non-interactive mode. */
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
|
|||||||
const authChoice = opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice;
|
const authChoice = opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice;
|
||||||
const normalizedOpts = authChoice === opts.authChoice ? opts : { ...opts, authChoice };
|
const normalizedOpts = authChoice === opts.authChoice ? opts : { ...opts, authChoice };
|
||||||
|
|
||||||
|
if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) {
|
||||||
|
runtime.error(
|
||||||
|
[
|
||||||
|
"Non-interactive onboarding requires explicit risk acknowledgement.",
|
||||||
|
"Read: https://docs.clawd.bot/security",
|
||||||
|
"Re-run with: clawdbot onboard --non-interactive --accept-risk ...",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
runtime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (normalizedOpts.reset) {
|
if (normalizedOpts.reset) {
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
const baseConfig = snapshot.valid ? snapshot.config : {};
|
const baseConfig = snapshot.valid ? snapshot.config : {};
|
||||||
|
|||||||
@@ -338,9 +338,10 @@ export async function statusAllCommand(
|
|||||||
Item: "Gateway",
|
Item: "Gateway",
|
||||||
Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}${gatewayAuth}`,
|
Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}${gatewayAuth}`,
|
||||||
},
|
},
|
||||||
|
{ Item: "Security", Value: "Run: clawdbot security audit --deep" },
|
||||||
gatewaySelfLine
|
gatewaySelfLine
|
||||||
? { Item: "Gateway self", Value: gatewaySelfLine }
|
? { Item: "Gateway self", Value: gatewaySelfLine }
|
||||||
: { Item: "Gateway self", Value: "unknown" },
|
: { Item: "Gateway self", Value: "unknown" },
|
||||||
daemon
|
daemon
|
||||||
? {
|
? {
|
||||||
Item: "Daemon",
|
Item: "Daemon",
|
||||||
|
|||||||
97
src/security/audit.test.ts
Normal file
97
src/security/audit.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { runSecurityAudit } from "./audit.js";
|
||||||
|
|
||||||
|
describe("security audit", () => {
|
||||||
|
it("flags non-loopback bind without auth as critical", async () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
gateway: {
|
||||||
|
bind: "lan",
|
||||||
|
auth: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
res.findings.some((f) => f.checkId === "gateway.bind_no_auth" && f.severity === "critical"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags logging.redactSensitive=off", async () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
logging: { redactSensitive: "off" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ checkId: "logging.redact_off", severity: "warn" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags tools.elevated allowFrom wildcard as critical", async () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
tools: {
|
||||||
|
elevated: {
|
||||||
|
allowFrom: { whatsapp: ["*"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "tools.elevated.allowFrom.whatsapp.wildcard",
|
||||||
|
severity: "critical",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a warning when deep probe fails", async () => {
|
||||||
|
const cfg: ClawdbotConfig = { gateway: { mode: "local" } };
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
deep: true,
|
||||||
|
deepTimeoutMs: 50,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
probeGatewayFn: async () => ({
|
||||||
|
ok: false,
|
||||||
|
url: "ws://127.0.0.1:18789",
|
||||||
|
connectLatencyMs: null,
|
||||||
|
error: "connect failed",
|
||||||
|
close: null,
|
||||||
|
health: null,
|
||||||
|
status: null,
|
||||||
|
presence: null,
|
||||||
|
configSnapshot: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ checkId: "gateway.probe_failed", severity: "warn" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
533
src/security/audit.ts
Normal file
533
src/security/audit.ts
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
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 { 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";
|
||||||
|
|
||||||
|
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: CONFIG_DIR). */
|
||||||
|
stateDir?: string;
|
||||||
|
/** Override config path check (default: CONFIG_PATH_CLAWDBOT). */
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 safeStat(targetPath: string): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
isSymlink: boolean;
|
||||||
|
isDir: boolean;
|
||||||
|
mode: number | null;
|
||||||
|
uid: number | null;
|
||||||
|
gid: number | null;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const lst = await fs.lstat(targetPath);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
isSymlink: lst.isSymbolicLink(),
|
||||||
|
isDir: lst.isDirectory(),
|
||||||
|
mode: typeof lst.mode === "number" ? lst.mode : null,
|
||||||
|
uid: typeof lst.uid === "number" ? lst.uid : null,
|
||||||
|
gid: typeof lst.gid === "number" ? lst.gid : null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
isSymlink: false,
|
||||||
|
isDir: false,
|
||||||
|
mode: null,
|
||||||
|
uid: null,
|
||||||
|
gid: null,
|
||||||
|
error: String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeBits(mode: number | null): number | null {
|
||||||
|
if (mode == null) return null;
|
||||||
|
return mode & 0o777;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOctal(bits: number | null): string {
|
||||||
|
if (bits == null) return "unknown";
|
||||||
|
return bits.toString(8).padStart(3, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorldWritable(bits: number | null): boolean {
|
||||||
|
if (bits == null) return false;
|
||||||
|
return (bits & 0o002) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupWritable(bits: number | null): boolean {
|
||||||
|
if (bits == null) return false;
|
||||||
|
return (bits & 0o020) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorldReadable(bits: number | null): boolean {
|
||||||
|
if (bits == null) return false;
|
||||||
|
return (bits & 0o004) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupReadable(bits: number | null): boolean {
|
||||||
|
if (bits == null) return false;
|
||||||
|
return (bits & 0o040) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectFilesystemFindings(params: {
|
||||||
|
stateDir: string;
|
||||||
|
configPath: string;
|
||||||
|
}): Promise<SecurityAuditFinding[]> {
|
||||||
|
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 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<typeof listChannelPlugins>;
|
||||||
|
}): Promise<SecurityAuditFinding[]> {
|
||||||
|
const findings: SecurityAuditFinding[] = [];
|
||||||
|
|
||||||
|
const warnDmPolicy = async (input: {
|
||||||
|
label: string;
|
||||||
|
provider: ChannelId;
|
||||||
|
dmPolicy: string;
|
||||||
|
allowFrom?: Array<string | number> | null;
|
||||||
|
policyPath?: string;
|
||||||
|
allowFromPath: string;
|
||||||
|
}) => {
|
||||||
|
const policyPath = input.policyPath ?? `${input.allowFromPath}policy`;
|
||||||
|
const configAllowFrom = normalizeAllowFromList(input.allowFrom);
|
||||||
|
const hasWildcard = configAllowFrom.includes("*");
|
||||||
|
|
||||||
|
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 "*".`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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*/, ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeProbeGateway(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
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.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 = 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 stateDir = opts.stateDir ?? CONFIG_DIR;
|
||||||
|
const configPath = opts.configPath ?? CONFIG_PATH_CLAWDBOT;
|
||||||
|
|
||||||
|
findings.push(...collectGatewayConfigFindings(cfg));
|
||||||
|
findings.push(...collectLoggingFindings(cfg));
|
||||||
|
findings.push(...collectElevatedFindings(cfg));
|
||||||
|
|
||||||
|
if (opts.includeFilesystem !== false) {
|
||||||
|
findings.push(...(await collectFilesystemFindings({ stateDir, configPath })));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "clawdbot status --all" to debug connectivity/auth, then re-run "clawdbot security audit --deep".`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = countBySeverity(findings);
|
||||||
|
return { ts: Date.now(), summary, findings, deep };
|
||||||
|
}
|
||||||
@@ -110,6 +110,7 @@ describe("runOnboardingWizard", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
runOnboardingWizard(
|
runOnboardingWizard(
|
||||||
{
|
{
|
||||||
|
acceptRisk: true,
|
||||||
flow: "quickstart",
|
flow: "quickstart",
|
||||||
authChoice: "skip",
|
authChoice: "skip",
|
||||||
installDaemon: false,
|
installDaemon: false,
|
||||||
@@ -150,6 +151,7 @@ describe("runOnboardingWizard", () => {
|
|||||||
|
|
||||||
await runOnboardingWizard(
|
await runOnboardingWizard(
|
||||||
{
|
{
|
||||||
|
acceptRisk: true,
|
||||||
flow: "quickstart",
|
flow: "quickstart",
|
||||||
authChoice: "skip",
|
authChoice: "skip",
|
||||||
installDaemon: false,
|
installDaemon: false,
|
||||||
@@ -201,6 +203,7 @@ describe("runOnboardingWizard", () => {
|
|||||||
|
|
||||||
await runOnboardingWizard(
|
await runOnboardingWizard(
|
||||||
{
|
{
|
||||||
|
acceptRisk: true,
|
||||||
flow: "quickstart",
|
flow: "quickstart",
|
||||||
mode: "local",
|
mode: "local",
|
||||||
workspace: workspaceDir,
|
workspace: workspaceDir,
|
||||||
|
|||||||
@@ -39,7 +39,35 @@ import { resolveUserPath } from "../utils.js";
|
|||||||
import { finalizeOnboardingWizard } from "./onboarding.finalize.js";
|
import { finalizeOnboardingWizard } from "./onboarding.finalize.js";
|
||||||
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
|
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
|
||||||
import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js";
|
import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js";
|
||||||
import type { WizardPrompter } from "./prompts.js";
|
import { WizardCancelledError, type WizardPrompter } from "./prompts.js";
|
||||||
|
|
||||||
|
async function requireRiskAcknowledgement(params: {
|
||||||
|
opts: OnboardOptions;
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
}) {
|
||||||
|
if (params.opts.acceptRisk === true) return;
|
||||||
|
|
||||||
|
await params.prompter.note(
|
||||||
|
[
|
||||||
|
"Please read: https://docs.clawd.bot/security",
|
||||||
|
"",
|
||||||
|
"Clawdbot agents can run commands, read/write files, and act through any tools you enable. They can only send messages on channels you configure (for example, an account you log in on this machine, or a bot account like Slack/Discord).",
|
||||||
|
"",
|
||||||
|
"If you’re new to this, start with the sandbox and least privilege. It helps limit what an agent can do if it’s tricked or makes a mistake.",
|
||||||
|
"Learn more: https://docs.clawd.bot/sandboxing",
|
||||||
|
].join("\n"),
|
||||||
|
"Security",
|
||||||
|
);
|
||||||
|
|
||||||
|
const ok = await params.prompter.confirm({
|
||||||
|
message:
|
||||||
|
"I understand this is powerful and inherently risky. Continue?",
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
throw new WizardCancelledError("risk not accepted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function runOnboardingWizard(
|
export async function runOnboardingWizard(
|
||||||
opts: OnboardOptions,
|
opts: OnboardOptions,
|
||||||
@@ -48,6 +76,7 @@ export async function runOnboardingWizard(
|
|||||||
) {
|
) {
|
||||||
printWizardHeader(runtime);
|
printWizardHeader(runtime);
|
||||||
await prompter.intro("Clawdbot onboarding");
|
await prompter.intro("Clawdbot onboarding");
|
||||||
|
await requireRiskAcknowledgement({ opts, prompter });
|
||||||
|
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
let baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
let baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
||||||
|
|||||||
Reference in New Issue
Block a user