//! Audit logging for security events use crate::config::AuditConfig; use anyhow::Result; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fs::OpenOptions; use std::io::Write; use std::path::PathBuf; use std::sync::Mutex; 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>, } 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( &self, channel: &str, command: &str, risk_level: &str, approved: bool, allowed: bool, success: bool, duration_ms: u64, ) -> Result<()> { let event = AuditEvent::new(AuditEventType::CommandExecution) .with_actor(channel.to_string(), None, None) .with_action( command.to_string(), risk_level.to_string(), approved, allowed, ) .with_result(success, None, duration_ms, None); self.log(&event) } /// 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 >= self.config.max_size_mb as u64 { 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 parsed: AuditEvent = serde_json::from_str(&json.unwrap().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(()) } }