Merge 60827e0dae into 4b5514a259
This commit is contained in:
commit
9681eed8ef
@ -6,6 +6,7 @@ Docs: https://docs.molt.bot
|
|||||||
Status: beta.
|
Status: beta.
|
||||||
|
|
||||||
### Changes
|
### 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.
|
- 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.
|
- 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).
|
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
|
||||||
|
|||||||
@ -191,6 +191,33 @@ export type GatewayHttpConfig = {
|
|||||||
endpoints?: GatewayHttpEndpointsConfig;
|
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 = {
|
export type GatewayNodesConfig = {
|
||||||
/** Browser routing policy for node-hosted browser proxies. */
|
/** Browser routing policy for node-hosted browser proxies. */
|
||||||
browser?: {
|
browser?: {
|
||||||
@ -233,6 +260,8 @@ export type GatewayConfig = {
|
|||||||
tls?: GatewayTlsConfig;
|
tls?: GatewayTlsConfig;
|
||||||
http?: GatewayHttpConfig;
|
http?: GatewayHttpConfig;
|
||||||
nodes?: GatewayNodesConfig;
|
nodes?: GatewayNodesConfig;
|
||||||
|
/** Security audit event logging configuration. */
|
||||||
|
auditLog?: GatewayAuditLogConfig;
|
||||||
/**
|
/**
|
||||||
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
|
* 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
|
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or
|
||||||
|
|||||||
254
src/security/audit-events.test.ts
Normal file
254
src/security/audit-events.test.ts
Normal file
@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
434
src/security/audit-events.ts
Normal file
434
src/security/audit-events.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
/** 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<string, unknown>;
|
||||||
|
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<Omit<AuditLogConfig, "customHandler">> & {
|
||||||
|
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<AuditLogConfig>): 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<AuditLogConfig>) {
|
||||||
|
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<AuditEvent, "timestamp">): 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<AuditLogConfig>): 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<AuditLogConfig>): 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<AuditLogConfig>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user