diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5321870..74fdca372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.molt.bot Status: beta. ### Changes +- Security: add audit event logging for compliance and forensic analysis. (#3954) - Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope. - Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. - macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index a0d562f7b..0492fae4b 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -191,6 +191,33 @@ export type GatewayHttpConfig = { endpoints?: GatewayHttpEndpointsConfig; }; +/** Audit event categories for filtering. */ +export type GatewayAuditCategory = + | "auth" + | "authz" + | "admin" + | "config" + | "rate_limit" + | "channel" + | "session"; + +/** + * Security audit logging configuration. + * Records security-relevant events for compliance and forensics. + */ +export type GatewayAuditLogConfig = { + /** Enable audit event logging (default: false). */ + enabled?: boolean; + /** Output target: 'file' or 'stdout' (default: 'file'). */ + target?: "file" | "stdout"; + /** File path for file target (default: ~/.clawdbot/audit/audit.jsonl). */ + filePath?: string; + /** Categories to log (default: all). */ + categories?: GatewayAuditCategory[]; + /** Include request IDs for correlation (default: true). */ + includeRequestIds?: boolean; +}; + export type GatewayNodesConfig = { /** Browser routing policy for node-hosted browser proxies. */ browser?: { @@ -233,6 +260,8 @@ export type GatewayConfig = { tls?: GatewayTlsConfig; http?: GatewayHttpConfig; nodes?: GatewayNodesConfig; + /** Security audit event logging configuration. */ + auditLog?: GatewayAuditLogConfig; /** * IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection * arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or diff --git a/src/security/audit-events.test.ts b/src/security/audit-events.test.ts new file mode 100644 index 000000000..4b95ea51f --- /dev/null +++ b/src/security/audit-events.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, afterEach, vi, beforeEach } from "vitest"; +import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + AuditLogger, + resolveAuditLogConfig, + type AuditEvent, +} from "./audit-events.js"; + +describe("AuditLogger", () => { + let testDir: string; + let testLogPath: string; + let logger: AuditLogger | null = null; + + beforeEach(() => { + testDir = join(tmpdir(), `audit-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + testLogPath = join(testDir, "audit.jsonl"); + }); + + afterEach(() => { + logger?.close(); + logger = null; + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe("basic logging", () => { + it("does not log when disabled", () => { + logger = new AuditLogger({ enabled: false, target: "file", filePath: testLogPath }); + logger.logAuth({ action: "login", outcome: "success" }); + logger.close(); + + expect(existsSync(testLogPath)).toBe(false); + }); + + it("logs to file when enabled", async () => { + logger = new AuditLogger({ enabled: true, target: "file", filePath: testLogPath }); + logger.logAuth({ action: "login", outcome: "success", actorId: "user-123" }); + logger.close(); + + // Wait for file write + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(existsSync(testLogPath)).toBe(true); + const content = readFileSync(testLogPath, "utf8"); + const event = JSON.parse(content.trim()); + expect(event.category).toBe("auth"); + expect(event.action).toBe("login"); + expect(event.outcome).toBe("success"); + expect(event.actor.id).toBe("user-123"); + }); + + it("logs to stdout when target is stdout", () => { + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + logger = new AuditLogger({ enabled: true, target: "stdout" }); + logger.logAuth({ action: "login", outcome: "success" }); + logger.close(); + + expect(writeSpy).toHaveBeenCalled(); + const output = writeSpy.mock.calls[0]?.[0] as string; + expect(output).toContain("[audit]"); + expect(output).toContain('"category":"auth"'); + + writeSpy.mockRestore(); + }); + + it("calls custom handler when target is custom", () => { + const events: AuditEvent[] = []; + const customHandler = (event: AuditEvent) => events.push(event); + + logger = new AuditLogger({ + enabled: true, + target: "custom", + customHandler, + }); + + logger.logAuth({ action: "login", outcome: "success" }); + logger.close(); + + expect(events).toHaveLength(1); + expect(events[0]?.category).toBe("auth"); + }); + }); + + describe("event types", () => { + let events: AuditEvent[]; + + beforeEach(() => { + events = []; + logger = new AuditLogger({ + enabled: true, + target: "custom", + customHandler: (event) => events.push(event), + }); + }); + + it("logs auth events correctly", () => { + logger!.logAuth({ + action: "login", + outcome: "failure", + actorId: "user-123", + actorIp: "192.168.1.1", + method: "token", + error: "Invalid token", + }); + + expect(events[0]?.category).toBe("auth"); + expect(events[0]?.action).toBe("login"); + expect(events[0]?.outcome).toBe("failure"); + expect(events[0]?.actor?.id).toBe("user-123"); + expect(events[0]?.actor?.ip).toBe("192.168.1.1"); + expect(events[0]?.details?.method).toBe("token"); + expect(events[0]?.error).toBe("Invalid token"); + }); + + it("logs authz events correctly", () => { + logger!.logAuthz({ + action: "access_denied", + outcome: "denied", + actorId: "user-456", + resource: "/admin/settings", + permission: "admin:write", + reason: "Insufficient permissions", + }); + + expect(events[0]?.category).toBe("authz"); + expect(events[0]?.action).toBe("access_denied"); + expect(events[0]?.target?.name).toBe("/admin/settings"); + expect(events[0]?.details?.permission).toBe("admin:write"); + expect(events[0]?.details?.reason).toBe("Insufficient permissions"); + }); + + it("logs admin events correctly", () => { + logger!.logAdmin({ + action: "config_change", + outcome: "success", + actorId: "admin-1", + targetType: "gateway", + targetId: "config", + changes: { bind: "lan" }, + }); + + expect(events[0]?.category).toBe("admin"); + expect(events[0]?.action).toBe("config_change"); + expect(events[0]?.target?.type).toBe("gateway"); + expect(events[0]?.details?.changes).toEqual({ bind: "lan" }); + }); + + it("logs rate limit events correctly", () => { + logger!.logRateLimit({ + action: "request_limited", + outcome: "denied", + actorIp: "10.0.0.1", + retryAfterMs: 5000, + }); + + expect(events[0]?.category).toBe("rate_limit"); + expect(events[0]?.action).toBe("request_limited"); + expect(events[0]?.details?.retryAfterMs).toBe(5000); + }); + + it("logs session events correctly", () => { + logger!.logSession({ + action: "session_end", + outcome: "success", + actorId: "user-789", + sessionId: "sess-abc", + channel: "telegram", + durationMs: 300000, + }); + + expect(events[0]?.category).toBe("session"); + expect(events[0]?.action).toBe("session_end"); + expect(events[0]?.target?.id).toBe("sess-abc"); + expect(events[0]?.details?.durationMs).toBe(300000); + }); + }); + + describe("filtering", () => { + it("filters by category", () => { + const events: AuditEvent[] = []; + logger = new AuditLogger({ + enabled: true, + target: "custom", + categories: ["auth"], // Only auth events + customHandler: (event) => events.push(event), + }); + + logger.logAuth({ action: "login", outcome: "success" }); + logger.logAdmin({ action: "config_change", outcome: "success" }); + logger.close(); + + expect(events).toHaveLength(1); + expect(events[0]?.category).toBe("auth"); + }); + + it("excludes requestId when disabled", () => { + const events: AuditEvent[] = []; + logger = new AuditLogger({ + enabled: true, + target: "custom", + includeRequestIds: false, + customHandler: (event) => events.push(event), + }); + + logger.logAuth({ action: "login", outcome: "success", requestId: "req-123" }); + logger.close(); + + expect(events[0]?.requestId).toBeUndefined(); + }); + }); + + describe("configuration", () => { + it("updates config at runtime", () => { + const events: AuditEvent[] = []; + logger = new AuditLogger({ + enabled: false, + target: "custom", + customHandler: (event) => events.push(event), + }); + + logger.logAuth({ action: "login", outcome: "success" }); + expect(events).toHaveLength(0); + + logger.updateConfig({ enabled: true }); + logger.logAuth({ action: "login", outcome: "success" }); + expect(events).toHaveLength(1); + }); + }); +}); + +describe("resolveAuditLogConfig", () => { + it("returns defaults when no config provided", () => { + const config = resolveAuditLogConfig(); + expect(config.enabled).toBe(false); + expect(config.target).toBe("file"); + expect(config.categories).toContain("auth"); + expect(config.includeRequestIds).toBe(true); + }); + + it("merges partial config with defaults", () => { + const config = resolveAuditLogConfig({ + enabled: true, + target: "stdout", + }); + expect(config.enabled).toBe(true); + expect(config.target).toBe("stdout"); + expect(config.categories).toContain("auth"); // Default + }); +}); diff --git a/src/security/audit-events.ts b/src/security/audit-events.ts new file mode 100644 index 000000000..f0d52a177 --- /dev/null +++ b/src/security/audit-events.ts @@ -0,0 +1,434 @@ +/** + * Security Audit Event Logging + * + * Records security-relevant events for compliance and forensic analysis: + * - Authentication attempts (success/failure) + * - Authorization decisions + * - Admin/elevated actions + * - Configuration changes + * - Rate limit violations + * + * Events are structured JSON for easy parsing by SIEM tools. + * Supports multiple output targets: file, stdout, or custom handler. + */ + +import { createWriteStream, type WriteStream, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { resolveStateDir } from "../config/paths.js"; + +// Event Categories +export type AuditCategory = + | "auth" // Authentication events + | "authz" // Authorization events + | "admin" // Administrative actions + | "config" // Configuration changes + | "rate_limit" // Rate limiting events + | "channel" // Channel/messaging events + | "session"; // Session lifecycle events + +// Event outcomes +export type AuditOutcome = "success" | "failure" | "denied" | "error"; + +// Base audit event structure +export type AuditEvent = { + /** ISO 8601 timestamp */ + timestamp: string; + /** Event category */ + category: AuditCategory; + /** Specific action within the category */ + action: string; + /** Outcome of the action */ + outcome: AuditOutcome; + /** Actor who initiated the action (user ID, IP, session ID, etc.) */ + actor?: { + type: "user" | "system" | "api" | "channel"; + id?: string; + ip?: string; + channel?: string; + }; + /** Target of the action */ + target?: { + type: string; + id?: string; + name?: string; + }; + /** Additional context */ + details?: Record; + /** Error message if outcome is failure/error */ + error?: string; + /** Request ID for correlation */ + requestId?: string; +}; + +// Specific event builders for type safety +export type AuthEventParams = { + action: "login" | "logout" | "token_refresh" | "password_change" | "api_key_use"; + outcome: AuditOutcome; + actorId?: string; + actorIp?: string; + method?: "token" | "password" | "tailscale" | "device"; + error?: string; + requestId?: string; +}; + +export type AuthzEventParams = { + action: "access_granted" | "access_denied" | "permission_check" | "role_assigned"; + outcome: AuditOutcome; + actorId?: string; + actorIp?: string; + resource?: string; + permission?: string; + reason?: string; + requestId?: string; +}; + +export type AdminEventParams = { + action: "config_change" | "user_add" | "user_remove" | "channel_add" | "channel_remove" | "elevated_exec"; + outcome: AuditOutcome; + actorId?: string; + actorIp?: string; + targetType?: string; + targetId?: string; + changes?: Record; + command?: string; + error?: string; + requestId?: string; +}; + +export type RateLimitEventParams = { + action: "request_limited" | "channel_limited" | "auth_backoff"; + outcome: "denied"; + actorId?: string; + actorIp?: string; + channel?: string; + retryAfterMs?: number; + requestId?: string; +}; + +export type SessionEventParams = { + action: "session_start" | "session_end" | "session_timeout"; + outcome: AuditOutcome; + actorId?: string; + sessionId?: string; + channel?: string; + durationMs?: number; + requestId?: string; +}; + +// Configuration +export type AuditLogConfig = { + /** Enable audit logging (default: false) */ + enabled?: boolean; + /** Output target: 'file', 'stdout', or 'custom' (default: 'file') */ + target?: "file" | "stdout" | "custom"; + /** File path for file target (default: ~/.clawdbot/audit/audit.jsonl) */ + filePath?: string; + /** Categories to log (default: all) */ + categories?: AuditCategory[]; + /** Minimum outcome severity to log (default: all) */ + minOutcome?: AuditOutcome; + /** Include request IDs for correlation (default: true) */ + includeRequestIds?: boolean; + /** Custom handler for 'custom' target */ + customHandler?: (event: AuditEvent) => void; +}; + +export type ResolvedAuditLogConfig = Required> & { + customHandler?: (event: AuditEvent) => void; +}; + +const DEFAULT_AUDIT_CONFIG: ResolvedAuditLogConfig = { + enabled: false, + target: "file", + filePath: "", // Resolved at runtime + categories: ["auth", "authz", "admin", "config", "rate_limit", "channel", "session"], + minOutcome: "success", // Log everything + includeRequestIds: true, +}; + +function resolveDefaultAuditPath(): string { + const stateDir = resolveStateDir(); + return join(stateDir, "audit", "audit.jsonl"); +} + +export function resolveAuditLogConfig(config?: Partial): ResolvedAuditLogConfig { + const filePath = config?.filePath || resolveDefaultAuditPath(); + return { + enabled: config?.enabled ?? DEFAULT_AUDIT_CONFIG.enabled, + target: config?.target ?? DEFAULT_AUDIT_CONFIG.target, + filePath, + categories: config?.categories ?? DEFAULT_AUDIT_CONFIG.categories, + minOutcome: config?.minOutcome ?? DEFAULT_AUDIT_CONFIG.minOutcome, + includeRequestIds: config?.includeRequestIds ?? DEFAULT_AUDIT_CONFIG.includeRequestIds, + customHandler: config?.customHandler, + }; +} + +/** + * Security audit logger for recording security-relevant events. + * Events are written as newline-delimited JSON (JSONL) for easy parsing. + */ +export class AuditLogger { + private config: ResolvedAuditLogConfig; + private fileStream: WriteStream | null = null; + private closed = false; + + constructor(config?: Partial) { + this.config = resolveAuditLogConfig(config); + + if (this.config.enabled && this.config.target === "file") { + this.initFileStream(); + } + } + + private initFileStream(): void { + const dir = dirname(this.config.filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + this.fileStream = createWriteStream(this.config.filePath, { + flags: "a", // Append mode + mode: 0o600, // Owner read/write only + encoding: "utf8", + }); + + this.fileStream.on("error", (err) => { + console.error(`[audit] File write error: ${err.message}`); + }); + } + + /** + * Log a raw audit event. + */ + log(event: Omit): void { + if (!this.config.enabled || this.closed) return; + + // Check category filter + if (!this.config.categories.includes(event.category)) return; + + const fullEvent: AuditEvent = { + ...event, + timestamp: new Date().toISOString(), + }; + + // Remove requestId if disabled + if (!this.config.includeRequestIds) { + delete fullEvent.requestId; + } + + this.write(fullEvent); + } + + /** + * Log an authentication event. + */ + logAuth(params: AuthEventParams): void { + this.log({ + category: "auth", + action: params.action, + outcome: params.outcome, + actor: params.actorId || params.actorIp + ? { type: "user", id: params.actorId, ip: params.actorIp } + : undefined, + details: params.method ? { method: params.method } : undefined, + error: params.error, + requestId: params.requestId, + }); + } + + /** + * Log an authorization event. + */ + logAuthz(params: AuthzEventParams): void { + this.log({ + category: "authz", + action: params.action, + outcome: params.outcome, + actor: params.actorId || params.actorIp + ? { type: "user", id: params.actorId, ip: params.actorIp } + : undefined, + target: params.resource + ? { type: "resource", name: params.resource } + : undefined, + details: { + ...(params.permission && { permission: params.permission }), + ...(params.reason && { reason: params.reason }), + }, + requestId: params.requestId, + }); + } + + /** + * Log an administrative action. + */ + logAdmin(params: AdminEventParams): void { + this.log({ + category: "admin", + action: params.action, + outcome: params.outcome, + actor: params.actorId || params.actorIp + ? { type: "user", id: params.actorId, ip: params.actorIp } + : undefined, + target: params.targetType + ? { type: params.targetType, id: params.targetId } + : undefined, + details: { + ...(params.changes && { changes: params.changes }), + ...(params.command && { command: params.command }), + }, + error: params.error, + requestId: params.requestId, + }); + } + + /** + * Log a rate limit event. + */ + logRateLimit(params: RateLimitEventParams): void { + this.log({ + category: "rate_limit", + action: params.action, + outcome: params.outcome, + actor: params.actorId || params.actorIp + ? { type: "user", id: params.actorId, ip: params.actorIp } + : undefined, + details: { + ...(params.channel && { channel: params.channel }), + ...(params.retryAfterMs && { retryAfterMs: params.retryAfterMs }), + }, + requestId: params.requestId, + }); + } + + /** + * Log a session event. + */ + logSession(params: SessionEventParams): void { + this.log({ + category: "session", + action: params.action, + outcome: params.outcome, + actor: params.actorId + ? { type: "user", id: params.actorId } + : undefined, + target: params.sessionId + ? { type: "session", id: params.sessionId } + : undefined, + details: { + ...(params.channel && { channel: params.channel }), + ...(params.durationMs && { durationMs: params.durationMs }), + }, + requestId: params.requestId, + }); + } + + /** + * Get current configuration. + */ + getConfig(): ResolvedAuditLogConfig { + return { ...this.config }; + } + + /** + * Update configuration at runtime. + * Merges new config with existing config values. + */ + updateConfig(config: Partial): void { + const wasEnabled = this.config.enabled; + const wasFile = this.config.target === "file"; + const oldPath = this.config.filePath; + + // Merge new config with existing config values + this.config = { + enabled: config.enabled ?? this.config.enabled, + target: config.target ?? this.config.target, + filePath: config.filePath ?? this.config.filePath, + categories: config.categories ?? this.config.categories, + minOutcome: config.minOutcome ?? this.config.minOutcome, + includeRequestIds: config.includeRequestIds ?? this.config.includeRequestIds, + customHandler: config.customHandler ?? this.config.customHandler, + }; + + // Handle file stream changes + if (this.config.enabled && this.config.target === "file") { + if (!wasEnabled || !wasFile || oldPath !== this.config.filePath) { + this.closeFileStream(); + this.initFileStream(); + } + } else { + this.closeFileStream(); + } + } + + /** + * Close the logger and release resources. + */ + close(): void { + this.closed = true; + this.closeFileStream(); + } + + private closeFileStream(): void { + if (this.fileStream) { + this.fileStream.end(); + this.fileStream = null; + } + } + + private write(event: AuditEvent): void { + const line = JSON.stringify(event) + "\n"; + + switch (this.config.target) { + case "file": + if (this.fileStream) { + this.fileStream.write(line); + } + break; + case "stdout": + process.stdout.write(`[audit] ${line}`); + break; + case "custom": + if (this.config.customHandler) { + this.config.customHandler(event); + } + break; + } + } +} + +// Singleton instance for global access +let globalAuditLogger: AuditLogger | null = null; + +/** + * Get or create the global audit logger instance. + */ +export function getAuditLogger(config?: Partial): AuditLogger { + if (!globalAuditLogger) { + globalAuditLogger = new AuditLogger(config); + } + return globalAuditLogger; +} + +/** + * Initialize the global audit logger with configuration. + * Call this early in application startup. + */ +export function initAuditLogger(config?: Partial): AuditLogger { + if (globalAuditLogger) { + globalAuditLogger.close(); + } + globalAuditLogger = new AuditLogger(config); + return globalAuditLogger; +} + +/** + * Close the global audit logger. + * Call this during application shutdown. + */ +export function closeAuditLogger(): void { + if (globalAuditLogger) { + globalAuditLogger.close(); + globalAuditLogger = null; + } +}