423 lines
12 KiB
Rust
423 lines
12 KiB
Rust
//! 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<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>>,
|
|
}
|
|
|
|
/// 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<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_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(())
|
|
}
|
|
}
|