feat(security): harden modules with WebSocket hook, expanded fs monitoring, log rotation, and stack traces
This commit is contained in:
parent
29d74bfa54
commit
4325cac7e4
@ -40,8 +40,13 @@ let resolvedSensitivePaths: string[] | null = null;
|
|||||||
let enforceMode = false;
|
let enforceMode = false;
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
let originalReadFile: typeof fs.readFileSync | null = null;
|
// Original function references for restoration
|
||||||
let originalReadFileAsync: typeof fs.promises.readFile | null = null;
|
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 ~.
|
* Resolve a path that may start with ~.
|
||||||
@ -53,12 +58,25 @@ function expandHome(p: string): string {
|
|||||||
return p;
|
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.
|
* Check if a path falls under any sensitive path prefix.
|
||||||
*/
|
*/
|
||||||
export function isSensitivePath(filePath: string): boolean {
|
export function isSensitivePath(filePath: string): boolean {
|
||||||
if (!resolvedSensitivePaths) return false;
|
if (!resolvedSensitivePaths) return false;
|
||||||
const normalized = path.resolve(filePath);
|
const normalized = resolveRealPath(filePath);
|
||||||
for (const sensitive of resolvedSensitivePaths) {
|
for (const sensitive of resolvedSensitivePaths) {
|
||||||
if (normalized === sensitive || normalized.startsWith(sensitive + path.sep)) {
|
if (normalized === sensitive || normalized.startsWith(sensitive + path.sep)) {
|
||||||
return true;
|
return true;
|
||||||
@ -76,22 +94,38 @@ export function auditFileAccess(
|
|||||||
operation: "read" | "write" | "stat" | "readdir" | "unlink",
|
operation: "read" | "write" | "stat" | "readdir" | "unlink",
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!resolvedSensitivePaths) return true;
|
if (!resolvedSensitivePaths) return true;
|
||||||
const normalized = path.resolve(filePath);
|
const normalized = resolveRealPath(filePath);
|
||||||
if (!isSensitivePath(normalized)) return true;
|
if (!isSensitivePath(normalized)) return true;
|
||||||
|
|
||||||
logSecurityEvent("sensitive_file_access", {
|
logSecurityEvent("sensitive_file_access", {
|
||||||
operation,
|
operation,
|
||||||
path: normalized,
|
path: normalized,
|
||||||
allowed: !enforceMode,
|
allowed: !enforceMode,
|
||||||
|
stackTrace: new Error().stack?.split("\n").slice(2, 7).join("\n"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return !enforceMode;
|
return !enforceMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install lightweight fs monitoring hooks.
|
* Create a guard wrapper that audits the first arg (file path) before calling the original.
|
||||||
* Only intercepts fs.readFileSync and fs.promises.readFile for sensitive path auditing.
|
*/
|
||||||
* This is intentionally minimal to avoid breaking the application.
|
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 {
|
export function installFsMonitor(opts?: FsMonitorOptions): void {
|
||||||
if (installed) return;
|
if (installed) return;
|
||||||
@ -103,40 +137,73 @@ export function installFsMonitor(opts?: FsMonitorOptions): void {
|
|||||||
resolvedSensitivePaths = rawPaths.map((p) => path.resolve(expandHome(p)));
|
resolvedSensitivePaths = rawPaths.map((p) => path.resolve(expandHome(p)));
|
||||||
enforceMode = opts?.enforce ?? false;
|
enforceMode = opts?.enforce ?? false;
|
||||||
|
|
||||||
// Wrap fs.readFileSync for synchronous reads
|
const readGuard = createGuard("read");
|
||||||
originalReadFile = fs.readFileSync;
|
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[]) => {
|
(fs as { readFileSync: typeof fs.readFileSync }).readFileSync = ((...args: unknown[]) => {
|
||||||
const filePath = args[0];
|
readGuard(args[0]);
|
||||||
if (typeof filePath === "string") {
|
return (origRFS as Function).apply(fs, args);
|
||||||
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;
|
}) as typeof fs.readFileSync;
|
||||||
|
|
||||||
// Wrap fs.promises.readFile for async reads
|
origWriteFileSync = fs.writeFileSync;
|
||||||
originalReadFileAsync = fs.promises.readFile;
|
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 (
|
(fs.promises as { readFile: typeof fs.promises.readFile }).readFile = (async (
|
||||||
...args: unknown[]
|
...args: unknown[]
|
||||||
) => {
|
) => {
|
||||||
const filePath = args[0];
|
readGuard(args[0]);
|
||||||
if (typeof filePath === "string") {
|
return (origRF as Function).apply(fs.promises, args);
|
||||||
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;
|
}) 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;
|
installed = true;
|
||||||
|
|
||||||
logSecurityEvent("hardening_init", {
|
logSecurityEvent("hardening_init", {
|
||||||
module: "fs-monitor",
|
module: "fs-monitor",
|
||||||
sensitivePathCount: resolvedSensitivePaths.length,
|
sensitivePathCount: resolvedSensitivePaths.length,
|
||||||
enforce: enforceMode,
|
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 {
|
export function uninstallFsMonitor(): void {
|
||||||
if (!installed) return;
|
if (!installed) return;
|
||||||
if (originalReadFile) {
|
if (origReadFileSync) {
|
||||||
(fs as { readFileSync: typeof fs.readFileSync }).readFileSync = originalReadFile;
|
(fs as { readFileSync: typeof fs.readFileSync }).readFileSync = origReadFileSync;
|
||||||
originalReadFile = null;
|
origReadFileSync = null;
|
||||||
}
|
}
|
||||||
if (originalReadFileAsync) {
|
if (origWriteFileSync) {
|
||||||
(fs.promises as { readFile: typeof fs.promises.readFile }).readFile = originalReadFileAsync;
|
(fs as { writeFileSync: typeof fs.writeFileSync }).writeFileSync = origWriteFileSync;
|
||||||
originalReadFileAsync = null;
|
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;
|
resolvedSensitivePaths = null;
|
||||||
installed = false;
|
installed = false;
|
||||||
|
|||||||
@ -22,11 +22,23 @@ export type HardeningLoggerOptions = {
|
|||||||
logFile?: string;
|
logFile?: string;
|
||||||
/** Also emit events to this callback (for tests). */
|
/** Also emit events to this callback (for tests). */
|
||||||
onEvent?: (event: SecurityEvent) => void;
|
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 logStream: fs.WriteStream | null = null;
|
||||||
let logFilePath: string | null = null;
|
let logFilePath: string | null = null;
|
||||||
let eventCallback: ((event: SecurityEvent) => void) | 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.
|
* Resolve the default security log path.
|
||||||
@ -36,6 +48,43 @@ function defaultLogPath(): string {
|
|||||||
return path.join(stateDir, "security-audit.log");
|
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.
|
* Initialize the hardening audit logger.
|
||||||
* Call once at gateway startup, before any other security module.
|
* 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
|
if (logStream) return; // already initialized
|
||||||
logFilePath = opts?.logFile ?? defaultLogPath();
|
logFilePath = opts?.logFile ?? defaultLogPath();
|
||||||
eventCallback = opts?.onEvent ?? null;
|
eventCallback = opts?.onEvent ?? null;
|
||||||
|
maxFileSize = opts?.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE;
|
||||||
|
maxRotated = opts?.maxRotatedFiles ?? DEFAULT_MAX_ROTATED;
|
||||||
try {
|
try {
|
||||||
const dir = path.dirname(logFilePath);
|
const dir = path.dirname(logFilePath);
|
||||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
logStream = fs.createWriteStream(logFilePath, { flags: "a", mode: 0o600 });
|
logStream = fs.createWriteStream(logFilePath, { flags: "a", mode: 0o600 });
|
||||||
// Silently handle write stream errors to avoid crashing the gateway.
|
// Silently handle write stream errors to avoid crashing the gateway.
|
||||||
logStream.on("error", () => {});
|
logStream.on("error", () => {});
|
||||||
|
// Track current file size for rotation
|
||||||
|
try {
|
||||||
|
bytesWritten = fs.statSync(logFilePath).size;
|
||||||
|
} catch {
|
||||||
|
bytesWritten = 0;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If we can't open the log file, continue without file logging.
|
// If we can't open the log file, continue without file logging.
|
||||||
// The onEvent callback (if set) will still work.
|
// The onEvent callback (if set) will still work.
|
||||||
@ -67,7 +124,13 @@ export function logSecurityEvent(type: SecurityEventType, detail: Record<string,
|
|||||||
detail,
|
detail,
|
||||||
};
|
};
|
||||||
if (logStream) {
|
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) {
|
if (eventCallback) {
|
||||||
eventCallback(event);
|
eventCallback(event);
|
||||||
@ -84,6 +147,7 @@ export function closeHardeningLogger(): void {
|
|||||||
}
|
}
|
||||||
logFilePath = null;
|
logFilePath = null;
|
||||||
eventCallback = null;
|
eventCallback = null;
|
||||||
|
bytesWritten = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -352,6 +352,140 @@ describe("fs-monitor", () => {
|
|||||||
__resetFsMonitorForTest();
|
__resetFsMonitorForTest();
|
||||||
expect(isFsMonitorActive()).toBe(false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -61,6 +61,7 @@ let enforceMode = true;
|
|||||||
let originalFetch: typeof globalThis.fetch | null = null;
|
let originalFetch: typeof globalThis.fetch | null = null;
|
||||||
let originalHttpRequest: typeof http.request | null = null;
|
let originalHttpRequest: typeof http.request | null = null;
|
||||||
let originalHttpsRequest: typeof https.request | null = null;
|
let originalHttpsRequest: typeof https.request | null = null;
|
||||||
|
let originalWebSocket: typeof globalThis.WebSocket | null = null;
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -122,6 +123,7 @@ export function installNetworkMonitor(opts?: NetworkMonitorOptions): void {
|
|||||||
url: String(
|
url: String(
|
||||||
typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
|
typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
|
||||||
).slice(0, 200),
|
).slice(0, 200),
|
||||||
|
stackTrace: new Error().stack?.split("\n").slice(1, 6).join("\n"),
|
||||||
});
|
});
|
||||||
if (enforceMode) {
|
if (enforceMode) {
|
||||||
throw new Error(`[network-monitor] Blocked outbound request to ${hostname}`);
|
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[]) => {
|
(http as { request: typeof http.request }).request = ((...args: unknown[]) => {
|
||||||
const hostname = extractHostnameFromHttpArgs(args);
|
const hostname = extractHostnameFromHttpArgs(args);
|
||||||
if (hostname && !isDomainAllowed(hostname)) {
|
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) {
|
if (enforceMode) {
|
||||||
throw new Error(`[network-monitor] Blocked outbound HTTP request to ${hostname}`);
|
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[]) => {
|
(https as { request: typeof https.request }).request = ((...args: unknown[]) => {
|
||||||
const hostname = extractHostnameFromHttpArgs(args);
|
const hostname = extractHostnameFromHttpArgs(args);
|
||||||
if (hostname && !isDomainAllowed(hostname)) {
|
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) {
|
if (enforceMode) {
|
||||||
throw new Error(`[network-monitor] Blocked outbound HTTPS request to ${hostname}`);
|
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);
|
return (originalHttpsRequest as Function).apply(https, args);
|
||||||
}) as typeof https.request;
|
}) 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;
|
installed = true;
|
||||||
|
|
||||||
logSecurityEvent("hardening_init", {
|
logSecurityEvent("hardening_init", {
|
||||||
@ -219,6 +252,10 @@ export function uninstallNetworkMonitor(): void {
|
|||||||
(https as { request: typeof https.request }).request = originalHttpsRequest;
|
(https as { request: typeof https.request }).request = originalHttpsRequest;
|
||||||
originalHttpsRequest = null;
|
originalHttpsRequest = null;
|
||||||
}
|
}
|
||||||
|
if (originalWebSocket) {
|
||||||
|
globalThis.WebSocket = originalWebSocket;
|
||||||
|
originalWebSocket = null;
|
||||||
|
}
|
||||||
allowedDomainSet = null;
|
allowedDomainSet = null;
|
||||||
allowedSuffixList = null;
|
allowedSuffixList = null;
|
||||||
installed = false;
|
installed = false;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user