Skip to main content

crawlkit_engine/
audit.rs

1use std::sync::Arc;
2
3use chrono::{DateTime, Utc};
4use parking_lot::Mutex;
5use serde::{Deserialize, Serialize};
6
7/// Audit event types.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub enum AuditEventType {
10    CrawlStarted,
11    CrawlCompleted,
12    CrawlFailed,
13    PageFetched,
14    PageAnalysisStarted,
15    PageAnalysisCompleted,
16    FindingStored,
17    ExportGenerated,
18    ConfigChanged,
19    ApiKeyCreated,
20    ApiKeyRevoked,
21    ErrorOccurred,
22}
23
24/// An audit trail event.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AuditEvent {
27    /// Unique event ID.
28    pub id: String,
29    /// Event timestamp.
30    pub timestamp: DateTime<Utc>,
31    /// Type of event.
32    pub event_type: AuditEventType,
33    /// Actor who triggered the event (API key, CLI user).
34    pub actor: String,
35    /// Event details.
36    pub details: String,
37    /// SHA-256 hash of event data for tamper evidence.
38    pub hash: String,
39    /// Hash of previous event (chain).
40    pub previous_hash: String,
41}
42
43/// Append-only audit trail with SHA-256 chaining.
44///
45/// Provides tamper-evident logging for compliance (Defence standards).
46pub struct AuditTrail {
47    events: Arc<Mutex<Vec<AuditEvent>>>,
48}
49
50impl AuditTrail {
51    /// Create empty audit trail.
52    #[must_use]
53    pub fn new() -> Self {
54        Self {
55            events: Arc::new(Mutex::new(Vec::new())),
56        }
57    }
58
59    /// Record an audit event.
60    pub fn record(&self, event_type: AuditEventType, actor: &str, details: &str) -> AuditEvent {
61        let mut events = self.events.lock();
62        let previous_hash = events
63            .last()
64            .map(|e| e.hash.clone())
65            .unwrap_or_else(|| "genesis".to_string());
66
67        let event = AuditEvent {
68            id: uuid::Uuid::new_v4().to_string(),
69            timestamp: Utc::now(),
70            event_type,
71            actor: actor.to_string(),
72            details: details.to_string(),
73            hash: compute_hash(details, &previous_hash),
74            previous_hash,
75        };
76
77        events.push(event.clone());
78        event
79    }
80
81    /// Get all events.
82    #[must_use]
83    pub fn events(&self) -> Vec<AuditEvent> {
84        self.events.lock().clone()
85    }
86
87    /// Get event count.
88    #[must_use]
89    pub fn len(&self) -> usize {
90        self.events.lock().len()
91    }
92
93    /// Check if trail is empty.
94    #[must_use]
95    pub fn is_empty(&self) -> bool {
96        self.events.lock().is_empty()
97    }
98
99    /// Verify chain integrity.
100    #[must_use]
101    pub fn verify_integrity(&self) -> bool {
102        let events = self.events.lock();
103        let mut prev_hash = "genesis".to_string();
104
105        for event in events.iter() {
106            if event.previous_hash != prev_hash {
107                return false;
108            }
109            let expected_hash = compute_hash(&event.details, &event.previous_hash);
110            if event.hash != expected_hash {
111                return false;
112            }
113            prev_hash = event.hash.clone();
114        }
115
116        true
117    }
118
119    /// Clear all events.
120    pub fn clear(&self) {
121        self.events.lock().clear();
122    }
123}
124
125impl Default for AuditTrail {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131/// Compute SHA-256 hash for tamper evidence.
132///
133/// Uses SHA-256 (FIPS 180-4) for cryptographic tamper evidence.
134/// The hash chains `details` with `previous_hash` to create an append-only
135/// tamper-evident log.
136fn compute_hash(details: &str, previous_hash: &str) -> String {
137    use sha2::{Digest, Sha256};
138
139    let mut hasher = Sha256::new();
140    hasher.update(details.as_bytes());
141    hasher.update(b"\0"); // separator to prevent length-extension ambiguity
142    hasher.update(previous_hash.as_bytes());
143    let result = hasher.finalize();
144    // Format each byte as lowercase hex
145    result.iter().map(|b| format!("{b:02x}")).collect()
146}
147
148// ---------------------------------------------------------------------------
149// Tests
150// ---------------------------------------------------------------------------
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_audit_trail_record() {
158        let trail = AuditTrail::new();
159        let event = trail.record(
160            AuditEventType::CrawlStarted,
161            "cli",
162            "Crawl started for https://example.com",
163        );
164
165        assert_eq!(trail.len(), 1);
166        assert!(!event.id.is_empty());
167        assert_eq!(event.previous_hash, "genesis");
168    }
169
170    #[test]
171    fn test_audit_trail_chain() {
172        let trail = AuditTrail::new();
173        let e1 = trail.record(AuditEventType::CrawlStarted, "cli", "start");
174        let e2 = trail.record(AuditEventType::PageFetched, "cli", "fetched");
175
176        assert_eq!(e2.previous_hash, e1.hash);
177        assert!(trail.verify_integrity());
178    }
179
180    #[test]
181    fn test_audit_trail_integrity() {
182        let trail = AuditTrail::new();
183        trail.record(AuditEventType::CrawlStarted, "cli", "start");
184        trail.record(AuditEventType::PageFetched, "cli", "fetched");
185
186        assert!(trail.verify_integrity());
187    }
188
189    #[test]
190    fn test_audit_trail_empty() {
191        let trail = AuditTrail::new();
192        assert!(trail.is_empty());
193        assert!(trail.verify_integrity());
194    }
195}