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:
parent
1c49100ab3
commit
083b2d99cd
@ -60,6 +60,10 @@ import {
|
|||||||
detectDangerousCommand,
|
detectDangerousCommand,
|
||||||
formatDangerousCommandError,
|
formatDangerousCommandError,
|
||||||
} from "../security/dangerous-commands.js";
|
} from "../security/dangerous-commands.js";
|
||||||
|
import {
|
||||||
|
logDangerousCommandBlocked,
|
||||||
|
logExecRun,
|
||||||
|
} from "../security/audit-log.js";
|
||||||
|
|
||||||
const DEFAULT_MAX_OUTPUT = clampNumber(
|
const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||||
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
||||||
@ -357,9 +361,21 @@ async function runExecProcess(opts: {
|
|||||||
notifyOnExit: boolean;
|
notifyOnExit: boolean;
|
||||||
scopeKey?: string;
|
scopeKey?: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
|
agentId?: string;
|
||||||
|
elevated?: boolean;
|
||||||
timeoutSec: number;
|
timeoutSec: number;
|
||||||
onUpdate?: (partialResult: AgentToolResult<ExecToolDetails>) => void;
|
onUpdate?: (partialResult: AgentToolResult<ExecToolDetails>) => void;
|
||||||
}): Promise<ExecProcessHandle> {
|
}): 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 startedAt = Date.now();
|
||||||
const sessionId = createSessionSlug();
|
const sessionId = createSessionSlug();
|
||||||
let child: ChildProcessWithoutNullStreams | null = null;
|
let child: ChildProcessWithoutNullStreams | null = null;
|
||||||
@ -762,6 +778,13 @@ export function createExecTool(
|
|||||||
// SECURITY: Check for dangerous command patterns before any execution
|
// SECURITY: Check for dangerous command patterns before any execution
|
||||||
const dangerousMatch = detectDangerousCommand(params.command, "high");
|
const dangerousMatch = detectDangerousCommand(params.command, "high");
|
||||||
if (dangerousMatch) {
|
if (dangerousMatch) {
|
||||||
|
logDangerousCommandBlocked({
|
||||||
|
sessionKey: defaults?.sessionKey,
|
||||||
|
agentId,
|
||||||
|
command: params.command,
|
||||||
|
reason: dangerousMatch.reason,
|
||||||
|
severity: dangerousMatch.severity,
|
||||||
|
});
|
||||||
throw new Error(formatDangerousCommandError(dangerousMatch));
|
throw new Error(formatDangerousCommandError(dangerousMatch));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
377
src/security/audit-log.ts
Normal file
377
src/security/audit-log.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user