Skip to main content

agentmesh/
audit.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Append-only hash-chain audit log with SHA-256 integrity verification.
5
6use crate::types::{AuditEntry, AuditFilter};
7use sha2::{Digest, Sha256};
8use std::sync::Mutex;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11/// Append-only hash-chained audit logger.
12///
13/// Each entry's hash covers `seq|timestamp|agent_id|action|decision|prev_hash`,
14/// creating a tamper-evident chain from the genesis entry.
15pub struct AuditLogger {
16    entries: Mutex<Vec<AuditEntry>>,
17    max_entries: Option<usize>,
18}
19
20impl AuditLogger {
21    /// Create an empty audit logger with no entry limit.
22    pub fn new() -> Self {
23        Self {
24            entries: Mutex::new(Vec::new()),
25            max_entries: None,
26        }
27    }
28
29    /// Create an audit logger that retains at most `max` entries,
30    /// evicting the oldest when the limit is exceeded.
31    pub fn with_max_entries(max: usize) -> Self {
32        Self {
33            entries: Mutex::new(Vec::new()),
34            max_entries: Some(max),
35        }
36    }
37
38    /// Append a new entry to the audit chain and return it.
39    pub fn log(&self, agent_id: &str, action: &str, decision: &str) -> AuditEntry {
40        let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
41        let seq = entries.len() as u64;
42        let prev_hash = entries.last().map(|e| e.hash.clone()).unwrap_or_default();
43        let timestamp = iso8601_now();
44
45        let hash_input = format!(
46            "{}|{}|{}|{}|{}|{}",
47            seq, timestamp, agent_id, action, decision, prev_hash
48        );
49        let hash = sha256_hex(&hash_input);
50
51        let entry = AuditEntry {
52            seq,
53            timestamp,
54            agent_id: agent_id.to_string(),
55            action: action.to_string(),
56            decision: decision.to_string(),
57            previous_hash: prev_hash,
58            hash,
59        };
60
61        entries.push(entry.clone());
62
63        // Evict oldest entries when the retention limit is exceeded.
64        if let Some(max) = self.max_entries {
65            if entries.len() > max {
66                let overflow = entries.len() - max;
67                entries.drain(..overflow);
68            }
69        }
70
71        entry
72    }
73
74    /// Verify the integrity of the entire hash chain.
75    ///
76    /// Returns `true` if every entry's hash is correct and linked to the
77    /// previous entry's hash.
78    pub fn verify(&self) -> bool {
79        let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
80        for (i, entry) in entries.iter().enumerate() {
81            let expected_prev = if i == 0 {
82                String::new()
83            } else {
84                entries[i - 1].hash.clone()
85            };
86            if entry.previous_hash != expected_prev {
87                return false;
88            }
89            let hash_input = format!(
90                "{}|{}|{}|{}|{}|{}",
91                entry.seq,
92                entry.timestamp,
93                entry.agent_id,
94                entry.action,
95                entry.decision,
96                entry.previous_hash
97            );
98            if entry.hash != sha256_hex(&hash_input) {
99                return false;
100            }
101        }
102        true
103    }
104
105    /// Return all audit entries.
106    pub fn entries(&self) -> Vec<AuditEntry> {
107        self.entries
108            .lock()
109            .unwrap_or_else(|e| e.into_inner())
110            .clone()
111    }
112
113    /// Serialise all audit entries to a JSON string.
114    pub fn export_json(&self) -> String {
115        let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
116        serde_json::to_string(&*entries).unwrap_or_else(|_| "[]".to_string())
117    }
118
119    /// Return entries matching the given filter.
120    pub fn get_entries(&self, filter: &AuditFilter) -> Vec<AuditEntry> {
121        self.entries
122            .lock()
123            .unwrap_or_else(|e| e.into_inner())
124            .iter()
125            .filter(|e| {
126                if let Some(ref id) = filter.agent_id {
127                    if e.agent_id != *id {
128                        return false;
129                    }
130                }
131                if let Some(ref action) = filter.action {
132                    if e.action != *action {
133                        return false;
134                    }
135                }
136                if let Some(ref decision) = filter.decision {
137                    if e.decision != *decision {
138                        return false;
139                    }
140                }
141                true
142            })
143            .cloned()
144            .collect()
145    }
146}
147
148impl Default for AuditLogger {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154fn sha256_hex(input: &str) -> String {
155    let mut hasher = Sha256::new();
156    hasher.update(input.as_bytes());
157    let result = hasher.finalize();
158    hex_encode(&result)
159}
160
161fn hex_encode(bytes: &[u8]) -> String {
162    bytes.iter().map(|b| format!("{:02x}", b)).collect()
163}
164
165fn iso8601_now() -> String {
166    let d = SystemTime::now()
167        .duration_since(UNIX_EPOCH)
168        .unwrap_or_default();
169    let secs = d.as_secs();
170    let days = secs / 86400;
171    let time_of_day = secs % 86400;
172    let hours = time_of_day / 3600;
173    let minutes = (time_of_day % 3600) / 60;
174    let seconds = time_of_day % 60;
175
176    // Days since epoch → civil date (Howard Hinnant algorithm)
177    let z = days as i64 + 719_468;
178    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
179    let doe = (z - era * 146_097) as u64;
180    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
181    let y = yoe as i64 + era * 400;
182    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
183    let mp = (5 * doy + 2) / 153;
184    let d_val = doy - (153 * mp + 2) / 5 + 1;
185    let m_val = if mp < 10 { mp + 3 } else { mp - 9 };
186    let y_val = if m_val <= 2 { y + 1 } else { y };
187
188    format!(
189        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
190        y_val, m_val, d_val, hours, minutes, seconds
191    )
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_append_and_verify() {
200        let logger = AuditLogger::new();
201        logger.log("agent-1", "data.read", "allow");
202        logger.log("agent-1", "shell:rm", "deny");
203        logger.log("agent-2", "deploy.prod", "requires_approval");
204        assert!(logger.verify());
205        assert_eq!(logger.entries().len(), 3);
206    }
207
208    #[test]
209    fn test_genesis_has_empty_prev_hash() {
210        let logger = AuditLogger::new();
211        let entry = logger.log("agent-1", "test", "allow");
212        assert!(entry.previous_hash.is_empty());
213    }
214
215    #[test]
216    fn test_chain_links() {
217        let logger = AuditLogger::new();
218        let e1 = logger.log("a", "action1", "allow");
219        let e2 = logger.log("a", "action2", "deny");
220        assert_eq!(e2.previous_hash, e1.hash);
221    }
222
223    #[test]
224    fn test_tamper_detection() {
225        let logger = AuditLogger::new();
226        logger.log("agent-1", "data.read", "allow");
227        logger.log("agent-1", "data.write", "allow");
228
229        // Tamper with the first entry
230        {
231            let mut entries = logger.entries.lock().unwrap();
232            entries[0].action = "tampered".to_string();
233        }
234        assert!(!logger.verify());
235    }
236
237    #[test]
238    fn test_filter() {
239        let logger = AuditLogger::new();
240        logger.log("agent-1", "data.read", "allow");
241        logger.log("agent-2", "data.write", "deny");
242        logger.log("agent-1", "shell:ls", "deny");
243
244        let filter = AuditFilter {
245            agent_id: Some("agent-1".to_string()),
246            ..Default::default()
247        };
248        let filtered = logger.get_entries(&filter);
249        assert_eq!(filtered.len(), 2);
250
251        let filter = AuditFilter {
252            decision: Some("deny".to_string()),
253            ..Default::default()
254        };
255        let filtered = logger.get_entries(&filter);
256        assert_eq!(filtered.len(), 2);
257    }
258
259    #[test]
260    fn test_sha256_not_placeholder() {
261        let hash = sha256_hex("test");
262        // SHA-256 of "test" is a known value
263        assert_eq!(hash.len(), 64); // 32 bytes = 64 hex chars
264        assert_eq!(
265            hash,
266            "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
267        );
268    }
269
270    #[test]
271    fn test_export_json() {
272        let logger = AuditLogger::new();
273        logger.log("agent-1", "data.read", "allow");
274        logger.log("agent-2", "shell:rm", "deny");
275        let json = logger.export_json();
276        let parsed: Vec<AuditEntry> = serde_json::from_str(&json).unwrap();
277        assert_eq!(parsed.len(), 2);
278        assert_eq!(parsed[0].agent_id, "agent-1");
279        assert_eq!(parsed[1].agent_id, "agent-2");
280    }
281
282    #[test]
283    fn test_export_json_empty() {
284        let logger = AuditLogger::new();
285        let json = logger.export_json();
286        assert_eq!(json, "[]");
287    }
288
289    #[test]
290    fn test_max_entries_eviction() {
291        let logger = AuditLogger::with_max_entries(3);
292        for i in 0..5 {
293            logger.log("agent", &format!("action-{}", i), "allow");
294        }
295        let entries = logger.entries();
296        assert_eq!(entries.len(), 3);
297        // Oldest entries (action-0, action-1) should have been evicted
298        assert_eq!(entries[0].action, "action-2");
299        assert_eq!(entries[1].action, "action-3");
300        assert_eq!(entries[2].action, "action-4");
301    }
302
303    #[test]
304    fn test_max_entries_not_exceeded() {
305        let logger = AuditLogger::with_max_entries(10);
306        logger.log("a", "x", "allow");
307        logger.log("b", "y", "deny");
308        assert_eq!(logger.entries().len(), 2);
309    }
310
311    #[test]
312    fn test_no_limit_grows_unbounded() {
313        let logger = AuditLogger::new();
314        for i in 0..100 {
315            logger.log("a", &format!("act-{}", i), "allow");
316        }
317        assert_eq!(logger.entries().len(), 100);
318    }
319
320    #[test]
321    fn test_empty_log_verify_returns_true() {
322        let logger = AuditLogger::new();
323        assert!(logger.verify());
324    }
325
326    #[test]
327    fn test_large_chain_verifies() {
328        let logger = AuditLogger::new();
329        for i in 0..50 {
330            logger.log("agent", &format!("action.{}", i), "allow");
331        }
332        assert!(logger.verify());
333        assert_eq!(logger.entries().len(), 50);
334    }
335
336    #[test]
337    fn test_tamper_middle_of_chain() {
338        let logger = AuditLogger::new();
339        for i in 0..5 {
340            logger.log("agent", &format!("action.{}", i), "allow");
341        }
342        {
343            let mut entries = logger.entries.lock().unwrap();
344            entries[2].action = "tampered".to_string();
345        }
346        assert!(!logger.verify());
347    }
348
349    #[test]
350    fn test_tamper_previous_hash_field() {
351        let logger = AuditLogger::new();
352        logger.log("agent", "a", "allow");
353        logger.log("agent", "b", "allow");
354        logger.log("agent", "c", "allow");
355        {
356            let mut entries = logger.entries.lock().unwrap();
357            entries[1].previous_hash =
358                "0000000000000000000000000000000000000000000000000000000000000000".to_string();
359        }
360        assert!(!logger.verify());
361    }
362
363    #[test]
364    fn test_filter_by_action_only() {
365        let logger = AuditLogger::new();
366        logger.log("agent-1", "data.read", "allow");
367        logger.log("agent-2", "data.read", "deny");
368        logger.log("agent-1", "data.write", "allow");
369        let filter = AuditFilter {
370            action: Some("data.read".to_string()),
371            ..Default::default()
372        };
373        let filtered = logger.get_entries(&filter);
374        assert_eq!(filtered.len(), 2);
375        assert!(filtered.iter().all(|e| e.action == "data.read"));
376    }
377
378    #[test]
379    fn test_filter_by_decision_only() {
380        let logger = AuditLogger::new();
381        logger.log("agent-1", "data.read", "allow");
382        logger.log("agent-1", "shell:rm", "deny");
383        logger.log("agent-2", "data.write", "allow");
384        let filter = AuditFilter {
385            decision: Some("allow".to_string()),
386            ..Default::default()
387        };
388        let filtered = logger.get_entries(&filter);
389        assert_eq!(filtered.len(), 2);
390        assert!(filtered.iter().all(|e| e.decision == "allow"));
391    }
392
393    #[test]
394    fn test_filter_multiple_criteria_and_logic() {
395        let logger = AuditLogger::new();
396        logger.log("agent-1", "data.read", "allow");
397        logger.log("agent-1", "data.read", "deny");
398        logger.log("agent-2", "data.read", "allow");
399        let filter = AuditFilter {
400            agent_id: Some("agent-1".to_string()),
401            action: Some("data.read".to_string()),
402            decision: Some("allow".to_string()),
403        };
404        let filtered = logger.get_entries(&filter);
405        assert_eq!(filtered.len(), 1);
406        assert_eq!(filtered[0].agent_id, "agent-1");
407        assert_eq!(filtered[0].action, "data.read");
408        assert_eq!(filtered[0].decision, "allow");
409    }
410
411    #[test]
412    fn test_filter_matching_nothing_returns_empty() {
413        let logger = AuditLogger::new();
414        logger.log("agent-1", "data.read", "allow");
415        let filter = AuditFilter {
416            agent_id: Some("nonexistent".to_string()),
417            ..Default::default()
418        };
419        let filtered = logger.get_entries(&filter);
420        assert!(filtered.is_empty());
421    }
422
423    #[test]
424    fn test_sequential_seq_numbers() {
425        let logger = AuditLogger::new();
426        for _ in 0..5 {
427            logger.log("agent", "action", "allow");
428        }
429        let entries = logger.entries();
430        for (i, entry) in entries.iter().enumerate() {
431            assert_eq!(entry.seq, i as u64);
432        }
433    }
434
435    #[test]
436    fn test_hash_is_64_hex_characters() {
437        let logger = AuditLogger::new();
438        let entry = logger.log("agent", "action", "allow");
439        assert_eq!(entry.hash.len(), 64);
440        assert!(entry.hash.chars().all(|c| c.is_ascii_hexdigit()));
441    }
442
443    #[test]
444    fn test_iso8601_now_format() {
445        let ts = iso8601_now();
446        // Should match YYYY-MM-DDTHH:MM:SSZ
447        assert_eq!(ts.len(), 20);
448        assert_eq!(&ts[4..5], "-");
449        assert_eq!(&ts[7..8], "-");
450        assert_eq!(&ts[10..11], "T");
451        assert_eq!(&ts[13..14], ":");
452        assert_eq!(&ts[16..17], ":");
453        assert_eq!(&ts[19..20], "Z");
454    }
455
456    #[test]
457    fn test_hex_encode_lowercase() {
458        let bytes = [0xAB, 0xCD, 0xEF, 0x01];
459        let hex = hex_encode(&bytes);
460        assert_eq!(hex, "abcdef01");
461        // Confirm all lowercase
462        assert_eq!(hex, hex.to_lowercase());
463    }
464}