zeroclaw/src/security/audit.rs
Chummy 9d29f30a31
fix(channels): execute tool calls in channel runtime (#302)
* fix(channels): execute tool calls in channel runtime (#302)

* chore(fmt): align repo formatting with rustfmt 1.92
2026-02-16 05:07:01 -05:00

308 lines
8.2 KiB
Rust

//! 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<String>,
pub username: Option<String>,
}
/// Action information (what was done)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Action {
pub command: Option<String>,
pub risk_level: Option<String>,
pub approved: bool,
pub allowed: bool,
}
/// Execution result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionResult {
pub success: bool,
pub exit_code: Option<i32>,
pub duration_ms: Option<u64>,
pub error: Option<String>,
}
/// Security context
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityContext {
pub policy_violation: bool,
pub rate_limit_remaining: Option<u32>,
pub sandbox_backend: Option<String>,
}
/// Complete audit event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub timestamp: DateTime<Utc>,
pub event_id: String,
pub event_type: AuditEventType,
pub actor: Option<Actor>,
pub action: Option<Action>,
pub result: Option<ExecutionResult>,
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<String>,
username: Option<String>,
) -> 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<i32>,
duration_ms: u64,
error: Option<String>,
) -> 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<String>) -> Self {
self.security.sandbox_backend = sandbox_backend;
self
}
}
/// Audit logger
pub struct AuditLogger {
log_path: PathBuf,
config: AuditConfig,
buffer: Mutex<Vec<AuditEvent>>,
}
impl AuditLogger {
/// Create a new audit logger
pub fn new(config: AuditConfig, zeroclaw_dir: PathBuf) -> Result<Self> {
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(())
}
}