From 29d74bfa54cd450bd7db2609cbc04eefd4046b73 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 08:20:11 +0000 Subject: [PATCH] feat(security): add hardening modules for single-user auth, network whitelist, and fs monitoring --- src/gateway/server.impl.ts | 14 + src/security/fs-monitor.ts | 170 +++++++++++ src/security/hardening-logger.ts | 99 +++++++ src/security/hardening.test.ts | 421 +++++++++++++++++++++++++++ src/security/hardening.ts | 218 ++++++++++++++ src/security/network-monitor.ts | 237 +++++++++++++++ src/security/single-user-enforcer.ts | 86 ++++++ src/web/inbound/monitor.ts | 11 + 8 files changed, 1256 insertions(+) create mode 100644 src/security/fs-monitor.ts create mode 100644 src/security/hardening-logger.ts create mode 100644 src/security/hardening.test.ts create mode 100644 src/security/hardening.ts create mode 100644 src/security/network-monitor.ts create mode 100644 src/security/single-user-enforcer.ts diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f641c4076..f8b82ec50 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1,6 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { initSubagentRegistry } from "../agents/subagent-registry.js"; import { registerSkillsChangeListener } from "../agents/skills/refresh.js"; +import { initHardening, teardownHardening } from "../security/hardening.js"; import type { CanvasHostServer } from "../canvas-host/server.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { createDefaultDeps } from "../cli/deps.js"; @@ -210,6 +211,18 @@ export async function startGatewayServer( } const cfgAtStart = loadConfig(); + + // Initialize security hardening (single-user auth, network whitelist, fs monitoring) + // early, before any I/O. Controlled by config or MOLTBOT_HARDENING_ENABLED=1 env var. + const hardeningState = initHardening(cfgAtStart); + if (hardeningState.active) { + log.info( + `security hardening active: singleUser=${hardeningState.singleUser}, ` + + `network=${hardeningState.networkMonitor}, fs=${hardeningState.fsMonitor}` + + (hardeningState.logPath ? `, log=${hardeningState.logPath}` : ""), + ); + } + const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart); if (diagnosticsEnabled) { startDiagnosticHeartbeat(); @@ -580,6 +593,7 @@ export async function startGatewayServer( } skillsChangeUnsub(); await close(opts); + teardownHardening(); }, }; } diff --git a/src/security/fs-monitor.ts b/src/security/fs-monitor.ts new file mode 100644 index 000000000..e68bff6e9 --- /dev/null +++ b/src/security/fs-monitor.ts @@ -0,0 +1,170 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { logSecurityEvent } from "./hardening-logger.js"; + +/** + * Default sensitive path patterns to monitor. + * Paths are resolved relative to the user's home directory. + */ +const DEFAULT_SENSITIVE_PATHS: string[] = [ + "~/.ssh", + "~/.aws", + "~/.gnupg", + "~/.config/gcloud", + "~/.azure", + "~/.kube", + "~/.docker", + "~/.npmrc", + "~/.netrc", + "~/.clawdbot/credentials", + "~/.moltbot/credentials", + "~/.gitconfig", + "~/.bash_history", + "~/.zsh_history", + "/etc/shadow", + "/etc/passwd", +]; + +export type FsMonitorOptions = { + /** Extra sensitive paths to monitor. Supports ~ for home dir. */ + extraSensitivePaths?: string[]; + /** Replace the default sensitive paths entirely. */ + sensitivePaths?: string[]; + /** If true, block access to sensitive paths. If false, only log. Default: false (audit mode). */ + enforce?: boolean; +}; + +let resolvedSensitivePaths: string[] | null = null; +let enforceMode = false; +let installed = false; + +let originalReadFile: typeof fs.readFileSync | null = null; +let originalReadFileAsync: typeof fs.promises.readFile | null = null; + +/** + * Resolve a path that may start with ~. + */ +function expandHome(p: string): string { + if (p.startsWith("~/") || p === "~") { + return path.join(os.homedir(), p.slice(1)); + } + return p; +} + +/** + * Check if a path falls under any sensitive path prefix. + */ +export function isSensitivePath(filePath: string): boolean { + if (!resolvedSensitivePaths) return false; + const normalized = path.resolve(filePath); + for (const sensitive of resolvedSensitivePaths) { + if (normalized === sensitive || normalized.startsWith(sensitive + path.sep)) { + return true; + } + } + return false; +} + +/** + * Record an access to a sensitive file in the audit log. + * Returns true if access is allowed, false if blocked (enforce mode). + */ +export function auditFileAccess( + filePath: string, + operation: "read" | "write" | "stat" | "readdir" | "unlink", +): boolean { + if (!resolvedSensitivePaths) return true; + const normalized = path.resolve(filePath); + if (!isSensitivePath(normalized)) return true; + + logSecurityEvent("sensitive_file_access", { + operation, + path: normalized, + allowed: !enforceMode, + }); + + return !enforceMode; +} + +/** + * Install lightweight fs monitoring hooks. + * Only intercepts fs.readFileSync and fs.promises.readFile for sensitive path auditing. + * This is intentionally minimal to avoid breaking the application. + */ +export function installFsMonitor(opts?: FsMonitorOptions): void { + if (installed) return; + + const rawPaths = opts?.sensitivePaths ?? [ + ...DEFAULT_SENSITIVE_PATHS, + ...(opts?.extraSensitivePaths ?? []), + ]; + resolvedSensitivePaths = rawPaths.map((p) => path.resolve(expandHome(p))); + enforceMode = opts?.enforce ?? false; + + // Wrap fs.readFileSync for synchronous reads + originalReadFile = fs.readFileSync; + (fs as { readFileSync: typeof fs.readFileSync }).readFileSync = ((...args: unknown[]) => { + const filePath = args[0]; + if (typeof filePath === "string") { + const allowed = auditFileAccess(filePath, "read"); + if (!allowed) { + throw new Error(`[fs-monitor] Blocked read access to sensitive path: ${filePath}`); + } + } + return (originalReadFile as Function).apply(fs, args); + }) as typeof fs.readFileSync; + + // Wrap fs.promises.readFile for async reads + originalReadFileAsync = fs.promises.readFile; + (fs.promises as { readFile: typeof fs.promises.readFile }).readFile = (async ( + ...args: unknown[] + ) => { + const filePath = args[0]; + if (typeof filePath === "string") { + const allowed = auditFileAccess(filePath, "read"); + if (!allowed) { + throw new Error(`[fs-monitor] Blocked read access to sensitive path: ${filePath}`); + } + } + return (originalReadFileAsync as Function).apply(fs.promises, args); + }) as typeof fs.promises.readFile; + + installed = true; + + logSecurityEvent("hardening_init", { + module: "fs-monitor", + sensitivePathCount: resolvedSensitivePaths.length, + enforce: enforceMode, + }); +} + +/** + * Uninstall the fs monitor, restoring original functions. + */ +export function uninstallFsMonitor(): void { + if (!installed) return; + if (originalReadFile) { + (fs as { readFileSync: typeof fs.readFileSync }).readFileSync = originalReadFile; + originalReadFile = null; + } + if (originalReadFileAsync) { + (fs.promises as { readFile: typeof fs.promises.readFile }).readFile = originalReadFileAsync; + originalReadFileAsync = null; + } + resolvedSensitivePaths = null; + installed = false; +} + +/** + * Check if the fs monitor is currently active. + */ +export function isFsMonitorActive(): boolean { + return installed; +} + +/** Reset internal state (test-only). */ +export function __resetFsMonitorForTest(): void { + uninstallFsMonitor(); +} diff --git a/src/security/hardening-logger.ts b/src/security/hardening-logger.ts new file mode 100644 index 000000000..744076bd1 --- /dev/null +++ b/src/security/hardening-logger.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveStateDir } from "../config/paths.js"; + +export type SecurityEventType = + | "blocked_sender" + | "blocked_network" + | "allowed_network" + | "sensitive_file_access" + | "hardening_init" + | "hardening_error"; + +export type SecurityEvent = { + timestamp: string; + type: SecurityEventType; + detail: Record; +}; + +export type HardeningLoggerOptions = { + /** Override the log file path. Defaults to ~/.clawdbot/security-audit.log */ + logFile?: string; + /** Also emit events to this callback (for tests). */ + onEvent?: (event: SecurityEvent) => void; +}; + +let logStream: fs.WriteStream | null = null; +let logFilePath: string | null = null; +let eventCallback: ((event: SecurityEvent) => void) | null = null; + +/** + * Resolve the default security log path. + */ +function defaultLogPath(): string { + const stateDir = resolveStateDir(); + return path.join(stateDir, "security-audit.log"); +} + +/** + * Initialize the hardening audit logger. + * Call once at gateway startup, before any other security module. + */ +export function initHardeningLogger(opts?: HardeningLoggerOptions): void { + if (logStream) return; // already initialized + logFilePath = opts?.logFile ?? defaultLogPath(); + eventCallback = opts?.onEvent ?? null; + try { + const dir = path.dirname(logFilePath); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + logStream = fs.createWriteStream(logFilePath, { flags: "a", mode: 0o600 }); + // Silently handle write stream errors to avoid crashing the gateway. + logStream.on("error", () => {}); + } catch { + // If we can't open the log file, continue without file logging. + // The onEvent callback (if set) will still work. + logStream = null; + } +} + +/** + * Write a structured security event to the audit log. + */ +export function logSecurityEvent(type: SecurityEventType, detail: Record): void { + const event: SecurityEvent = { + timestamp: new Date().toISOString(), + type, + detail, + }; + if (logStream) { + logStream.write(JSON.stringify(event) + "\n"); + } + if (eventCallback) { + eventCallback(event); + } +} + +/** + * Close the audit log stream. Call on gateway shutdown. + */ +export function closeHardeningLogger(): void { + if (logStream) { + logStream.end(); + logStream = null; + } + logFilePath = null; + eventCallback = null; +} + +/** + * Get the current log file path (for status/diagnostics). + */ +export function getHardeningLogPath(): string | null { + return logFilePath; +} + +/** Reset internal state (test-only). */ +export function __resetHardeningLoggerForTest(): void { + closeHardeningLogger(); +} diff --git a/src/security/hardening.test.ts b/src/security/hardening.test.ts new file mode 100644 index 000000000..8bf92e8f5 --- /dev/null +++ b/src/security/hardening.test.ts @@ -0,0 +1,421 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { SecurityEvent } from "./hardening-logger.js"; +import { + __resetHardeningLoggerForTest, + initHardeningLogger, + logSecurityEvent, +} from "./hardening-logger.js"; +import { + __resetSingleUserEnforcerForTest, + hashSender, + initSingleUserEnforcer, + isAuthorizedSender, + isSingleUserEnforcerActive, +} from "./single-user-enforcer.js"; +import { + __resetNetworkMonitorForTest, + installNetworkMonitor, + isDomainAllowed, + isNetworkMonitorActive, +} from "./network-monitor.js"; +import { + __resetFsMonitorForTest, + auditFileAccess, + installFsMonitor, + isFsMonitorActive, + isSensitivePath, +} from "./fs-monitor.js"; +import { initHardening, isHardeningEnabled, teardownHardening } from "./hardening.js"; + +// --------------------------------------------------------------------------- +// Hardening Logger +// --------------------------------------------------------------------------- + +describe("hardening-logger", () => { + let tmpDir: string; + + beforeEach(async () => { + __resetHardeningLoggerForTest(); + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-hardening-log-")); + }); + + afterEach(async () => { + __resetHardeningLoggerForTest(); + await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + }); + + it("writes structured events to the log file", async () => { + const logFile = path.join(tmpDir, "test-security.log"); + initHardeningLogger({ logFile }); + + logSecurityEvent("hardening_init", { test: true }); + logSecurityEvent("blocked_sender", { sender: "test" }); + + // Give the write stream a moment to flush + await new Promise((resolve) => setTimeout(resolve, 50)); + + const content = await fs.promises.readFile(logFile, "utf-8"); + const lines = content.trim().split("\n").filter(Boolean); + expect(lines).toHaveLength(2); + + const first = JSON.parse(lines[0]) as SecurityEvent; + expect(first.type).toBe("hardening_init"); + expect(first.detail.test).toBe(true); + expect(first.timestamp).toBeTruthy(); + + const second = JSON.parse(lines[1]) as SecurityEvent; + expect(second.type).toBe("blocked_sender"); + }); + + it("invokes the onEvent callback", () => { + const events: SecurityEvent[] = []; + initHardeningLogger({ logFile: path.join(tmpDir, "cb.log"), onEvent: (e) => events.push(e) }); + + logSecurityEvent("hardening_init", { module: "test" }); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("hardening_init"); + }); + + it("does not crash when logging before init", () => { + // Should be a no-op, not a crash + logSecurityEvent("hardening_error", { error: "no init" }); + }); +}); + +// --------------------------------------------------------------------------- +// Single-User Enforcer +// --------------------------------------------------------------------------- + +describe("single-user-enforcer", () => { + const testPhone = "+8613800138000"; + const testHash = crypto.createHash("sha256").update(testPhone).digest("hex"); + + beforeEach(() => { + __resetSingleUserEnforcerForTest(); + __resetHardeningLoggerForTest(); + }); + + afterEach(() => { + __resetSingleUserEnforcerForTest(); + __resetHardeningLoggerForTest(); + }); + + it("hashSender produces correct SHA-256", () => { + const hash = hashSender(testPhone); + expect(hash).toBe(testHash); + expect(hash).toHaveLength(64); + }); + + it("rejects invalid hash in init", () => { + expect(() => initSingleUserEnforcer({ authorizedUserHash: "notahash" })).toThrow( + "64-char lowercase hex SHA-256", + ); + }); + + it("allows authorized sender", () => { + const events: SecurityEvent[] = []; + initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) }); + initSingleUserEnforcer({ authorizedUserHash: testHash }); + + expect(isSingleUserEnforcerActive()).toBe(true); + expect(isAuthorizedSender(testPhone)).toBe(true); + // No blocked_sender event should be emitted + expect(events.filter((e) => e.type === "blocked_sender")).toHaveLength(0); + }); + + it("blocks unauthorized sender", () => { + const events: SecurityEvent[] = []; + initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) }); + initSingleUserEnforcer({ authorizedUserHash: testHash }); + + expect(isAuthorizedSender("+1234567890")).toBe(false); + expect(events.filter((e) => e.type === "blocked_sender")).toHaveLength(1); + }); + + it("blocks all senders when not initialized (fail-closed)", () => { + const events: SecurityEvent[] = []; + initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) }); + + // Not initialized - should deny all + expect(isAuthorizedSender(testPhone)).toBe(false); + expect(events.filter((e) => e.type === "hardening_error")).toHaveLength(1); + }); + + it("uses constant-time comparison (functional check)", () => { + initSingleUserEnforcer({ authorizedUserHash: testHash }); + + // Verify it works correctly even with similar inputs + expect(isAuthorizedSender(testPhone)).toBe(true); + expect(isAuthorizedSender("+8613800138001")).toBe(false); + expect(isAuthorizedSender("+8613800138000 ")).toBe(false); // trailing space + expect(isAuthorizedSender("")).toBe(false); + }); + + it("is inactive before init", () => { + expect(isSingleUserEnforcerActive()).toBe(false); + }); + + it("accepts uppercase hash in init", () => { + const upperHash = testHash.toUpperCase(); + // Should normalize to lowercase + initSingleUserEnforcer({ authorizedUserHash: upperHash }); + expect(isSingleUserEnforcerActive()).toBe(true); + expect(isAuthorizedSender(testPhone)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Network Monitor +// --------------------------------------------------------------------------- + +describe("network-monitor", () => { + beforeEach(() => { + __resetNetworkMonitorForTest(); + __resetHardeningLoggerForTest(); + }); + + afterEach(() => { + __resetNetworkMonitorForTest(); + __resetHardeningLoggerForTest(); + }); + + it("allows whitelisted domains", () => { + installNetworkMonitor(); + expect(isDomainAllowed("api.anthropic.com")).toBe(true); + expect(isDomainAllowed("web.whatsapp.com")).toBe(true); + expect(isDomainAllowed("localhost")).toBe(true); + expect(isDomainAllowed("127.0.0.1")).toBe(true); + }); + + it("allows whatsapp subdomains via suffix match", () => { + installNetworkMonitor(); + expect(isDomainAllowed("mmg.whatsapp.net")).toBe(true); + expect(isDomainAllowed("media-ams2-1.cdn.whatsapp.net")).toBe(true); + expect(isDomainAllowed("something.whatsapp.com")).toBe(true); + }); + + it("blocks non-whitelisted domains", () => { + installNetworkMonitor(); + expect(isDomainAllowed("evil.com")).toBe(false); + expect(isDomainAllowed("attacker.example.org")).toBe(false); + expect(isDomainAllowed("api.openai.com")).toBe(false); // not in default list + }); + + it("allows extra domains when configured", () => { + installNetworkMonitor({ extraAllowedDomains: ["api.openai.com"] }); + expect(isDomainAllowed("api.openai.com")).toBe(true); + expect(isDomainAllowed("evil.com")).toBe(false); + }); + + it("allows extra suffixes when configured", () => { + installNetworkMonitor({ extraAllowedSuffixes: [".openai.com"] }); + expect(isDomainAllowed("api.openai.com")).toBe(true); + expect(isDomainAllowed("files.openai.com")).toBe(true); + expect(isDomainAllowed("evil.com")).toBe(false); + }); + + it("replaces default domains when allowedDomains is set", () => { + installNetworkMonitor({ allowedDomains: ["custom.example.com"] }); + expect(isDomainAllowed("custom.example.com")).toBe(true); + expect(isDomainAllowed("api.anthropic.com")).toBe(false); // default removed + }); + + it("is case-insensitive for domain matching", () => { + installNetworkMonitor(); + expect(isDomainAllowed("API.ANTHROPIC.COM")).toBe(true); + expect(isDomainAllowed("Web.WhatsApp.Com")).toBe(true); + }); + + it("returns true when not installed (passthrough)", () => { + expect(isDomainAllowed("anything.com")).toBe(true); + }); + + it("reports active status correctly", () => { + expect(isNetworkMonitorActive()).toBe(false); + installNetworkMonitor(); + expect(isNetworkMonitorActive()).toBe(true); + __resetNetworkMonitorForTest(); + expect(isNetworkMonitorActive()).toBe(false); + }); + + it("blocks fetch to non-whitelisted domain in enforce mode", async () => { + const events: SecurityEvent[] = []; + initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) }); + installNetworkMonitor({ enforce: true }); + + await expect(fetch("https://evil.com/steal-data")).rejects.toThrow( + "[network-monitor] Blocked outbound request to evil.com", + ); + expect(events.filter((e) => e.type === "blocked_network")).toHaveLength(1); + }); + + it("allows fetch to whitelisted domain", async () => { + installNetworkMonitor({ enforce: true }); + + // This should not throw (though the actual fetch may fail due to network) + // We just verify it doesn't throw a network-monitor error + try { + await fetch("https://api.anthropic.com/v1/messages", { signal: AbortSignal.timeout(100) }); + } catch (err) { + // Network error is expected (no real API key), but not a monitor block + expect(String(err)).not.toContain("[network-monitor]"); + } + }); +}); + +// --------------------------------------------------------------------------- +// File System Monitor +// --------------------------------------------------------------------------- + +describe("fs-monitor", () => { + beforeEach(() => { + __resetFsMonitorForTest(); + __resetHardeningLoggerForTest(); + }); + + afterEach(() => { + __resetFsMonitorForTest(); + __resetHardeningLoggerForTest(); + }); + + it("detects sensitive paths", () => { + installFsMonitor(); + + const home = os.homedir(); + expect(isSensitivePath(path.join(home, ".ssh", "id_rsa"))).toBe(true); + expect(isSensitivePath(path.join(home, ".aws", "credentials"))).toBe(true); + expect(isSensitivePath(path.join(home, ".gnupg", "pubring.kbx"))).toBe(true); + expect(isSensitivePath("/etc/shadow")).toBe(true); + }); + + it("does not flag non-sensitive paths", () => { + installFsMonitor(); + + expect(isSensitivePath("/tmp/safe-file.txt")).toBe(false); + expect(isSensitivePath(path.join(os.homedir(), "Documents", "notes.txt"))).toBe(false); + }); + + it("supports extra sensitive paths", () => { + installFsMonitor({ extraSensitivePaths: ["~/Documents/secret"] }); + + const secretPath = path.join(os.homedir(), "Documents", "secret", "file.txt"); + expect(isSensitivePath(secretPath)).toBe(true); + }); + + it("audit logs sensitive file access", () => { + const events: SecurityEvent[] = []; + initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) }); + installFsMonitor(); + + const sshKey = path.join(os.homedir(), ".ssh", "id_rsa"); + const result = auditFileAccess(sshKey, "read"); + expect(result).toBe(true); // default is audit-only (no blocking) + expect(events.filter((e) => e.type === "sensitive_file_access")).toHaveLength(1); + }); + + it("blocks in enforce mode", () => { + const events: SecurityEvent[] = []; + initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) }); + installFsMonitor({ enforce: true }); + + const sshKey = path.join(os.homedir(), ".ssh", "id_rsa"); + const result = auditFileAccess(sshKey, "read"); + expect(result).toBe(false); // blocked + expect(events.filter((e) => e.type === "sensitive_file_access")).toHaveLength(1); + expect(events[events.length - 1].detail.allowed).toBe(false); + }); + + it("does not log non-sensitive access", () => { + const events: SecurityEvent[] = []; + initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) }); + installFsMonitor(); + + auditFileAccess("/tmp/safe.txt", "read"); + expect(events.filter((e) => e.type === "sensitive_file_access")).toHaveLength(0); + }); + + it("returns true when not installed (passthrough)", () => { + expect(isSensitivePath(path.join(os.homedir(), ".ssh", "id_rsa"))).toBe(false); + expect(auditFileAccess("/anything", "read")).toBe(true); + }); + + it("reports active status correctly", () => { + expect(isFsMonitorActive()).toBe(false); + installFsMonitor(); + expect(isFsMonitorActive()).toBe(true); + __resetFsMonitorForTest(); + expect(isFsMonitorActive()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Hardening Integration (initHardening / isHardeningEnabled) +// --------------------------------------------------------------------------- + +describe("hardening integration", () => { + beforeEach(() => { + __resetSingleUserEnforcerForTest(); + __resetNetworkMonitorForTest(); + __resetFsMonitorForTest(); + __resetHardeningLoggerForTest(); + delete process.env.MOLTBOT_HARDENING_ENABLED; + delete process.env.CLAWDBOT_HARDENING_ENABLED; + delete process.env.MOLTBOT_AUTHORIZED_USER_HASH; + delete process.env.CLAWDBOT_AUTHORIZED_USER_HASH; + }); + + afterEach(() => { + teardownHardening(); + delete process.env.MOLTBOT_HARDENING_ENABLED; + delete process.env.CLAWDBOT_HARDENING_ENABLED; + delete process.env.MOLTBOT_AUTHORIZED_USER_HASH; + delete process.env.CLAWDBOT_AUTHORIZED_USER_HASH; + }); + + it("is disabled by default", () => { + expect(isHardeningEnabled({})).toBe(false); + }); + + it("can be enabled via config", () => { + expect(isHardeningEnabled({ security: { hardening: { enabled: true } } } as any)).toBe(true); + }); + + it("can be enabled via env var", () => { + process.env.MOLTBOT_HARDENING_ENABLED = "1"; + expect(isHardeningEnabled({})).toBe(true); + }); + + it("returns inactive when disabled", () => { + const result = initHardening({}); + expect(result.active).toBe(false); + }); + + it("initializes all modules when enabled with full config", () => { + const testHash = crypto.createHash("sha256").update("+1234567890").digest("hex"); + process.env.MOLTBOT_HARDENING_ENABLED = "1"; + process.env.MOLTBOT_AUTHORIZED_USER_HASH = testHash; + + const result = initHardening({}); + expect(result.active).toBe(true); + expect(result.singleUser).toBe(true); + expect(result.networkMonitor).toBe(true); + expect(result.fsMonitor).toBe(true); + expect(result.logPath).toBeTruthy(); + }); + + it("initializes without single-user when no hash is provided", () => { + process.env.MOLTBOT_HARDENING_ENABLED = "1"; + + const result = initHardening({}); + expect(result.active).toBe(true); + expect(result.singleUser).toBe(false); + expect(result.networkMonitor).toBe(true); + expect(result.fsMonitor).toBe(true); + }); +}); diff --git a/src/security/hardening.ts b/src/security/hardening.ts new file mode 100644 index 000000000..5528f55e8 --- /dev/null +++ b/src/security/hardening.ts @@ -0,0 +1,218 @@ +/** + * Security hardening module - entry point. + * + * Provides single-user authorization, network domain whitelisting, + * and file system access monitoring for secure single-user deployments. + * + * Enable by setting `security.hardening.enabled: true` in config or + * `MOLTBOT_HARDENING_ENABLED=1` environment variable. + * + * Configuration (in moltbot.json): + * security: { + * hardening: { + * enabled: true, + * authorizedUserHash: "", + * network: { + * enforce: true, + * extraAllowedDomains: ["api.openai.com"], + * extraAllowedSuffixes: [".openai.com"], + * logAllowed: false, + * }, + * filesystem: { + * enforce: false, + * extraSensitivePaths: ["~/Documents/secret"], + * }, + * }, + * } + * + * Or via environment variables: + * MOLTBOT_HARDENING_ENABLED=1 + * MOLTBOT_AUTHORIZED_USER_HASH= + * MOLTBOT_HARDENING_NETWORK_ENFORCE=1 + * MOLTBOT_HARDENING_FS_ENFORCE=0 + */ + +import type { MoltbotConfig } from "../config/config.js"; +import { + closeHardeningLogger, + getHardeningLogPath, + initHardeningLogger, + logSecurityEvent, +} from "./hardening-logger.js"; +import { initSingleUserEnforcer, isSingleUserEnforcerActive } from "./single-user-enforcer.js"; +import { + installNetworkMonitor, + isNetworkMonitorActive, + uninstallNetworkMonitor, +} from "./network-monitor.js"; +import { installFsMonitor, isFsMonitorActive, uninstallFsMonitor } from "./fs-monitor.js"; + +export type HardeningConfig = { + enabled?: boolean; + /** SHA-256 hex hash of the authorized user's E.164 phone number. */ + authorizedUserHash?: string; + network?: { + enforce?: boolean; + extraAllowedDomains?: string[]; + extraAllowedSuffixes?: string[]; + logAllowed?: boolean; + }; + filesystem?: { + enforce?: boolean; + extraSensitivePaths?: string[]; + }; +}; + +/** + * Extract hardening config from the app config and environment. + */ +function resolveHardeningConfig(cfg: MoltbotConfig): HardeningConfig { + const security = (cfg as Record).security as Record | undefined; + const hardening = security?.hardening as HardeningConfig | undefined; + return hardening ?? {}; +} + +/** + * Check if hardening is enabled via config or environment. + */ +export function isHardeningEnabled(cfg: MoltbotConfig): boolean { + const config = resolveHardeningConfig(cfg); + if (config.enabled === true) return true; + if (config.enabled === false) return false; + // Also check environment variable + const envFlag = process.env.MOLTBOT_HARDENING_ENABLED ?? process.env.CLAWDBOT_HARDENING_ENABLED; + return envFlag === "1" || envFlag === "true"; +} + +/** + * Initialize all security hardening modules. + * Should be called very early in gateway startup, before any I/O. + */ +export function initHardening(cfg: MoltbotConfig): { + active: boolean; + singleUser: boolean; + networkMonitor: boolean; + fsMonitor: boolean; + logPath: string | null; +} { + if (!isHardeningEnabled(cfg)) { + return { + active: false, + singleUser: false, + networkMonitor: false, + fsMonitor: false, + logPath: null, + }; + } + + const config = resolveHardeningConfig(cfg); + + // 1. Initialize audit logger first (all other modules depend on it). + initHardeningLogger(); + + logSecurityEvent("hardening_init", { + module: "hardening", + message: "Security hardening starting", + }); + + // 2. Single-user enforcer (if hash is configured). + const userHash = + config.authorizedUserHash ?? + process.env.MOLTBOT_AUTHORIZED_USER_HASH ?? + process.env.CLAWDBOT_AUTHORIZED_USER_HASH; + + let singleUserActive = false; + if (userHash) { + try { + initSingleUserEnforcer({ authorizedUserHash: userHash }); + singleUserActive = true; + } catch (err) { + logSecurityEvent("hardening_error", { + module: "single-user-enforcer", + error: String(err), + }); + } + } + + // 3. Network monitor. + let networkActive = false; + try { + const networkEnforceEnv = + process.env.MOLTBOT_HARDENING_NETWORK_ENFORCE ?? + process.env.CLAWDBOT_HARDENING_NETWORK_ENFORCE; + installNetworkMonitor({ + enforce: + config.network?.enforce ?? (networkEnforceEnv === "1" || networkEnforceEnv === "true"), + extraAllowedDomains: config.network?.extraAllowedDomains, + extraAllowedSuffixes: config.network?.extraAllowedSuffixes, + logAllowed: config.network?.logAllowed ?? false, + }); + networkActive = true; + } catch (err) { + logSecurityEvent("hardening_error", { + module: "network-monitor", + error: String(err), + }); + } + + // 4. File system monitor. + let fsActive = false; + try { + const fsEnforceEnv = + process.env.MOLTBOT_HARDENING_FS_ENFORCE ?? process.env.CLAWDBOT_HARDENING_FS_ENFORCE; + installFsMonitor({ + enforce: config.filesystem?.enforce ?? (fsEnforceEnv === "1" || fsEnforceEnv === "true"), + extraSensitivePaths: config.filesystem?.extraSensitivePaths, + }); + fsActive = true; + } catch (err) { + logSecurityEvent("hardening_error", { + module: "fs-monitor", + error: String(err), + }); + } + + logSecurityEvent("hardening_init", { + module: "hardening", + message: "Security hardening initialized", + singleUser: singleUserActive, + networkMonitor: networkActive, + fsMonitor: fsActive, + }); + + return { + active: true, + singleUser: singleUserActive, + networkMonitor: networkActive, + fsMonitor: fsActive, + logPath: getHardeningLogPath(), + }; +} + +/** + * Tear down all hardening modules. Call on gateway shutdown. + */ +export function teardownHardening(): void { + uninstallNetworkMonitor(); + uninstallFsMonitor(); + closeHardeningLogger(); +} + +/** + * Get the current hardening status for diagnostics. + */ +export function getHardeningStatus(): { + active: boolean; + singleUser: boolean; + networkMonitor: boolean; + fsMonitor: boolean; + logPath: string | null; +} { + return { + active: isSingleUserEnforcerActive() || isNetworkMonitorActive() || isFsMonitorActive(), + singleUser: isSingleUserEnforcerActive(), + networkMonitor: isNetworkMonitorActive(), + fsMonitor: isFsMonitorActive(), + logPath: getHardeningLogPath(), + }; +} diff --git a/src/security/network-monitor.ts b/src/security/network-monitor.ts new file mode 100644 index 000000000..641152506 --- /dev/null +++ b/src/security/network-monitor.ts @@ -0,0 +1,237 @@ +import http from "node:http"; +import https from "node:https"; + +import { logSecurityEvent } from "./hardening-logger.js"; + +/** + * Default domain whitelist for single-user secure mode. + * Only these domains (and their subdomains) are allowed for outbound requests. + */ +const DEFAULT_ALLOWED_DOMAINS: string[] = [ + // Claude / Anthropic API + "api.anthropic.com", + // WhatsApp Web protocol (Baileys) + "web.whatsapp.com", + "w1.web.whatsapp.com", + "w2.web.whatsapp.com", + "w3.web.whatsapp.com", + "w4.web.whatsapp.com", + "w5.web.whatsapp.com", + "w6.web.whatsapp.com", + "w7.web.whatsapp.com", + "w8.web.whatsapp.com", + "w9.web.whatsapp.com", + // WhatsApp media servers + "mmg.whatsapp.net", + "media.whatsapp.com", + "media-ams2-1.cdn.whatsapp.net", + // WhatsApp general + "g.whatsapp.net", + "v.whatsapp.net", + "pps.whatsapp.net", + // Localhost / loopback (gateway internal) + "localhost", + "127.0.0.1", + "::1", + "[::1]", +]; + +/** Suffix patterns that match any subdomain (e.g. ".whatsapp.net" matches "mmg.whatsapp.net") */ +const DEFAULT_ALLOWED_SUFFIXES: string[] = [".whatsapp.net", ".whatsapp.com", ".cdn.whatsapp.net"]; + +export type NetworkMonitorOptions = { + /** Extra domains to allow beyond the defaults. */ + extraAllowedDomains?: string[]; + /** Extra domain suffixes to allow (e.g. ".openai.com"). */ + extraAllowedSuffixes?: string[]; + /** Replace the default whitelist entirely. */ + allowedDomains?: string[]; + /** Replace the default suffix whitelist entirely. */ + allowedSuffixes?: string[]; + /** If true, log allowed requests too (verbose). Default: false. */ + logAllowed?: boolean; + /** If true, block disallowed requests. If false, only log (monitor mode). Default: true. */ + enforce?: boolean; +}; + +let allowedDomainSet: Set | null = null; +let allowedSuffixList: string[] | null = null; +let logAllowedRequests = false; +let enforceMode = true; +let originalFetch: typeof globalThis.fetch | null = null; +let originalHttpRequest: typeof http.request | null = null; +let originalHttpsRequest: typeof https.request | null = null; +let installed = false; + +/** + * Check if a hostname is allowed by the whitelist. + */ +export function isDomainAllowed(hostname: string): boolean { + if (!allowedDomainSet || !allowedSuffixList) return true; // not initialized = passthrough + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + if (!normalized) return false; + if (allowedDomainSet.has(normalized)) return true; + for (const suffix of allowedSuffixList) { + if (normalized.endsWith(suffix)) return true; + } + return false; +} + +/** + * Extract hostname from a URL string or Request object. + */ +function extractHostname(input: string | URL | Request): string | null { + try { + const urlStr = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const parsed = new URL(urlStr); + return parsed.hostname; + } catch { + return null; + } +} + +/** + * Install the network monitor by wrapping global.fetch and http(s).request. + */ +export function installNetworkMonitor(opts?: NetworkMonitorOptions): void { + if (installed) return; + + const domains = opts?.allowedDomains ?? [ + ...DEFAULT_ALLOWED_DOMAINS, + ...(opts?.extraAllowedDomains ?? []), + ]; + const suffixes = opts?.allowedSuffixes ?? [ + ...DEFAULT_ALLOWED_SUFFIXES, + ...(opts?.extraAllowedSuffixes ?? []), + ]; + + allowedDomainSet = new Set(domains.map((d) => d.trim().toLowerCase())); + allowedSuffixList = suffixes.map((s) => s.trim().toLowerCase()); + logAllowedRequests = opts?.logAllowed ?? false; + enforceMode = opts?.enforce !== false; + + // Wrap global.fetch + originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const hostname = extractHostname(input as string | URL | Request); + if (hostname && !isDomainAllowed(hostname)) { + logSecurityEvent("blocked_network", { + method: "fetch", + hostname, + url: String( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ).slice(0, 200), + }); + if (enforceMode) { + throw new Error(`[network-monitor] Blocked outbound request to ${hostname}`); + } + } else if (hostname && logAllowedRequests) { + logSecurityEvent("allowed_network", { + method: "fetch", + hostname, + }); + } + return originalFetch!(input, init); + }) as typeof globalThis.fetch; + + // Wrap http.request + originalHttpRequest = http.request; + (http as { request: typeof http.request }).request = ((...args: unknown[]) => { + const hostname = extractHostnameFromHttpArgs(args); + if (hostname && !isDomainAllowed(hostname)) { + logSecurityEvent("blocked_network", { method: "http.request", hostname }); + if (enforceMode) { + throw new Error(`[network-monitor] Blocked outbound HTTP request to ${hostname}`); + } + } else if (hostname && logAllowedRequests) { + logSecurityEvent("allowed_network", { method: "http.request", hostname }); + } + return (originalHttpRequest as Function).apply(http, args); + }) as typeof http.request; + + // Wrap https.request + originalHttpsRequest = https.request; + (https as { request: typeof https.request }).request = ((...args: unknown[]) => { + const hostname = extractHostnameFromHttpArgs(args); + if (hostname && !isDomainAllowed(hostname)) { + logSecurityEvent("blocked_network", { method: "https.request", hostname }); + if (enforceMode) { + throw new Error(`[network-monitor] Blocked outbound HTTPS request to ${hostname}`); + } + } else if (hostname && logAllowedRequests) { + logSecurityEvent("allowed_network", { method: "https.request", hostname }); + } + return (originalHttpsRequest as Function).apply(https, args); + }) as typeof https.request; + + installed = true; + + logSecurityEvent("hardening_init", { + module: "network-monitor", + allowedDomainCount: allowedDomainSet.size, + allowedSuffixCount: allowedSuffixList.length, + enforce: enforceMode, + }); +} + +/** + * Extract hostname from http.request / https.request arguments. + * Signature: (url, options, callback) | (options, callback) | (url, callback) + */ +function extractHostnameFromHttpArgs(args: unknown[]): string | null { + const first = args[0]; + if (typeof first === "string") { + try { + return new URL(first).hostname; + } catch { + return null; + } + } + if (first instanceof URL) { + return first.hostname; + } + if (first && typeof first === "object") { + const opts = first as Record; + if (typeof opts.hostname === "string") return opts.hostname; + if (typeof opts.host === "string") { + // host may include port + return opts.host.replace(/:\d+$/, ""); + } + } + return null; +} + +/** + * Uninstall the network monitor, restoring original functions. + */ +export function uninstallNetworkMonitor(): void { + if (!installed) return; + if (originalFetch) { + globalThis.fetch = originalFetch; + originalFetch = null; + } + if (originalHttpRequest) { + (http as { request: typeof http.request }).request = originalHttpRequest; + originalHttpRequest = null; + } + if (originalHttpsRequest) { + (https as { request: typeof https.request }).request = originalHttpsRequest; + originalHttpsRequest = null; + } + allowedDomainSet = null; + allowedSuffixList = null; + installed = false; +} + +/** + * Check if the network monitor is currently active. + */ +export function isNetworkMonitorActive(): boolean { + return installed; +} + +/** Reset internal state (test-only). */ +export function __resetNetworkMonitorForTest(): void { + uninstallNetworkMonitor(); +} diff --git a/src/security/single-user-enforcer.ts b/src/security/single-user-enforcer.ts new file mode 100644 index 000000000..fca61bc20 --- /dev/null +++ b/src/security/single-user-enforcer.ts @@ -0,0 +1,86 @@ +import crypto from "node:crypto"; + +import { logSecurityEvent } from "./hardening-logger.js"; + +export type SingleUserEnforcerOptions = { + /** + * SHA-256 hex hash of the authorized WhatsApp JID (e.g. "+8613800138000"). + * When set, only messages from this JID are allowed; everything else is silently dropped. + * Generate with: echo -n "+8613800138000" | sha256sum + */ + authorizedUserHash: string; +}; + +let enforcerHash: string | null = null; + +/** + * Hash a sender identifier with SHA-256. + */ +export function hashSender(sender: string): string { + return crypto.createHash("sha256").update(sender).digest("hex"); +} + +/** + * Initialize the single-user enforcer. + * Must be called before any message processing. + */ +export function initSingleUserEnforcer(opts: SingleUserEnforcerOptions): void { + const hash = opts.authorizedUserHash.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(hash)) { + throw new Error( + "single-user-enforcer: authorizedUserHash must be a 64-char lowercase hex SHA-256 hash", + ); + } + enforcerHash = hash; + logSecurityEvent("hardening_init", { + module: "single-user-enforcer", + hashPrefix: hash.slice(0, 8) + "...", + }); +} + +/** + * Check whether a sender is the authorized user. + * Uses constant-time comparison to prevent timing attacks. + * + * @param senderIdentifier - The sender's normalized identifier (e.g. E.164 phone number "+8613800138000") + * @returns true if authorized, false otherwise + */ +export function isAuthorizedSender(senderIdentifier: string): boolean { + if (!enforcerHash) { + // Enforcer not initialized - fail closed (deny all). + logSecurityEvent("hardening_error", { + module: "single-user-enforcer", + error: "enforcer not initialized, denying all", + }); + return false; + } + + const senderHash = hashSender(senderIdentifier); + + // Constant-time comparison to prevent timing side-channel attacks. + const authorized = crypto.timingSafeEqual( + Buffer.from(senderHash, "hex"), + Buffer.from(enforcerHash, "hex"), + ); + + if (!authorized) { + logSecurityEvent("blocked_sender", { + senderHashPrefix: senderHash.slice(0, 8) + "...", + senderIdentifier: senderIdentifier.slice(0, 4) + "****", + }); + } + + return authorized; +} + +/** + * Check if the single-user enforcer is active. + */ +export function isSingleUserEnforcerActive(): boolean { + return enforcerHash !== null; +} + +/** Reset internal state (test-only). */ +export function __resetSingleUserEnforcerForTest(): void { + enforcerHash = null; +} diff --git a/src/web/inbound/monitor.ts b/src/web/inbound/monitor.ts index 3633cbce9..c6c4cd6d1 100644 --- a/src/web/inbound/monitor.ts +++ b/src/web/inbound/monitor.ts @@ -9,6 +9,10 @@ import { saveMediaBuffer } from "../../media/store.js"; import { createInboundDebouncer } from "../../auto-reply/inbound-debounce.js"; import { jidToE164, resolveJidToE164 } from "../../utils.js"; import { createWaSocket, getStatusCode, waitForWaConnection } from "../session.js"; +import { + isAuthorizedSender, + isSingleUserEnforcerActive, +} from "../../security/single-user-enforcer.js"; import { checkInboundAccessControl } from "./access-control.js"; import { isRecentInboundMessage } from "./dedupe.js"; import { @@ -166,6 +170,13 @@ export async function monitorWebInbox(options: { : null : from; + // Single-user enforcer: silently drop messages from non-authorized senders. + // This runs before access-control to avoid any response/pairing leakage. + if (isSingleUserEnforcerActive()) { + const senderToCheck = senderE164 ?? from; + if (!isAuthorizedSender(senderToCheck)) continue; + } + let groupSubject: string | undefined; let groupParticipants: string[] | undefined; if (group) {