Files
overlord/Overlord-Server/src/auditLog.ts
T

339 lines
8.1 KiB
TypeScript
Raw Normal View History

2026-03-05 00:04:26 -06:00
import Database from "bun:sqlite";
import { resolve } from "path";
import { ensureDataDir } from "./paths";
const dataDir = ensureDataDir();
const dbPath = resolve(dataDir, "overlord.db");
const db = new Database(dbPath);
const auditQueue: AuditLogEntry[] = [];
const BATCH_SIZE = 50;
const BATCH_INTERVAL_MS = 1000;
let flushTimeout: Timer | null = null;
const VERBOSE_AUDIT = process.env.VERBOSE_AUDIT === "true";
db.run(`
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
username TEXT NOT NULL,
ip TEXT NOT NULL,
action TEXT NOT NULL,
target_client_id TEXT,
details TEXT,
success INTEGER NOT NULL,
error_message TEXT
);
`);
db.run(
`CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_logs(timestamp DESC);`,
);
db.run(
`CREATE INDEX IF NOT EXISTS idx_audit_username ON audit_logs(username);`,
);
db.run(`CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);`);
export enum AuditAction {
CLIENT_FIRST_CONNECT = "client_first_connect",
CLIENT_RECONNECT = "client_reconnect",
CLIENT_DISCONNECT = "client_disconnect",
LOGIN = "login",
LOGOUT = "logout",
LOGIN_FAILED = "login_failed",
COMMAND = "command",
FILE_DOWNLOAD = "file_download",
FILE_UPLOAD = "file_upload",
FILE_DELETE = "file_delete",
FILE_LIST = "file_list",
FILE_MKDIR = "file_mkdir",
FILE_READ = "file_read",
FILE_WRITE = "file_write",
FILE_SEARCH = "file_search",
FILE_COPY = "file_copy",
FILE_MOVE = "file_move",
FILE_CHMOD = "file_chmod",
FILE_EXECUTE = "file_execute",
FILE_ZIP = "file_zip",
CONSOLE_START = "console_start",
CONSOLE_STOP = "console_stop",
CONSOLE_INPUT = "console_input",
DESKTOP_START = "desktop_start",
DESKTOP_STOP = "desktop_stop",
DESKTOP_MOUSE = "desktop_mouse",
DESKTOP_KEYBOARD = "desktop_keyboard",
PROCESS_LIST = "process_list",
PROCESS_KILL = "process_kill",
SCREENSHOT = "screenshot",
DISCONNECT = "disconnect",
RECONNECT = "reconnect",
UNINSTALL = "uninstall",
AGENT_UPDATE = "agent_update",
SCRIPT_EXECUTE = "script_execute",
SILENT_EXECUTE = "silent_execute",
}
export interface AuditLogEntry {
id?: number;
timestamp: number;
username: string;
ip: string;
action: AuditAction | string;
targetClientId?: string;
details?: string;
success: boolean;
errorMessage?: string;
}
export interface AuditLogFilters {
page?: number;
pageSize?: number;
username?: string;
action?: string;
actions?: string[];
targetClientId?: string;
startDate?: number;
endDate?: number;
successOnly?: boolean;
}
export function logAudit(entry: AuditLogEntry): void {
auditQueue.push(entry);
if (VERBOSE_AUDIT) {
const status = entry.success ? "✓" : "✗";
const target = entry.targetClientId
? ` [client: ${entry.targetClientId.substring(0, 8)}...]`
: "";
console.log(
`[audit] ${status} ${entry.username}@${entry.ip} - ${entry.action}${target}`,
);
}
if (auditQueue.length >= BATCH_SIZE) {
flushAuditLogs();
} else if (!flushTimeout) {
flushTimeout = setTimeout(flushAuditLogs, BATCH_INTERVAL_MS);
}
}
function flushAuditLogs(): void {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTimeout = null;
}
if (auditQueue.length === 0) return;
const logsToWrite = auditQueue.splice(0, auditQueue.length);
try {
const insert = db.prepare(
`INSERT INTO audit_logs (timestamp, username, ip, action, target_client_id, details, success, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
const transaction = db.transaction((logs: AuditLogEntry[]) => {
for (const entry of logs) {
insert.run(
entry.timestamp,
entry.username,
entry.ip,
entry.action,
entry.targetClientId || null,
entry.details || null,
entry.success ? 1 : 0,
entry.errorMessage || null,
);
}
});
transaction(logsToWrite);
} catch (error) {
console.error(
`[audit] Failed to flush ${logsToWrite.length} audit entries:`,
error,
);
}
}
export function flushAuditLogsSync(): void {
flushAuditLogs();
}
export function getAuditLogs(filters: AuditLogFilters = {}): {
logs: AuditLogEntry[];
total: number;
page: number;
pageSize: number;
} {
const page = filters.page || 1;
const pageSize = filters.pageSize || 50;
const offset = (page - 1) * pageSize;
const where: string[] = [];
const params: any[] = [];
if (filters.username) {
where.push("username = ?");
params.push(filters.username);
}
if (filters.action) {
where.push("action = ?");
params.push(filters.action);
}
if (filters.actions && filters.actions.length > 0) {
const placeholders = filters.actions.map(() => "?").join(", ");
where.push(`action IN (${placeholders})`);
params.push(...filters.actions);
}
if (filters.targetClientId) {
where.push("target_client_id = ?");
params.push(filters.targetClientId);
}
if (filters.startDate) {
where.push("timestamp >= ?");
params.push(filters.startDate);
}
if (filters.endDate) {
where.push("timestamp <= ?");
params.push(filters.endDate);
}
if (filters.successOnly) {
where.push("success = 1");
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const countQuery = `SELECT COUNT(*) as total FROM audit_logs ${whereSql}`;
const countResult = db.query(countQuery).get(...params) as { total: number };
const total = countResult?.total || 0;
const logsQuery = `
SELECT id, timestamp, username, ip, action, target_client_id, details, success, error_message
FROM audit_logs
${whereSql}
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
`;
const logs = db.query(logsQuery).all(...params, pageSize, offset) as any[];
return {
logs: logs.map((row) => ({
id: row.id,
timestamp: row.timestamp,
username: row.username,
ip: row.ip,
action: row.action,
targetClientId: row.target_client_id || undefined,
details: row.details || undefined,
success: row.success === 1,
errorMessage: row.error_message || undefined,
})),
total,
page,
pageSize,
};
}
export function getAuditStats(): {
totalLogs: number;
last24h: number;
failedLogins: number;
topUsers: Array<{ username: string; count: number }>;
topActions: Array<{ action: string; count: number }>;
} {
const now = Date.now();
const yesterday = now - 24 * 60 * 60 * 1000;
const totalLogs = (
db.query("SELECT COUNT(*) as count FROM audit_logs").get() as any
).count;
const last24h = (
db
.query("SELECT COUNT(*) as count FROM audit_logs WHERE timestamp >= ?")
.get(yesterday) as any
).count;
const failedLogins = (
db
.query(
"SELECT COUNT(*) as count FROM audit_logs WHERE action = ? AND success = 0",
)
.get(AuditAction.LOGIN_FAILED) as any
).count;
const topUsers = db
.query(
`
SELECT username, COUNT(*) as count
FROM audit_logs
WHERE timestamp >= ?
GROUP BY username
ORDER BY count DESC
LIMIT 5
`,
)
.all(yesterday) as Array<{ username: string; count: number }>;
const topActions = db
.query(
`
SELECT action, COUNT(*) as count
FROM audit_logs
WHERE timestamp >= ?
GROUP BY action
ORDER BY count DESC
LIMIT 10
`,
)
.all(yesterday) as Array<{ action: string; count: number }>;
return {
totalLogs,
last24h,
failedLogins,
topUsers,
topActions,
};
}
export function cleanupOldAuditLogs(daysToKeep: number = 90): number {
const cutoffTime = Date.now() - daysToKeep * 24 * 60 * 60 * 1000;
const result = db.run("DELETE FROM audit_logs WHERE timestamp < ?", [
cutoffTime,
]);
const deleted = result.changes;
if (deleted > 0) {
console.log(
`[audit] Cleaned up ${deleted} audit logs older than ${daysToKeep} days`,
);
}
return deleted;
}
process.on("beforeExit", () => {
flushAuditLogs();
});
process.on("SIGINT", () => {
flushAuditLogs();
process.exit(0);
});
process.on("SIGTERM", () => {
flushAuditLogs();
process.exit(0);
});
console.log("[audit] Audit logging system initialized");