//! Audit logging for security events use crate::config::AuditConfig; use anyhow::Result; use chrono::{DateTime, Utc}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use std::fs::OpenOptions; use std::io::Write; use std::path::PathBuf; use uuid::Uuid; /// Audit event types #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuditEventType { CommandExecution, FileAccess, ConfigChange, AuthSuccess, AuthFailure, PolicyViolation, SecurityEvent, } /// Actor information (who performed the action) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Actor { pub channel: String, pub user_id: Option, pub username: Option, } /// Action information (what was done) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Action { pub command: Option, pub risk_level: Option, pub approved: bool, pub allowed: bool, } /// Execution result #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExecutionResult { pub success: bool, pub exit_code: Option, pub duration_ms: Option, pub error: Option, } /// Security context #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecurityContext { pub policy_violation: bool, pub rate_limit_remaining: Option, pub sandbox_backend: Option, } /// Complete audit event #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditEvent { pub timestamp: DateTime, pub event_id: String, pub event_type: AuditEventType, pub actor: Option, pub action: Option, pub result: Option, pub security: SecurityContext, } impl AuditEvent { /// Create a new audit event pub fn new(event_type: AuditEventType) -> Self { Self { timestamp: Utc::now(), event_id: Uuid::new_v4().to_string(), event_type, actor: None, action: None, result: None, security: SecurityContext { policy_violation: false, rate_limit_remaining: None, sandbox_backend: None, }, } } /// Set the actor pub fn with_actor( mut self, channel: String, user_id: Option, username: Option, ) -> Self { self.actor = Some(Actor { channel, user_id, username, }); self } /// Set the action pub fn with_action( mut self, command: String, risk_level: String, approved: bool, allowed: bool, ) -> Self { self.action = Some(Action { command: Some(command), risk_level: Some(risk_level), approved, allowed, }); self } /// Set the result pub fn with_result( mut self, success: bool, exit_code: Option, duration_ms: u64, error: Option, ) -> Self { self.result = Some(ExecutionResult { success, exit_code, duration_ms: Some(duration_ms), error, }); self } /// Set security context pub fn with_security(mut self, sandbox_backend: Option) -> Self { self.security.sandbox_backend = sandbox_backend; self } } /// Audit logger pub struct AuditLogger { log_path: PathBuf, config: AuditConfig, buffer: Mutex>, } /// Structured command execution details for audit logging. #[derive(Debug, Clone)] pub struct CommandExecutionLog<'a> { pub channel: &'a str, pub command: &'a str, pub risk_level: &'a str, pub approved: bool, pub allowed: bool, pub success: bool, pub duration_ms: u64, } impl AuditLogger { /// Create a new audit logger pub fn new(config: AuditConfig, zeroclaw_dir: PathBuf) -> Result { let log_path = zeroclaw_dir.join(&config.log_path); Ok(Self { log_path, config, buffer: Mutex::new(Vec::new()), }) } /// Log an event pub fn log(&self, event: &AuditEvent) -> Result<()> { if !self.config.enabled { return Ok(()); } // Check log size and rotate if needed self.rotate_if_needed()?; // Serialize and write let line = serde_json::to_string(event)?; let mut file = OpenOptions::new() .create(true) .append(true) .open(&self.log_path)?; writeln!(file, "{}", line)?; file.sync_all()?; Ok(()) } /// Log a command execution event. pub fn log_command_event(&self, entry: CommandExecutionLog<'_>) -> Result<()> { let event = AuditEvent::new(AuditEventType::CommandExecution) .with_actor(entry.channel.to_string(), None, None) .with_action( entry.command.to_string(), entry.risk_level.to_string(), entry.approved, entry.allowed, ) .with_result(entry.success, None, entry.duration_ms, None); self.log(&event) } /// Backward-compatible helper to log a command execution event. #[allow(clippy::too_many_arguments)] pub fn log_command( &self, channel: &str, command: &str, risk_level: &str, approved: bool, allowed: bool, success: bool, duration_ms: u64, ) -> Result<()> { self.log_command_event(CommandExecutionLog { channel, command, risk_level, approved, allowed, success, duration_ms, }) } /// Rotate log if it exceeds max size fn rotate_if_needed(&self) -> Result<()> { if let Ok(metadata) = std::fs::metadata(&self.log_path) { let current_size_mb = metadata.len() / (1024 * 1024); if current_size_mb >= u64::from(self.config.max_size_mb) { self.rotate()?; } } Ok(()) } /// Rotate the log file fn rotate(&self) -> Result<()> { for i in (1..10).rev() { let old_name = format!("{}.{}.log", self.log_path.display(), i); let new_name = format!("{}.{}.log", self.log_path.display(), i + 1); let _ = std::fs::rename(&old_name, &new_name); } let rotated = format!("{}.1.log", self.log_path.display()); std::fs::rename(&self.log_path, &rotated)?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn audit_event_new_creates_unique_id() { let event1 = AuditEvent::new(AuditEventType::CommandExecution); let event2 = AuditEvent::new(AuditEventType::CommandExecution); assert_ne!(event1.event_id, event2.event_id); } #[test] fn audit_event_with_actor() { let event = AuditEvent::new(AuditEventType::CommandExecution).with_actor( "telegram".to_string(), Some("123".to_string()), Some("@alice".to_string()), ); assert!(event.actor.is_some()); let actor = event.actor.as_ref().unwrap(); assert_eq!(actor.channel, "telegram"); assert_eq!(actor.user_id, Some("123".to_string())); assert_eq!(actor.username, Some("@alice".to_string())); } #[test] fn audit_event_with_action() { let event = AuditEvent::new(AuditEventType::CommandExecution).with_action( "ls -la".to_string(), "low".to_string(), false, true, ); assert!(event.action.is_some()); let action = event.action.as_ref().unwrap(); assert_eq!(action.command, Some("ls -la".to_string())); assert_eq!(action.risk_level, Some("low".to_string())); } #[test] fn audit_event_serializes_to_json() { let event = AuditEvent::new(AuditEventType::CommandExecution) .with_actor("telegram".to_string(), None, None) .with_action("ls".to_string(), "low".to_string(), false, true) .with_result(true, Some(0), 15, None); let json = serde_json::to_string(&event); assert!(json.is_ok()); let json = json.expect("serialize"); let parsed: AuditEvent = serde_json::from_str(json.as_str()).expect("parse"); assert!(parsed.actor.is_some()); assert!(parsed.action.is_some()); assert!(parsed.result.is_some()); } #[test] fn audit_logger_disabled_does_not_create_file() -> Result<()> { let tmp = TempDir::new()?; let config = AuditConfig { enabled: false, ..Default::default() }; let logger = AuditLogger::new(config, tmp.path().to_path_buf())?; let event = AuditEvent::new(AuditEventType::CommandExecution); logger.log(&event)?; // File should not exist since logging is disabled assert!(!tmp.path().join("audit.log").exists()); Ok(()) } // ── §8.1 Log rotation tests ───────────────────────────── #[tokio::test] async fn audit_logger_writes_event_when_enabled() -> Result<()> { let tmp = TempDir::new()?; let config = AuditConfig { enabled: true, max_size_mb: 10, ..Default::default() }; let logger = AuditLogger::new(config, tmp.path().to_path_buf())?; let event = AuditEvent::new(AuditEventType::CommandExecution) .with_actor("cli".to_string(), None, None) .with_action("ls".to_string(), "low".to_string(), false, true); logger.log(&event)?; let log_path = tmp.path().join("audit.log"); assert!(log_path.exists(), "audit log file must be created"); let content = tokio::fs::read_to_string(&log_path).await?; assert!(!content.is_empty(), "audit log must not be empty"); let parsed: AuditEvent = serde_json::from_str(content.trim())?; assert!(parsed.action.is_some()); Ok(()) } #[tokio::test] async fn audit_log_command_event_writes_structured_entry() -> Result<()> { let tmp = TempDir::new()?; let config = AuditConfig { enabled: true, max_size_mb: 10, ..Default::default() }; let logger = AuditLogger::new(config, tmp.path().to_path_buf())?; logger.log_command_event(CommandExecutionLog { channel: "telegram", command: "echo test", risk_level: "low", approved: false, allowed: true, success: true, duration_ms: 42, })?; let log_path = tmp.path().join("audit.log"); let content = tokio::fs::read_to_string(&log_path).await?; let parsed: AuditEvent = serde_json::from_str(content.trim())?; let action = parsed.action.unwrap(); assert_eq!(action.command, Some("echo test".to_string())); assert_eq!(action.risk_level, Some("low".to_string())); assert!(action.allowed); let result = parsed.result.unwrap(); assert!(result.success); assert_eq!(result.duration_ms, Some(42)); Ok(()) } #[test] fn audit_rotation_creates_numbered_backup() -> Result<()> { let tmp = TempDir::new()?; let config = AuditConfig { enabled: true, max_size_mb: 0, // Force rotation on first write ..Default::default() }; let logger = AuditLogger::new(config, tmp.path().to_path_buf())?; // Write initial content that triggers rotation let log_path = tmp.path().join("audit.log"); std::fs::write(&log_path, "initial content\n")?; let event = AuditEvent::new(AuditEventType::CommandExecution); logger.log(&event)?; let rotated = format!("{}.1.log", log_path.display()); assert!( std::path::Path::new(&rotated).exists(), "rotation must create .1.log backup" ); Ok(()) } }