openclaw/src/commands/doctor-security.ts

240 lines
8.6 KiB
TypeScript

import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig, GatewayBindMode } from "../config/config.js";
import type { AgentConfig } from "../config/types.agents.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
import { resolveDmAllowState } from "../security/dm-policy-shared.js";
import { note } from "../terminal/note.js";
import { resolveDefaultChannelAccountContext } from "./channel-account-context.js";
function collectImplicitHeartbeatDirectPolicyWarnings(cfg: OpenClawConfig): string[] {
const warnings: string[] = [];
const maybeWarn = (params: {
label: string;
heartbeat: AgentConfig["heartbeat"] | undefined;
pathHint: string;
}) => {
const heartbeat = params.heartbeat;
if (!heartbeat || heartbeat.target === undefined || heartbeat.target === "none") {
return;
}
if (heartbeat.directPolicy !== undefined) {
return;
}
warnings.push(
`- ${params.label}: heartbeat delivery is configured while ${params.pathHint} is unset.`,
' Heartbeat now allows direct/DM targets by default. Set it explicitly to "allow" or "block" to pin upgrade behavior.',
);
};
maybeWarn({
label: "Heartbeat defaults",
heartbeat: cfg.agents?.defaults?.heartbeat,
pathHint: "agents.defaults.heartbeat.directPolicy",
});
for (const agent of cfg.agents?.list ?? []) {
maybeWarn({
label: `Heartbeat agent "${agent.id}"`,
heartbeat: agent.heartbeat,
pathHint: `heartbeat.directPolicy for agent "${agent.id}"`,
});
}
return warnings;
}
export async function noteSecurityWarnings(cfg: OpenClawConfig) {
const warnings: string[] = [];
const auditHint = `- Run: ${formatCliCommand("openclaw security audit --deep")}`;
if (cfg.approvals?.exec?.enabled === false) {
warnings.push(
"- Note: approvals.exec.enabled=false disables approval forwarding only.",
" Host exec gating still comes from ~/.openclaw/exec-approvals.json.",
` Check local policy with: ${formatCliCommand("openclaw approvals get --gateway")}`,
);
}
warnings.push(...collectImplicitHeartbeatDirectPolicyWarnings(cfg));
// ===========================================
// GATEWAY NETWORK EXPOSURE CHECK
// ===========================================
// Check for dangerous gateway binding configurations
// that expose the gateway to network without proper auth
const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string;
const customBindHost = cfg.gateway?.customBindHost?.trim();
const bindModes: GatewayBindMode[] = ["auto", "lan", "loopback", "custom", "tailnet"];
const bindMode = bindModes.includes(gatewayBind as GatewayBindMode)
? (gatewayBind as GatewayBindMode)
: undefined;
const resolvedBindHost = bindMode
? await resolveGatewayBindHost(bindMode, customBindHost)
: "0.0.0.0";
const isExposed = !isLoopbackHost(resolvedBindHost);
const resolvedAuth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
env: process.env,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
});
const authToken = resolvedAuth.token?.trim() ?? "";
const authPassword = resolvedAuth.password?.trim() ?? "";
const hasToken =
authToken.length > 0 ||
hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults);
const hasPassword =
authPassword.length > 0 ||
hasConfiguredSecretInput(cfg.gateway?.auth?.password, cfg.secrets?.defaults);
const hasSharedSecret =
(resolvedAuth.mode === "token" && hasToken) ||
(resolvedAuth.mode === "password" && hasPassword);
const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`;
const saferRemoteAccessLines = [
" Safer remote access: keep bind loopback and use Tailscale Serve/Funnel or an SSH tunnel.",
" Example tunnel: ssh -N -L 18789:127.0.0.1:18789 user@gateway-host",
" Docs: https://docs.openclaw.ai/gateway/remote",
];
if (isExposed) {
if (!hasSharedSecret) {
const authFixLines =
resolvedAuth.mode === "password"
? [
` Fix: ${formatCliCommand("openclaw configure")} to set a password`,
` Or switch to token: ${formatCliCommand("openclaw config set gateway.auth.mode token")}`,
]
: [
` Fix: ${formatCliCommand("openclaw doctor --fix")} to generate a token`,
` Or set token directly: ${formatCliCommand(
"openclaw config set gateway.auth.mode token",
)}`,
];
warnings.push(
`- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`,
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
` Fix: ${formatCliCommand("openclaw config set gateway.bind loopback")}`,
...saferRemoteAccessLines,
...authFixLines,
);
} else {
// Auth is configured, but still warn about network exposure
warnings.push(
`- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`,
` Ensure your auth credentials are strong and not exposed.`,
...saferRemoteAccessLines,
);
}
}
const warnDmPolicy = async (params: {
label: string;
provider: ChannelId;
accountId: string;
dmPolicy: string;
allowFrom?: Array<string | number> | null;
policyPath?: string;
allowFromPath: string;
approveHint: string;
normalizeEntry?: (raw: string) => string;
}) => {
const dmPolicy = params.dmPolicy;
const policyPath = params.policyPath ?? `${params.allowFromPath}policy`;
const { hasWildcard, allowCount, isMultiUserDm } = await resolveDmAllowState({
provider: params.provider,
accountId: params.accountId,
allowFrom: params.allowFrom,
normalizeEntry: params.normalizeEntry,
});
const dmScope = cfg.session?.dmScope ?? "main";
if (dmPolicy === "open") {
const allowFromPath = `${params.allowFromPath}allowFrom`;
warnings.push(`- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`);
if (!hasWildcard) {
warnings.push(
`- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`,
);
}
}
if (dmPolicy === "disabled") {
warnings.push(`- ${params.label} DMs: disabled (${policyPath}="disabled").`);
return;
}
if (dmPolicy !== "open" && allowCount === 0) {
warnings.push(
`- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`,
);
warnings.push(` ${params.approveHint}`);
}
if (dmScope === "main" && isMultiUserDm) {
warnings.push(
`- ${params.label} DMs: multiple senders share the main session; run: ` +
formatCliCommand('openclaw config set session.dmScope "per-channel-peer"') +
' (or "per-account-channel-peer" for multi-account channels) to isolate sessions.',
);
}
};
for (const plugin of listChannelPlugins()) {
if (!plugin.security) {
continue;
}
const { defaultAccountId, account, enabled, configured, diagnostics } =
await resolveDefaultChannelAccountContext(plugin, cfg, {
mode: "read_only",
commandName: "doctor",
});
for (const diagnostic of diagnostics) {
warnings.push(`- [secrets] ${diagnostic}`);
}
if (!enabled) {
continue;
}
if (!configured) {
continue;
}
const dmPolicy = plugin.security.resolveDmPolicy?.({
cfg,
accountId: defaultAccountId,
account,
});
if (dmPolicy) {
await warnDmPolicy({
label: plugin.meta.label ?? plugin.id,
provider: plugin.id,
accountId: defaultAccountId,
dmPolicy: dmPolicy.policy,
allowFrom: dmPolicy.allowFrom,
policyPath: dmPolicy.policyPath,
allowFromPath: dmPolicy.allowFromPath,
approveHint: dmPolicy.approveHint,
normalizeEntry: dmPolicy.normalizeEntry,
});
}
if (plugin.security.collectWarnings) {
const extra = await plugin.security.collectWarnings({
cfg,
accountId: defaultAccountId,
account,
});
if (extra?.length) {
warnings.push(...extra);
}
}
}
const lines = warnings.length > 0 ? warnings : ["- No channel security warnings detected."];
lines.push(auditHint);
note(lines.join("\n"), "Security");
}