security: add audit logging infrastructure

- Add src/security/audit-log.ts with structured JSONL audit logging:
  - Session lifecycle (start, end, auth failures)
  - Tool invocations
  - Command execution
  - Dangerous command blocks
  - Pairing events
  - Config changes

- Integrate audit logging into bash-tools.exec.ts:
  - Log blocked dangerous commands
  - Log command executions

- Features:
  - Append-only JSONL format for easy parsing
  - Daily log rotation
  - Configurable retention (default 90 days)
  - Restrictive file permissions (0600)
  - Sensitive data redaction
This commit is contained in:
SpencersServer 2026-01-29 13:22:31 +02:00
parent 1c49100ab3
commit 083b2d99cd
2 changed files with 400 additions and 0 deletions

View File

@ -60,6 +60,10 @@ import {
detectDangerousCommand,
formatDangerousCommandError,
} from "../security/dangerous-commands.js";
import {
logDangerousCommandBlocked,
logExecRun,
} from "../security/audit-log.js";
const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
@ -357,9 +361,21 @@ async function runExecProcess(opts: {
notifyOnExit: boolean;
scopeKey?: string;
sessionKey?: string;
agentId?: string;
elevated?: boolean;
timeoutSec: number;
onUpdate?: (partialResult: AgentToolResult<ExecToolDetails>) => void;
}): Promise<ExecProcessHandle> {
// SECURITY: Audit log the command execution
logExecRun({
sessionKey: opts.sessionKey,
agentId: opts.agentId,
command: opts.command,
host: opts.sandbox ? "sandbox" : "gateway",
elevated: opts.elevated,
workdir: opts.workdir,
});
const startedAt = Date.now();
const sessionId = createSessionSlug();
let child: ChildProcessWithoutNullStreams | null = null;
@ -762,6 +778,13 @@ export function createExecTool(
// SECURITY: Check for dangerous command patterns before any execution
const dangerousMatch = detectDangerousCommand(params.command, "high");
if (dangerousMatch) {
logDangerousCommandBlocked({
sessionKey: defaults?.sessionKey,
agentId,
command: params.command,
reason: dangerousMatch.reason,
severity: dangerousMatch.severity,
});
throw new Error(formatDangerousCommandError(dangerousMatch));
}

377
src/security/audit-log.ts Normal file
View File

@ -0,0 +1,377 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
/**
* SECURITY: Audit Logging Module
*
* Provides structured, append-only audit logging for security-relevant events.
* Logs are stored in JSONL format for easy parsing and analysis.
*
* Events logged:
* - Session lifecycle (start, end, auth failures)
* - Tool invocations (especially exec, gateway, elevated)
* - Command execution (with dangerous command detection results)
* - Pairing events (requests, approvals, rejections)
* - Configuration changes
*/
export type AuditEventType =
| "session.start"
| "session.end"
| "session.auth_failure"
| "tool.invoke"
| "tool.denied"
| "exec.run"
| "exec.blocked"
| "exec.elevated"
| "pairing.request"
| "pairing.approved"
| "pairing.rejected"
| "pairing.expired"
| "config.loaded"
| "config.changed"
| "secret.detected"
| "dangerous_command.blocked";
export interface AuditLogEntry {
timestamp: string;
event: AuditEventType;
sessionKey?: string;
agentId?: string;
channel?: string;
userId?: string;
details: Record<string, unknown>;
}
interface AuditLogConfig {
enabled: boolean;
logDir: string;
maxFileSizeMb: number;
retentionDays: number;
}
const DEFAULT_CONFIG: AuditLogConfig = {
enabled: true,
logDir: path.join(os.homedir(), ".clawdbot", "audit"),
maxFileSizeMb: 10,
retentionDays: 90,
};
let currentConfig: AuditLogConfig = { ...DEFAULT_CONFIG };
/**
* Configure the audit logger.
*/
export function configureAuditLog(config: Partial<AuditLogConfig>): void {
currentConfig = { ...currentConfig, ...config };
if (currentConfig.enabled) {
ensureLogDir();
}
}
/**
* Ensure the audit log directory exists with proper permissions.
*/
function ensureLogDir(): void {
const logDir = currentConfig.logDir;
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
}
// Ensure directory has restrictive permissions
try {
fs.chmodSync(logDir, 0o700);
} catch {
// Best effort
}
}
/**
* Get the current log file path (rotated daily).
*/
function getCurrentLogPath(): string {
const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
return path.join(currentConfig.logDir, `audit-${date}.jsonl`);
}
/**
* Write an audit log entry.
*/
function writeLogEntry(entry: AuditLogEntry): void {
if (!currentConfig.enabled) return;
try {
ensureLogDir();
const logPath = getCurrentLogPath();
const line = JSON.stringify(entry) + "\n";
// Append-only write with restrictive permissions
fs.appendFileSync(logPath, line, { mode: 0o600 });
} catch (err) {
// Audit logging should never crash the application
// Log to stderr as fallback
console.error("[audit-log] Failed to write entry:", err);
}
}
/**
* Log a session start event.
*/
export function logSessionStart(params: {
sessionKey: string;
agentId?: string;
channel?: string;
userId?: string;
model?: string;
}): void {
writeLogEntry({
timestamp: new Date().toISOString(),
event: "session.start",
sessionKey: params.sessionKey,
agentId: params.agentId,
channel: params.channel,
userId: params.userId,
details: {
model: params.model,
},
});
}
/**
* Log a session end event.
*/
export function logSessionEnd(params: {
sessionKey: string;
agentId?: string;
reason?: string;
durationMs?: number;
}): void {
writeLogEntry({
timestamp: new Date().toISOString(),
event: "session.end",
sessionKey: params.sessionKey,
agentId: params.agentId,
details: {
reason: params.reason,
durationMs: params.durationMs,
},
});
}
/**
* Log an authentication failure.
*/
export function logAuthFailure(params: {
sessionKey?: string;
channel?: string;
userId?: string;
reason: string;
ip?: string;
}): void {
writeLogEntry({
timestamp: new Date().toISOString(),
event: "session.auth_failure",
sessionKey: params.sessionKey,
channel: params.channel,
userId: params.userId,
details: {
reason: params.reason,
ip: params.ip,
},
});
}
/**
* Log a tool invocation.
*/
export function logToolInvoke(params: {
sessionKey?: string;
agentId?: string;
toolName: string;
toolCallId?: string;
args?: Record<string, unknown>;
sensitive?: boolean;
}): void {
// For sensitive tools, redact arguments
const safeArgs = params.sensitive ? { redacted: true } : params.args;
writeLogEntry({
timestamp: new Date().toISOString(),
event: "tool.invoke",
sessionKey: params.sessionKey,
agentId: params.agentId,
details: {
toolName: params.toolName,
toolCallId: params.toolCallId,
args: safeArgs,
},
});
}
/**
* Log a denied tool invocation.
*/
export function logToolDenied(params: {
sessionKey?: string;
agentId?: string;
toolName: string;
reason: string;
}): void {
writeLogEntry({
timestamp: new Date().toISOString(),
event: "tool.denied",
sessionKey: params.sessionKey,
agentId: params.agentId,
details: {
toolName: params.toolName,
reason: params.reason,
},
});
}
/**
* Log a command execution.
*/
export function logExecRun(params: {
sessionKey?: string;
agentId?: string;
command: string;
host: "sandbox" | "gateway" | "node";
elevated?: boolean;
workdir?: string;
}): void {
// Truncate very long commands
const truncatedCommand =
params.command.length > 500 ? params.command.slice(0, 500) + "..." : params.command;
writeLogEntry({
timestamp: new Date().toISOString(),
event: params.elevated ? "exec.elevated" : "exec.run",
sessionKey: params.sessionKey,
agentId: params.agentId,
details: {
command: truncatedCommand,
host: params.host,
elevated: params.elevated,
workdir: params.workdir,
},
});
}
/**
* Log a blocked dangerous command.
*/
export function logDangerousCommandBlocked(params: {
sessionKey?: string;
agentId?: string;
command: string;
reason: string;
severity: string;
}): void {
writeLogEntry({
timestamp: new Date().toISOString(),
event: "dangerous_command.blocked",
sessionKey: params.sessionKey,
agentId: params.agentId,
details: {
command: params.command.slice(0, 500),
reason: params.reason,
severity: params.severity,
},
});
}
/**
* Log a pairing event.
*/
export function logPairingEvent(params: {
event: "pairing.request" | "pairing.approved" | "pairing.rejected" | "pairing.expired";
channel?: string;
userId?: string;
pairingCode?: string;
nodeId?: string;
reason?: string;
}): void {
writeLogEntry({
timestamp: new Date().toISOString(),
event: params.event,
channel: params.channel,
userId: params.userId,
details: {
pairingCode: params.pairingCode ? "***" : undefined, // Redact actual code
nodeId: params.nodeId,
reason: params.reason,
},
});
}
/**
* Log configuration changes.
*/
export function logConfigChange(params: {
action: "loaded" | "changed";
configPath?: string;
changedKeys?: string[];
}): void {
writeLogEntry({
timestamp: new Date().toISOString(),
event: params.action === "loaded" ? "config.loaded" : "config.changed",
details: {
configPath: params.configPath,
changedKeys: params.changedKeys,
},
});
}
/**
* Log when secrets are detected in files (from secret-guard).
*/
export function logSecretDetected(params: {
source: string;
path: string;
action: "blocked" | "warned";
}): void {
writeLogEntry({
timestamp: new Date().toISOString(),
event: "secret.detected",
details: {
source: params.source,
path: params.path,
action: params.action,
},
});
}
/**
* Clean up old audit log files based on retention policy.
*/
export async function cleanupOldLogs(): Promise<number> {
if (!currentConfig.enabled) return 0;
const logDir = currentConfig.logDir;
if (!fs.existsSync(logDir)) return 0;
const now = Date.now();
const maxAgeMs = currentConfig.retentionDays * 24 * 60 * 60 * 1000;
let removed = 0;
try {
const files = fs.readdirSync(logDir);
for (const file of files) {
if (!file.startsWith("audit-") || !file.endsWith(".jsonl")) continue;
const filePath = path.join(logDir, file);
const stats = fs.statSync(filePath);
const age = now - stats.mtimeMs;
if (age > maxAgeMs) {
fs.unlinkSync(filePath);
removed += 1;
}
}
} catch {
// Best effort cleanup
}
return removed;
}