feat(security): harden modules with WebSocket hook, expanded fs monitoring, log rotation, and stack traces

This commit is contained in:
Claude 2026-01-28 09:56:59 +00:00
parent 29d74bfa54
commit 4325cac7e4
No known key found for this signature in database
4 changed files with 354 additions and 36 deletions

View File

@ -40,8 +40,13 @@ 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;
// Original function references for restoration
let origReadFileSync: typeof fs.readFileSync | null = null;
let origWriteFileSync: typeof fs.writeFileSync | null = null;
let origReadFile: typeof fs.promises.readFile | null = null;
let origWriteFile: typeof fs.promises.writeFile | null = null;
let origStat: typeof fs.promises.stat | null = null;
let origUnlink: typeof fs.promises.unlink | null = null;
/**
* Resolve a path that may start with ~.
@ -53,12 +58,25 @@ function expandHome(p: string): string {
return p;
}
/**
* Resolve a file path, following symlinks where possible to prevent bypass via symlinks.
*/
function resolveRealPath(filePath: string): string {
const resolved = path.resolve(filePath);
try {
return fs.realpathSync.native(resolved);
} catch {
// If realpath fails (e.g. file doesn't exist yet), fall back to path.resolve
return resolved;
}
}
/**
* 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);
const normalized = resolveRealPath(filePath);
for (const sensitive of resolvedSensitivePaths) {
if (normalized === sensitive || normalized.startsWith(sensitive + path.sep)) {
return true;
@ -76,22 +94,38 @@ export function auditFileAccess(
operation: "read" | "write" | "stat" | "readdir" | "unlink",
): boolean {
if (!resolvedSensitivePaths) return true;
const normalized = path.resolve(filePath);
const normalized = resolveRealPath(filePath);
if (!isSensitivePath(normalized)) return true;
logSecurityEvent("sensitive_file_access", {
operation,
path: normalized,
allowed: !enforceMode,
stackTrace: new Error().stack?.split("\n").slice(2, 7).join("\n"),
});
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.
* Create a guard wrapper that audits the first arg (file path) before calling the original.
*/
function createGuard(
operation: "read" | "write" | "stat" | "readdir" | "unlink",
): (filePath: unknown) => void {
return (filePath: unknown) => {
if (typeof filePath === "string") {
const allowed = auditFileAccess(filePath, operation);
if (!allowed) {
throw new Error(`[fs-monitor] Blocked ${operation} access to sensitive path: ${filePath}`);
}
}
};
}
/**
* Install fs monitoring hooks for sensitive path auditing.
* Hooks both sync and async versions of: readFile, writeFile, stat, unlink.
*/
export function installFsMonitor(opts?: FsMonitorOptions): void {
if (installed) return;
@ -103,40 +137,73 @@ export function installFsMonitor(opts?: FsMonitorOptions): void {
resolvedSensitivePaths = rawPaths.map((p) => path.resolve(expandHome(p)));
enforceMode = opts?.enforce ?? false;
// Wrap fs.readFileSync for synchronous reads
originalReadFile = fs.readFileSync;
const readGuard = createGuard("read");
const writeGuard = createGuard("write");
const statGuard = createGuard("stat");
const unlinkGuard = createGuard("unlink");
// Sync hooks
origReadFileSync = fs.readFileSync;
const origRFS = origReadFileSync;
(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);
readGuard(args[0]);
return (origRFS as Function).apply(fs, args);
}) as typeof fs.readFileSync;
// Wrap fs.promises.readFile for async reads
originalReadFileAsync = fs.promises.readFile;
origWriteFileSync = fs.writeFileSync;
const origWFS = origWriteFileSync;
(fs as { writeFileSync: typeof fs.writeFileSync }).writeFileSync = ((...args: unknown[]) => {
writeGuard(args[0]);
return (origWFS as Function).apply(fs, args);
}) as typeof fs.writeFileSync;
// Async hooks (fs.promises)
origReadFile = fs.promises.readFile;
const origRF = origReadFile;
(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);
readGuard(args[0]);
return (origRF as Function).apply(fs.promises, args);
}) as typeof fs.promises.readFile;
origWriteFile = fs.promises.writeFile;
const origWF = origWriteFile;
(fs.promises as { writeFile: typeof fs.promises.writeFile }).writeFile = (async (
...args: unknown[]
) => {
writeGuard(args[0]);
return (origWF as Function).apply(fs.promises, args);
}) as typeof fs.promises.writeFile;
origStat = fs.promises.stat;
const origST = origStat;
(fs.promises as { stat: typeof fs.promises.stat }).stat = (async (...args: unknown[]) => {
statGuard(args[0]);
return (origST as Function).apply(fs.promises, args);
}) as typeof fs.promises.stat;
origUnlink = fs.promises.unlink;
const origUL = origUnlink;
(fs.promises as { unlink: typeof fs.promises.unlink }).unlink = (async (...args: unknown[]) => {
unlinkGuard(args[0]);
return (origUL as Function).apply(fs.promises, args);
}) as typeof fs.promises.unlink;
installed = true;
logSecurityEvent("hardening_init", {
module: "fs-monitor",
sensitivePathCount: resolvedSensitivePaths.length,
enforce: enforceMode,
hookedMethods: [
"readFileSync",
"writeFileSync",
"promises.readFile",
"promises.writeFile",
"promises.stat",
"promises.unlink",
],
});
}
@ -145,13 +212,29 @@ export function installFsMonitor(opts?: FsMonitorOptions): void {
*/
export function uninstallFsMonitor(): void {
if (!installed) return;
if (originalReadFile) {
(fs as { readFileSync: typeof fs.readFileSync }).readFileSync = originalReadFile;
originalReadFile = null;
if (origReadFileSync) {
(fs as { readFileSync: typeof fs.readFileSync }).readFileSync = origReadFileSync;
origReadFileSync = null;
}
if (originalReadFileAsync) {
(fs.promises as { readFile: typeof fs.promises.readFile }).readFile = originalReadFileAsync;
originalReadFileAsync = null;
if (origWriteFileSync) {
(fs as { writeFileSync: typeof fs.writeFileSync }).writeFileSync = origWriteFileSync;
origWriteFileSync = null;
}
if (origReadFile) {
(fs.promises as { readFile: typeof fs.promises.readFile }).readFile = origReadFile;
origReadFile = null;
}
if (origWriteFile) {
(fs.promises as { writeFile: typeof fs.promises.writeFile }).writeFile = origWriteFile;
origWriteFile = null;
}
if (origStat) {
(fs.promises as { stat: typeof fs.promises.stat }).stat = origStat;
origStat = null;
}
if (origUnlink) {
(fs.promises as { unlink: typeof fs.promises.unlink }).unlink = origUnlink;
origUnlink = null;
}
resolvedSensitivePaths = null;
installed = false;

View File

@ -22,11 +22,23 @@ export type HardeningLoggerOptions = {
logFile?: string;
/** Also emit events to this callback (for tests). */
onEvent?: (event: SecurityEvent) => void;
/** Max log file size in bytes before rotation. Default: 10 MB. */
maxFileSizeBytes?: number;
/** Number of rotated log files to keep. Default: 3. */
maxRotatedFiles?: number;
};
/** Default max log file size: 10 MB */
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
/** Default number of rotated files to keep */
const DEFAULT_MAX_ROTATED = 3;
let logStream: fs.WriteStream | null = null;
let logFilePath: string | null = null;
let eventCallback: ((event: SecurityEvent) => void) | null = null;
let bytesWritten = 0;
let maxFileSize = DEFAULT_MAX_FILE_SIZE;
let maxRotated = DEFAULT_MAX_ROTATED;
/**
* Resolve the default security log path.
@ -36,6 +48,43 @@ function defaultLogPath(): string {
return path.join(stateDir, "security-audit.log");
}
/**
* Rotate the log file when it exceeds the size limit.
* Renames: .log -> .log.1, .log.1 -> .log.2, etc. Deletes oldest.
*/
function rotateLogFile(): void {
if (!logFilePath || !logStream) return;
try {
logStream.end();
logStream = null;
// Shift existing rotated files
for (let i = maxRotated; i >= 1; i--) {
const older = `${logFilePath}.${i}`;
if (i === maxRotated) {
try {
fs.unlinkSync(older);
} catch {
// ignore
}
}
const newer = i === 1 ? logFilePath : `${logFilePath}.${i - 1}`;
try {
fs.renameSync(newer, older);
} catch {
// ignore (file may not exist)
}
}
// Open a fresh log file
logStream = fs.createWriteStream(logFilePath, { flags: "a", mode: 0o600 });
logStream.on("error", () => {});
bytesWritten = 0;
} catch {
// If rotation fails, continue with current stream
}
}
/**
* Initialize the hardening audit logger.
* Call once at gateway startup, before any other security module.
@ -44,12 +93,20 @@ export function initHardeningLogger(opts?: HardeningLoggerOptions): void {
if (logStream) return; // already initialized
logFilePath = opts?.logFile ?? defaultLogPath();
eventCallback = opts?.onEvent ?? null;
maxFileSize = opts?.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE;
maxRotated = opts?.maxRotatedFiles ?? DEFAULT_MAX_ROTATED;
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", () => {});
// Track current file size for rotation
try {
bytesWritten = fs.statSync(logFilePath).size;
} catch {
bytesWritten = 0;
}
} catch {
// If we can't open the log file, continue without file logging.
// The onEvent callback (if set) will still work.
@ -67,7 +124,13 @@ export function logSecurityEvent(type: SecurityEventType, detail: Record<string,
detail,
};
if (logStream) {
logStream.write(JSON.stringify(event) + "\n");
const line = JSON.stringify(event) + "\n";
logStream.write(line);
bytesWritten += Buffer.byteLength(line, "utf-8");
// Rotate if file exceeds max size
if (bytesWritten >= maxFileSize) {
rotateLogFile();
}
}
if (eventCallback) {
eventCallback(event);
@ -84,6 +147,7 @@ export function closeHardeningLogger(): void {
}
logFilePath = null;
eventCallback = null;
bytesWritten = 0;
}
/**

View File

@ -352,6 +352,140 @@ describe("fs-monitor", () => {
__resetFsMonitorForTest();
expect(isFsMonitorActive()).toBe(false);
});
it("audits write operations to sensitive paths", () => {
const events: SecurityEvent[] = [];
initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) });
installFsMonitor();
const awsCreds = path.join(os.homedir(), ".aws", "credentials");
auditFileAccess(awsCreds, "write");
const writeEvents = events.filter(
(e) => e.type === "sensitive_file_access" && e.detail.operation === "write",
);
expect(writeEvents).toHaveLength(1);
});
it("audits stat/unlink operations on sensitive paths", () => {
const events: SecurityEvent[] = [];
initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) });
installFsMonitor();
const sshKey = path.join(os.homedir(), ".ssh", "id_rsa");
auditFileAccess(sshKey, "stat");
auditFileAccess(sshKey, "unlink");
const fsEvents = events.filter((e) => e.type === "sensitive_file_access");
expect(fsEvents).toHaveLength(2);
expect(fsEvents[0].detail.operation).toBe("stat");
expect(fsEvents[1].detail.operation).toBe("unlink");
});
it("includes stack trace in audit events", () => {
const events: SecurityEvent[] = [];
initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) });
installFsMonitor();
const sshKey = path.join(os.homedir(), ".ssh", "id_rsa");
auditFileAccess(sshKey, "read");
const fsEvent = events.find((e) => e.type === "sensitive_file_access");
expect(fsEvent).toBeDefined();
expect(fsEvent!.detail.stackTrace).toBeTruthy();
expect(typeof fsEvent!.detail.stackTrace).toBe("string");
});
it("normalizes paths with .. to detect traversal", () => {
installFsMonitor();
const home = os.homedir();
// Path traversal: ~/Documents/../.ssh/id_rsa resolves to ~/.ssh/id_rsa
const traversalPath = path.join(home, "Documents", "..", ".ssh", "id_rsa");
expect(isSensitivePath(traversalPath)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Log Rotation
// ---------------------------------------------------------------------------
describe("hardening-logger rotation", () => {
let tmpDir: string;
beforeEach(async () => {
__resetHardeningLoggerForTest();
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-log-rotation-"));
});
afterEach(async () => {
__resetHardeningLoggerForTest();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
it("continues logging after rotation without crashing", () => {
const events: SecurityEvent[] = [];
const logFile = path.join(tmpDir, "test.log");
// Tiny threshold so rotation triggers quickly
initHardeningLogger({
logFile,
maxFileSizeBytes: 50,
maxRotatedFiles: 2,
onEvent: (e) => events.push(e),
});
// Write many events to trigger multiple rotations
for (let i = 0; i < 20; i++) {
logSecurityEvent("hardening_init", { iteration: i, padding: "x".repeat(30) });
}
// All events should be captured by the callback regardless of rotation
expect(events).toHaveLength(20);
// Logger should still be functional (no crash from rotation)
logSecurityEvent("hardening_init", { final: true });
expect(events).toHaveLength(21);
});
});
// ---------------------------------------------------------------------------
// Network Monitor - Stack Traces & WebSocket
// ---------------------------------------------------------------------------
describe("network-monitor stack traces", () => {
beforeEach(() => {
__resetNetworkMonitorForTest();
__resetHardeningLoggerForTest();
});
afterEach(() => {
__resetNetworkMonitorForTest();
__resetHardeningLoggerForTest();
});
it("includes stack trace in blocked fetch events", async () => {
const events: SecurityEvent[] = [];
initHardeningLogger({ logFile: "/dev/null", onEvent: (e) => events.push(e) });
installNetworkMonitor({ enforce: true });
try {
await fetch("https://evil.com/data");
} catch {
// expected
}
const blocked = events.find((e) => e.type === "blocked_network");
expect(blocked).toBeDefined();
expect(blocked!.detail.stackTrace).toBeTruthy();
expect(typeof blocked!.detail.stackTrace).toBe("string");
});
it("blocks IP address access (not in whitelist)", () => {
installNetworkMonitor();
// Raw IP addresses should not be allowed unless explicitly whitelisted
expect(isDomainAllowed("1.2.3.4")).toBe(false);
expect(isDomainAllowed("192.168.1.1")).toBe(false);
// But loopback is whitelisted
expect(isDomainAllowed("127.0.0.1")).toBe(true);
});
});
// ---------------------------------------------------------------------------

View File

@ -61,6 +61,7 @@ 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 originalWebSocket: typeof globalThis.WebSocket | null = null;
let installed = false;
/**
@ -122,6 +123,7 @@ export function installNetworkMonitor(opts?: NetworkMonitorOptions): void {
url: String(
typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
).slice(0, 200),
stackTrace: new Error().stack?.split("\n").slice(1, 6).join("\n"),
});
if (enforceMode) {
throw new Error(`[network-monitor] Blocked outbound request to ${hostname}`);
@ -140,7 +142,11 @@ export function installNetworkMonitor(opts?: NetworkMonitorOptions): void {
(http as { request: typeof http.request }).request = ((...args: unknown[]) => {
const hostname = extractHostnameFromHttpArgs(args);
if (hostname && !isDomainAllowed(hostname)) {
logSecurityEvent("blocked_network", { method: "http.request", hostname });
logSecurityEvent("blocked_network", {
method: "http.request",
hostname,
stackTrace: new Error().stack?.split("\n").slice(1, 6).join("\n"),
});
if (enforceMode) {
throw new Error(`[network-monitor] Blocked outbound HTTP request to ${hostname}`);
}
@ -155,7 +161,11 @@ export function installNetworkMonitor(opts?: NetworkMonitorOptions): void {
(https as { request: typeof https.request }).request = ((...args: unknown[]) => {
const hostname = extractHostnameFromHttpArgs(args);
if (hostname && !isDomainAllowed(hostname)) {
logSecurityEvent("blocked_network", { method: "https.request", hostname });
logSecurityEvent("blocked_network", {
method: "https.request",
hostname,
stackTrace: new Error().stack?.split("\n").slice(1, 6).join("\n"),
});
if (enforceMode) {
throw new Error(`[network-monitor] Blocked outbound HTTPS request to ${hostname}`);
}
@ -165,6 +175,29 @@ export function installNetworkMonitor(opts?: NetworkMonitorOptions): void {
return (originalHttpsRequest as Function).apply(https, args);
}) as typeof https.request;
// Wrap WebSocket constructor (Baileys uses WebSocket for WhatsApp protocol)
if (typeof globalThis.WebSocket !== "undefined") {
originalWebSocket = globalThis.WebSocket;
const OrigWs = originalWebSocket;
globalThis.WebSocket = new Proxy(OrigWs, {
construct(target, args) {
const url = args[0];
const hostname = typeof url === "string" ? extractHostname(url) : null;
if (hostname && !isDomainAllowed(hostname)) {
logSecurityEvent("blocked_network", {
method: "WebSocket",
hostname,
stackTrace: new Error().stack?.split("\n").slice(1, 6).join("\n"),
});
if (enforceMode) {
throw new Error(`[network-monitor] Blocked WebSocket connection to ${hostname}`);
}
}
return Reflect.construct(target, args);
},
}) as typeof globalThis.WebSocket;
}
installed = true;
logSecurityEvent("hardening_init", {
@ -219,6 +252,10 @@ export function uninstallNetworkMonitor(): void {
(https as { request: typeof https.request }).request = originalHttpsRequest;
originalHttpsRequest = null;
}
if (originalWebSocket) {
globalThis.WebSocket = originalWebSocket;
originalWebSocket = null;
}
allowedDomainSet = null;
allowedSuffixList = null;
installed = false;