Skip to main content

zeph_tools/
audit.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::Path;
5
6use crate::config::AuditConfig;
7
8#[derive(Debug)]
9pub struct AuditLogger {
10    destination: AuditDestination,
11}
12
13#[derive(Debug)]
14enum AuditDestination {
15    Stdout,
16    File(tokio::sync::Mutex<tokio::fs::File>),
17}
18
19#[derive(serde::Serialize)]
20pub struct AuditEntry {
21    pub timestamp: String,
22    pub tool: String,
23    pub command: String,
24    pub result: AuditResult,
25    pub duration_ms: u64,
26    /// Fine-grained error category label from the taxonomy. `None` for successful executions.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub error_category: Option<String>,
29}
30
31#[derive(serde::Serialize)]
32#[serde(tag = "type")]
33pub enum AuditResult {
34    #[serde(rename = "success")]
35    Success,
36    #[serde(rename = "blocked")]
37    Blocked { reason: String },
38    #[serde(rename = "error")]
39    Error { message: String },
40    #[serde(rename = "timeout")]
41    Timeout,
42}
43
44impl AuditLogger {
45    /// Create a new `AuditLogger` from config.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error if a file destination cannot be opened.
50    pub async fn from_config(config: &AuditConfig) -> Result<Self, std::io::Error> {
51        let destination = if config.destination == "stdout" {
52            AuditDestination::Stdout
53        } else {
54            let file = tokio::fs::OpenOptions::new()
55                .create(true)
56                .append(true)
57                .open(Path::new(&config.destination))
58                .await?;
59            AuditDestination::File(tokio::sync::Mutex::new(file))
60        };
61
62        Ok(Self { destination })
63    }
64
65    pub async fn log(&self, entry: &AuditEntry) {
66        let Ok(json) = serde_json::to_string(entry) else {
67            return;
68        };
69
70        match &self.destination {
71            AuditDestination::Stdout => {
72                tracing::info!(target: "audit", "{json}");
73            }
74            AuditDestination::File(file) => {
75                use tokio::io::AsyncWriteExt;
76                let mut f = file.lock().await;
77                let line = format!("{json}\n");
78                if let Err(e) = f.write_all(line.as_bytes()).await {
79                    tracing::error!("failed to write audit log: {e}");
80                } else if let Err(e) = f.flush().await {
81                    tracing::error!("failed to flush audit log: {e}");
82                }
83            }
84        }
85    }
86}
87
88pub(crate) fn chrono_now() -> String {
89    use std::time::{SystemTime, UNIX_EPOCH};
90    let secs = SystemTime::now()
91        .duration_since(UNIX_EPOCH)
92        .unwrap_or_default()
93        .as_secs();
94    format!("{secs}")
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn audit_entry_serialization() {
103        let entry = AuditEntry {
104            timestamp: "1234567890".into(),
105            tool: "shell".into(),
106            command: "echo hello".into(),
107            result: AuditResult::Success,
108            duration_ms: 42,
109            error_category: None,
110        };
111        let json = serde_json::to_string(&entry).unwrap();
112        assert!(json.contains("\"type\":\"success\""));
113        assert!(json.contains("\"tool\":\"shell\""));
114        assert!(json.contains("\"duration_ms\":42"));
115    }
116
117    #[test]
118    fn audit_result_blocked_serialization() {
119        let entry = AuditEntry {
120            timestamp: "0".into(),
121            tool: "shell".into(),
122            command: "sudo rm".into(),
123            result: AuditResult::Blocked {
124                reason: "blocked command: sudo".into(),
125            },
126            duration_ms: 0,
127            error_category: Some("policy_blocked".to_owned()),
128        };
129        let json = serde_json::to_string(&entry).unwrap();
130        assert!(json.contains("\"type\":\"blocked\""));
131        assert!(json.contains("\"reason\""));
132    }
133
134    #[test]
135    fn audit_result_error_serialization() {
136        let entry = AuditEntry {
137            timestamp: "0".into(),
138            tool: "shell".into(),
139            command: "bad".into(),
140            result: AuditResult::Error {
141                message: "exec failed".into(),
142            },
143            duration_ms: 0,
144            error_category: None,
145        };
146        let json = serde_json::to_string(&entry).unwrap();
147        assert!(json.contains("\"type\":\"error\""));
148    }
149
150    #[test]
151    fn audit_result_timeout_serialization() {
152        let entry = AuditEntry {
153            timestamp: "0".into(),
154            tool: "shell".into(),
155            command: "sleep 999".into(),
156            result: AuditResult::Timeout,
157            duration_ms: 30000,
158            error_category: Some("timeout".to_owned()),
159        };
160        let json = serde_json::to_string(&entry).unwrap();
161        assert!(json.contains("\"type\":\"timeout\""));
162    }
163
164    #[tokio::test]
165    async fn audit_logger_stdout() {
166        let config = AuditConfig {
167            enabled: true,
168            destination: "stdout".into(),
169        };
170        let logger = AuditLogger::from_config(&config).await.unwrap();
171        let entry = AuditEntry {
172            timestamp: "0".into(),
173            tool: "shell".into(),
174            command: "echo test".into(),
175            result: AuditResult::Success,
176            duration_ms: 1,
177            error_category: None,
178        };
179        logger.log(&entry).await;
180    }
181
182    #[tokio::test]
183    async fn audit_logger_file() {
184        let dir = tempfile::tempdir().unwrap();
185        let path = dir.path().join("audit.log");
186        let config = AuditConfig {
187            enabled: true,
188            destination: path.display().to_string(),
189        };
190        let logger = AuditLogger::from_config(&config).await.unwrap();
191        let entry = AuditEntry {
192            timestamp: "0".into(),
193            tool: "shell".into(),
194            command: "echo test".into(),
195            result: AuditResult::Success,
196            duration_ms: 1,
197            error_category: None,
198        };
199        logger.log(&entry).await;
200
201        let content = tokio::fs::read_to_string(&path).await.unwrap();
202        assert!(content.contains("\"tool\":\"shell\""));
203    }
204
205    #[tokio::test]
206    async fn audit_logger_file_write_error_logged() {
207        let config = AuditConfig {
208            enabled: true,
209            destination: "/nonexistent/dir/audit.log".into(),
210        };
211        let result = AuditLogger::from_config(&config).await;
212        assert!(result.is_err());
213    }
214
215    #[tokio::test]
216    async fn audit_logger_multiple_entries() {
217        let dir = tempfile::tempdir().unwrap();
218        let path = dir.path().join("audit.log");
219        let config = AuditConfig {
220            enabled: true,
221            destination: path.display().to_string(),
222        };
223        let logger = AuditLogger::from_config(&config).await.unwrap();
224
225        for i in 0..5 {
226            let entry = AuditEntry {
227                timestamp: i.to_string(),
228                tool: "shell".into(),
229                command: format!("cmd{i}"),
230                result: AuditResult::Success,
231                duration_ms: i,
232                error_category: None,
233            };
234            logger.log(&entry).await;
235        }
236
237        let content = tokio::fs::read_to_string(&path).await.unwrap();
238        assert_eq!(content.lines().count(), 5);
239    }
240}