diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index dc502b931..0031ff2f0 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -1,13 +1,18 @@ import type { Command } from "commander"; +import fs from "node:fs"; +import path from "node:path"; -import { loadConfig } from "../config/config.js"; +import { loadConfig, writeConfigFile } from "../config/config.js"; import { defaultRuntime } from "../runtime.js"; import { runSecurityAudit } from "../security/audit.js"; import { fixSecurityFootguns } from "../security/fix.js"; +import { ipManager } from "../security/ip-manager.js"; +import { DEFAULT_LOG_DIR } from "../logging/logger.js"; import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { parseDuration } from "./parse-duration.js"; type SecurityAuditOptions = { json?: boolean; @@ -146,4 +151,264 @@ export function registerSecurityCli(program: Command) { defaultRuntime.log(lines.join("\n")); }); + + // openclaw security status + security + .command("status") + .description("Show security shield status") + .action(async () => { + const cfg = loadConfig(); + const enabled = cfg.security?.shield?.enabled ?? false; + const rateLimitingEnabled = cfg.security?.shield?.rateLimiting?.enabled ?? false; + const intrusionDetectionEnabled = cfg.security?.shield?.intrusionDetection?.enabled ?? false; + const firewallEnabled = cfg.security?.shield?.ipManagement?.firewall?.enabled ?? false; + const alertingEnabled = cfg.security?.alerting?.enabled ?? false; + + const lines: string[] = []; + lines.push(theme.heading("Security Shield Status")); + lines.push(""); + lines.push(`Shield: ${enabled ? theme.success("ENABLED") : theme.error("DISABLED")}`); + lines.push(`Rate Limiting: ${rateLimitingEnabled ? theme.success("ENABLED") : theme.muted("disabled")}`); + lines.push(`Intrusion Detection: ${intrusionDetectionEnabled ? theme.success("ENABLED") : theme.muted("disabled")}`); + lines.push(`Firewall Integration: ${firewallEnabled ? theme.success("ENABLED") : theme.muted("disabled")}`); + lines.push(`Alerting: ${alertingEnabled ? theme.success("ENABLED") : theme.muted("disabled")}`); + + if (alertingEnabled && cfg.security?.alerting?.channels?.telegram?.enabled) { + lines.push(` Telegram: ${theme.success("ENABLED")}`); + } + + lines.push(""); + lines.push(theme.muted(`Docs: ${formatDocsLink("/security/shield", "docs.openclaw.ai/security/shield")}`)); + defaultRuntime.log(lines.join("\n")); + }); + + // openclaw security enable + security + .command("enable") + .description("Enable security shield") + .action(async () => { + const cfg = loadConfig(); + cfg.security = cfg.security || {}; + cfg.security.shield = cfg.security.shield || {}; + cfg.security.shield.enabled = true; + + await writeConfigFile(cfg); + defaultRuntime.log(theme.success("✓ Security shield enabled")); + defaultRuntime.log(theme.muted(` Restart gateway for changes to take effect: ${formatCliCommand("openclaw gateway restart")}`)); + }); + + // openclaw security disable + security + .command("disable") + .description("Disable security shield") + .action(async () => { + const cfg = loadConfig(); + if (!cfg.security?.shield) { + defaultRuntime.log(theme.muted("Security shield already disabled")); + return; + } + + cfg.security.shield.enabled = false; + await writeConfigFile(cfg); + defaultRuntime.log(theme.warn("⚠ Security shield disabled")); + defaultRuntime.log(theme.muted(` Restart gateway for changes to take effect: ${formatCliCommand("openclaw gateway restart")}`)); + }); + + // openclaw security logs + security + .command("logs") + .description("View security event logs") + .option("-f, --follow", "Follow log output (tail -f)") + .option("-n, --lines ", "Number of lines to show", "50") + .option("--severity ", "Filter by severity (critical, warn, info)") + .action(async (opts: { follow?: boolean; lines?: string; severity?: string }) => { + const today = new Date().toISOString().split("T")[0]; + const logFile = path.join(DEFAULT_LOG_DIR, `security-${today}.jsonl`); + + if (!fs.existsSync(logFile)) { + defaultRuntime.log(theme.warn(`No security logs found for today: ${logFile}`)); + defaultRuntime.log(theme.muted(`Logs are created when security events occur`)); + return; + } + + const lines = parseInt(opts.lines || "50", 10); + const severity = opts.severity?.toLowerCase(); + + if (opts.follow) { + // Tail follow mode + const { spawn } = await import("node:child_process"); + const tail = spawn("tail", ["-f", "-n", String(lines), logFile], { + stdio: "inherit", + }); + + tail.on("error", (err) => { + defaultRuntime.log(theme.error(`Failed to tail logs: ${String(err)}`)); + process.exit(1); + }); + } else { + // Read last N lines + const content = fs.readFileSync(logFile, "utf-8"); + const allLines = content.trim().split("\n").filter(Boolean); + const lastLines = allLines.slice(-lines); + + for (const line of lastLines) { + try { + const event = JSON.parse(line); + if (severity && event.severity !== severity) { + continue; + } + + const severityLabel = + event.severity === "critical" + ? theme.error("CRITICAL") + : event.severity === "warn" + ? theme.warn("WARN") + : theme.muted("INFO"); + + const timestamp = new Date(event.timestamp).toLocaleString(); + defaultRuntime.log(`[${timestamp}] ${severityLabel} ${event.action} (${event.ip})`); + + if (event.details && Object.keys(event.details).length > 0) { + defaultRuntime.log(theme.muted(` ${JSON.stringify(event.details)}`)); + } + } catch { + // Skip invalid lines + } + } + } + }); + + // openclaw blocklist + const blocklist = program + .command("blocklist") + .description("Manage IP blocklist"); + + blocklist + .command("list") + .description("List all blocked IPs") + .option("--json", "Print JSON", false) + .action(async (opts: { json?: boolean }) => { + const entries = ipManager.getBlocklist(); + + if (opts.json) { + defaultRuntime.log(JSON.stringify(entries, null, 2)); + return; + } + + if (entries.length === 0) { + defaultRuntime.log(theme.muted("No blocked IPs")); + return; + } + + defaultRuntime.log(theme.heading(`Blocked IPs (${entries.length})`)); + defaultRuntime.log(""); + + for (const entry of entries) { + const expiresAt = new Date(entry.expiresAt); + const now = new Date(); + const remaining = expiresAt.getTime() - now.getTime(); + const hours = Math.floor(remaining / (1000 * 60 * 60)); + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); + + defaultRuntime.log(`${theme.bold(entry.ip)}`); + defaultRuntime.log(` Reason: ${entry.reason}`); + defaultRuntime.log(` Source: ${entry.source}`); + defaultRuntime.log(` Blocked: ${new Date(entry.blockedAt).toLocaleString()}`); + defaultRuntime.log(` Expires: ${expiresAt.toLocaleString()} (${hours}h ${minutes}m remaining)`); + defaultRuntime.log(""); + } + }); + + blocklist + .command("add ") + .description("Block an IP address") + .option("-r, --reason ", "Block reason", "manual") + .option("-d, --duration ", "Block duration (e.g., 24h, 7d, 30d)", "24h") + .action(async (ip: string, opts: { reason?: string; duration?: string }) => { + const reason = opts.reason || "manual"; + const durationMs = parseDuration(opts.duration || "24h"); + + ipManager.blockIp({ + ip, + reason, + durationMs, + source: "manual", + }); + + defaultRuntime.log(theme.success(`✓ Blocked ${ip}`)); + defaultRuntime.log(theme.muted(` Reason: ${reason}`)); + defaultRuntime.log(theme.muted(` Duration: ${opts.duration}`)); + }); + + blocklist + .command("remove ") + .description("Unblock an IP address") + .action(async (ip: string) => { + const removed = ipManager.unblockIp(ip); + + if (removed) { + defaultRuntime.log(theme.success(`✓ Unblocked ${ip}`)); + } else { + defaultRuntime.log(theme.muted(`IP ${ip} was not blocked`)); + } + }); + + // openclaw allowlist + const allowlist = program + .command("allowlist") + .description("Manage IP allowlist"); + + allowlist + .command("list") + .description("List all allowed IPs") + .option("--json", "Print JSON", false) + .action(async (opts: { json?: boolean }) => { + const entries = ipManager.getAllowlist(); + + if (opts.json) { + defaultRuntime.log(JSON.stringify(entries, null, 2)); + return; + } + + if (entries.length === 0) { + defaultRuntime.log(theme.muted("No allowed IPs")); + return; + } + + defaultRuntime.log(theme.heading(`Allowed IPs (${entries.length})`)); + defaultRuntime.log(""); + + for (const entry of entries) { + defaultRuntime.log(`${theme.bold(entry.ip)}`); + defaultRuntime.log(` Reason: ${entry.reason}`); + defaultRuntime.log(` Source: ${entry.source}`); + defaultRuntime.log(` Added: ${new Date(entry.addedAt).toLocaleString()}`); + defaultRuntime.log(""); + } + }); + + allowlist + .command("add ") + .description("Add IP to allowlist (supports CIDR notation)") + .option("-r, --reason ", "Allow reason", "manual") + .action(async (ip: string, opts: { reason?: string }) => { + const reason = opts.reason || "manual"; + + ipManager.allowIp({ + ip, + reason, + source: "manual", + }); + + defaultRuntime.log(theme.success(`✓ Added ${ip} to allowlist`)); + defaultRuntime.log(theme.muted(` Reason: ${reason}`)); + }); + + allowlist + .command("remove ") + .description("Remove IP from allowlist") + .action(async (ip: string) => { + ipManager.removeFromAllowlist(ip); + defaultRuntime.log(theme.success(`✓ Removed ${ip} from allowlist`)); + }); }