feat(security): add hardening modules for single-user auth, network whitelist, and fs monitoring
This commit is contained in:
parent
9688454a30
commit
29d74bfa54
@ -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
170
src/security/fs-monitor.ts
Normal 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();
|
||||
}
|
||||
99
src/security/hardening-logger.ts
Normal file
99
src/security/hardening-logger.ts
Normal 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();
|
||||
}
|
||||
421
src/security/hardening.test.ts
Normal file
421
src/security/hardening.test.ts
Normal 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
218
src/security/hardening.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
237
src/security/network-monitor.ts
Normal file
237
src/security/network-monitor.ts
Normal 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();
|
||||
}
|
||||
86
src/security/single-user-enforcer.ts
Normal file
86
src/security/single-user-enforcer.ts
Normal 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;
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user