infiniloom_engine/
audit.rs

1//! Audit logging framework for SOC2/GDPR/HIPAA compliance
2//!
3//! This module provides comprehensive audit logging for security-sensitive operations
4//! in the embedding pipeline. It tracks all actions that access, process, or transform
5//! sensitive data for compliance and forensic purposes.
6//!
7//! # SOC2 Compliance
8//!
9//! The audit log supports SOC2 requirements for:
10//! - **CC6.1**: Logical access security (who accessed what)
11//! - **CC6.6**: System operations monitoring
12//! - **CC7.2**: Change management tracking
13//!
14//! # Quick Start
15//!
16//! ```rust,no_run
17//! use infiniloom_engine::audit::{AuditLogger, FileAuditLogger, AuditEvent, AuditEventKind};
18//!
19//! // Create a file-based audit logger
20//! let logger = FileAuditLogger::new("/var/log/infiniloom/audit.jsonl").unwrap();
21//!
22//! // Log a scan event
23//! logger.log(AuditEvent::new(
24//!     AuditEventKind::ScanStarted,
25//!     "repo-123",
26//!     Some("user@example.com".to_string()),
27//! ).with_detail("path", "/path/to/repo"));
28//!
29//! // Log a secret detection
30//! logger.log(AuditEvent::new(
31//!     AuditEventKind::SecretDetected,
32//!     "repo-123",
33//!     Some("user@example.com".to_string()),
34//! ).with_detail("file", "config.py")
35//!  .with_detail("kind", "AWS Credential")
36//!  .with_detail("line", "42"));
37//! ```
38//!
39//! # Event Types
40//!
41//! The audit log captures these security-relevant events:
42//!
43//! | Event | Description | Severity |
44//! |-------|-------------|----------|
45//! | `ScanStarted` | Repository scan initiated | Info |
46//! | `ScanCompleted` | Repository scan finished | Info |
47//! | `SecretDetected` | Secret/credential found | Critical |
48//! | `PiiDetected` | PII data found | High |
49//! | `SecretRedacted` | Secret was redacted | Warning |
50//! | `ChunkGenerated` | Embedding chunk created | Debug |
51//! | `ManifestUpdated` | Manifest file changed | Info |
52//! | `AccessDenied` | Access control rejection | Warning |
53//! | `ConfigChanged` | Settings modified | Info |
54//!
55//! # Log Format (JSONL)
56//!
57//! Each log entry is a JSON object on a single line:
58//!
59//! ```json
60//! {"timestamp":"2024-01-15T10:30:00Z","event":"secret_detected","session_id":"abc123","repo_id":"myrepo","user":"admin@corp.com","severity":"critical","details":{"file":"config.py","kind":"AWS Credential","line":"42"}}
61//! ```
62//!
63//! # Retention and Rotation
64//!
65//! For production deployments, configure log rotation externally (e.g., logrotate)
66//! and ensure logs are retained per your compliance requirements (typically 1-7 years).
67
68use serde::{Deserialize, Serialize};
69use std::collections::HashMap;
70use std::fs::{File, OpenOptions};
71use std::io::{BufWriter, Write};
72use std::path::Path;
73use std::sync::{Arc, Mutex, RwLock};
74use std::time::SystemTime;
75
76/// Audit event severity levels
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum AuditSeverity {
80    /// Debug-level events (chunk generation, etc.)
81    Debug,
82    /// Informational events (scan start/complete, config changes)
83    Info,
84    /// Warning events (redaction, access issues)
85    Warning,
86    /// High severity (PII detection)
87    High,
88    /// Critical security events (secrets detected)
89    Critical,
90}
91
92impl AuditSeverity {
93    /// Get the string representation
94    pub fn as_str(&self) -> &'static str {
95        match self {
96            Self::Debug => "debug",
97            Self::Info => "info",
98            Self::Warning => "warning",
99            Self::High => "high",
100            Self::Critical => "critical",
101        }
102    }
103}
104
105/// Types of auditable events
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum AuditEventKind {
109    // === Scan Lifecycle ===
110    /// Repository scan started
111    ScanStarted,
112    /// Repository scan completed
113    ScanCompleted,
114    /// Scan failed with error
115    ScanFailed,
116
117    // === Security Events ===
118    /// Secret/credential detected
119    SecretDetected,
120    /// PII data detected
121    PiiDetected,
122    /// Secret was redacted from output
123    SecretRedacted,
124    /// License compliance issue detected
125    LicenseViolation,
126
127    // === Chunk Operations ===
128    /// Embedding chunk generated
129    ChunkGenerated,
130    /// Chunk was split due to size
131    ChunkSplit,
132    /// Chunk was skipped (e.g., binary file)
133    ChunkSkipped,
134
135    // === Manifest Operations ===
136    /// Manifest file created
137    ManifestCreated,
138    /// Manifest file updated
139    ManifestUpdated,
140    /// Manifest diff computed
141    ManifestDiffComputed,
142
143    // === Access Control ===
144    /// Access denied to resource
145    AccessDenied,
146    /// Path traversal attempt blocked
147    PathTraversalBlocked,
148
149    // === Configuration ===
150    /// Configuration loaded
151    ConfigLoaded,
152    /// Configuration changed
153    ConfigChanged,
154
155    // === Export/Output ===
156    /// Data exported to file
157    DataExported,
158    /// Data sent to external service
159    DataTransmitted,
160}
161
162impl AuditEventKind {
163    /// Get the default severity for this event type
164    pub fn default_severity(&self) -> AuditSeverity {
165        match self {
166            // Critical
167            Self::SecretDetected => AuditSeverity::Critical,
168            Self::PathTraversalBlocked => AuditSeverity::Critical,
169
170            // High
171            Self::PiiDetected => AuditSeverity::High,
172            Self::LicenseViolation => AuditSeverity::High,
173            Self::AccessDenied => AuditSeverity::High,
174
175            // Warning
176            Self::SecretRedacted => AuditSeverity::Warning,
177            Self::ScanFailed => AuditSeverity::Warning,
178            Self::ChunkSkipped => AuditSeverity::Warning,
179
180            // Info
181            Self::ScanStarted
182            | Self::ScanCompleted
183            | Self::ManifestCreated
184            | Self::ManifestUpdated
185            | Self::ManifestDiffComputed
186            | Self::ConfigLoaded
187            | Self::ConfigChanged
188            | Self::DataExported
189            | Self::DataTransmitted => AuditSeverity::Info,
190
191            // Debug
192            Self::ChunkGenerated | Self::ChunkSplit => AuditSeverity::Debug,
193        }
194    }
195
196    /// Get human-readable event name
197    pub fn name(&self) -> &'static str {
198        match self {
199            Self::ScanStarted => "scan_started",
200            Self::ScanCompleted => "scan_completed",
201            Self::ScanFailed => "scan_failed",
202            Self::SecretDetected => "secret_detected",
203            Self::PiiDetected => "pii_detected",
204            Self::SecretRedacted => "secret_redacted",
205            Self::LicenseViolation => "license_violation",
206            Self::ChunkGenerated => "chunk_generated",
207            Self::ChunkSplit => "chunk_split",
208            Self::ChunkSkipped => "chunk_skipped",
209            Self::ManifestCreated => "manifest_created",
210            Self::ManifestUpdated => "manifest_updated",
211            Self::ManifestDiffComputed => "manifest_diff_computed",
212            Self::AccessDenied => "access_denied",
213            Self::PathTraversalBlocked => "path_traversal_blocked",
214            Self::ConfigLoaded => "config_loaded",
215            Self::ConfigChanged => "config_changed",
216            Self::DataExported => "data_exported",
217            Self::DataTransmitted => "data_transmitted",
218        }
219    }
220}
221
222/// A single audit log event
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct AuditEvent {
225    /// ISO 8601 timestamp
226    pub timestamp: String,
227
228    /// Event type
229    pub event: AuditEventKind,
230
231    /// Unique session/request ID for correlation
232    pub session_id: String,
233
234    /// Repository identifier
235    pub repo_id: String,
236
237    /// User/service account that triggered the event
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub user: Option<String>,
240
241    /// Event severity
242    pub severity: AuditSeverity,
243
244    /// Additional key-value details
245    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
246    pub details: HashMap<String, String>,
247}
248
249impl AuditEvent {
250    /// Create a new audit event
251    pub fn new(kind: AuditEventKind, repo_id: impl Into<String>, user: Option<String>) -> Self {
252        Self {
253            timestamp: Self::iso8601_now(),
254            event: kind,
255            session_id: Self::generate_session_id(),
256            repo_id: repo_id.into(),
257            user,
258            severity: kind.default_severity(),
259            details: HashMap::new(),
260        }
261    }
262
263    /// Create an event with a specific session ID (for correlation)
264    pub fn with_session(
265        kind: AuditEventKind,
266        repo_id: impl Into<String>,
267        session_id: impl Into<String>,
268        user: Option<String>,
269    ) -> Self {
270        Self {
271            timestamp: Self::iso8601_now(),
272            event: kind,
273            session_id: session_id.into(),
274            repo_id: repo_id.into(),
275            user,
276            severity: kind.default_severity(),
277            details: HashMap::new(),
278        }
279    }
280
281    /// Add a detail key-value pair
282    pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
283        self.details.insert(key.into(), value.into());
284        self
285    }
286
287    /// Add multiple details at once
288    pub fn with_details(mut self, details: impl IntoIterator<Item = (String, String)>) -> Self {
289        self.details.extend(details);
290        self
291    }
292
293    /// Override the default severity
294    pub fn with_severity(mut self, severity: AuditSeverity) -> Self {
295        self.severity = severity;
296        self
297    }
298
299    /// Generate ISO 8601 timestamp
300    fn iso8601_now() -> String {
301        let now = SystemTime::now();
302        let duration = now
303            .duration_since(SystemTime::UNIX_EPOCH)
304            .unwrap_or_default();
305        let secs = duration.as_secs();
306
307        // Convert to date/time components (simplified UTC)
308        let days = secs / 86400;
309        let remaining = secs % 86400;
310        let hours = remaining / 3600;
311        let minutes = (remaining % 3600) / 60;
312        let seconds = remaining % 60;
313
314        // Calculate year, month, day from days since epoch
315        let (year, month, day) = Self::days_to_ymd(days);
316
317        format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", year, month, day, hours, minutes, seconds)
318    }
319
320    /// Convert days since Unix epoch to year/month/day
321    fn days_to_ymd(days: u64) -> (i32, u32, u32) {
322        // Simplified calculation (not accounting for all edge cases)
323        let mut remaining_days = days as i64;
324        let mut year = 1970i32;
325
326        loop {
327            let days_in_year = if Self::is_leap_year(year) { 366 } else { 365 };
328            if remaining_days < days_in_year {
329                break;
330            }
331            remaining_days -= days_in_year;
332            year += 1;
333        }
334
335        let is_leap = Self::is_leap_year(year);
336        let month_days: [i64; 12] = if is_leap {
337            [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
338        } else {
339            [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
340        };
341
342        let mut month = 1u32;
343        for &days in &month_days {
344            if remaining_days < days {
345                break;
346            }
347            remaining_days -= days;
348            month += 1;
349        }
350
351        (year, month, (remaining_days + 1) as u32)
352    }
353
354    fn is_leap_year(year: i32) -> bool {
355        (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
356    }
357
358    /// Generate a unique session ID
359    fn generate_session_id() -> String {
360        use std::hash::{Hash, Hasher};
361        use std::time::Instant;
362
363        let mut hasher = std::collections::hash_map::DefaultHasher::new();
364        Instant::now().hash(&mut hasher);
365        std::thread::current().id().hash(&mut hasher);
366
367        let hash = hasher.finish();
368        format!("{:016x}", hash)
369    }
370
371    /// Serialize to JSON
372    pub fn to_json(&self) -> String {
373        serde_json::to_string(self).unwrap_or_else(|_| String::from("{}"))
374    }
375}
376
377/// Trait for audit log backends
378pub trait AuditLogger: Send + Sync {
379    /// Log an audit event
380    fn log(&self, event: AuditEvent);
381
382    /// Flush any buffered events
383    fn flush(&self);
384
385    /// Get the minimum severity level to log
386    fn min_severity(&self) -> AuditSeverity;
387
388    /// Check if an event should be logged based on severity
389    fn should_log(&self, severity: AuditSeverity) -> bool {
390        let min = self.min_severity();
391        match (min, severity) {
392            (AuditSeverity::Debug, _) => true,
393            (AuditSeverity::Info, AuditSeverity::Debug) => false,
394            (AuditSeverity::Info, _) => true,
395            (AuditSeverity::Warning, AuditSeverity::Debug | AuditSeverity::Info) => false,
396            (AuditSeverity::Warning, _) => true,
397            (AuditSeverity::High, AuditSeverity::High | AuditSeverity::Critical) => true,
398            (AuditSeverity::High, _) => false,
399            (AuditSeverity::Critical, AuditSeverity::Critical) => true,
400            (AuditSeverity::Critical, _) => false,
401        }
402    }
403}
404
405/// File-based audit logger (JSONL format)
406pub struct FileAuditLogger {
407    writer: Mutex<BufWriter<File>>,
408    min_severity: AuditSeverity,
409}
410
411impl FileAuditLogger {
412    /// Create a new file-based audit logger
413    pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
414        let file = OpenOptions::new().create(true).append(true).open(path)?;
415        Ok(Self { writer: Mutex::new(BufWriter::new(file)), min_severity: AuditSeverity::Info })
416    }
417
418    /// Create with a specific minimum severity level
419    pub fn with_min_severity(mut self, severity: AuditSeverity) -> Self {
420        self.min_severity = severity;
421        self
422    }
423}
424
425impl AuditLogger for FileAuditLogger {
426    fn log(&self, event: AuditEvent) {
427        if !self.should_log(event.severity) {
428            return;
429        }
430
431        let json = event.to_json();
432        if let Ok(mut writer) = self.writer.lock() {
433            // Ignore write errors - audit logging should not crash the app
434            drop(writeln!(writer, "{}", json));
435        }
436    }
437
438    fn flush(&self) {
439        if let Ok(mut writer) = self.writer.lock() {
440            // Ignore flush errors - audit logging should not crash the app
441            drop(writer.flush());
442        }
443    }
444
445    fn min_severity(&self) -> AuditSeverity {
446        self.min_severity
447    }
448}
449
450/// In-memory audit logger for testing
451pub struct MemoryAuditLogger {
452    events: RwLock<Vec<AuditEvent>>,
453    min_severity: AuditSeverity,
454}
455
456impl Default for MemoryAuditLogger {
457    fn default() -> Self {
458        Self::new()
459    }
460}
461
462impl MemoryAuditLogger {
463    /// Create a new in-memory audit logger
464    pub fn new() -> Self {
465        Self { events: RwLock::new(Vec::new()), min_severity: AuditSeverity::Debug }
466    }
467
468    /// Create with a specific minimum severity level
469    pub fn with_min_severity(mut self, severity: AuditSeverity) -> Self {
470        self.min_severity = severity;
471        self
472    }
473
474    /// Get all logged events
475    pub fn events(&self) -> Vec<AuditEvent> {
476        self.events.read().map(|e| e.clone()).unwrap_or_default()
477    }
478
479    /// Get events of a specific kind
480    pub fn events_of_kind(&self, kind: AuditEventKind) -> Vec<AuditEvent> {
481        self.events()
482            .into_iter()
483            .filter(|e| e.event == kind)
484            .collect()
485    }
486
487    /// Get events with severity >= threshold
488    pub fn events_with_min_severity(&self, severity: AuditSeverity) -> Vec<AuditEvent> {
489        self.events()
490            .into_iter()
491            .filter(|e| {
492                let temp_logger =
493                    MemoryAuditLogger { events: RwLock::new(Vec::new()), min_severity: severity };
494                temp_logger.should_log(e.severity)
495            })
496            .collect()
497    }
498
499    /// Clear all events
500    pub fn clear(&self) {
501        if let Ok(mut events) = self.events.write() {
502            events.clear();
503        }
504    }
505
506    /// Get event count
507    pub fn len(&self) -> usize {
508        self.events.read().map(|e| e.len()).unwrap_or(0)
509    }
510
511    /// Check if empty
512    pub fn is_empty(&self) -> bool {
513        self.len() == 0
514    }
515}
516
517impl AuditLogger for MemoryAuditLogger {
518    fn log(&self, event: AuditEvent) {
519        if !self.should_log(event.severity) {
520            return;
521        }
522
523        if let Ok(mut events) = self.events.write() {
524            events.push(event);
525        }
526    }
527
528    fn flush(&self) {
529        // No-op for in-memory logger
530    }
531
532    fn min_severity(&self) -> AuditSeverity {
533        self.min_severity
534    }
535}
536
537/// No-op audit logger (disables auditing)
538pub struct NullAuditLogger;
539
540impl AuditLogger for NullAuditLogger {
541    fn log(&self, _event: AuditEvent) {
542        // No-op
543    }
544
545    fn flush(&self) {
546        // No-op
547    }
548
549    fn min_severity(&self) -> AuditSeverity {
550        AuditSeverity::Critical // Only log critical events (effectively disabled)
551    }
552}
553
554/// Multi-logger that fans out to multiple backends
555pub struct MultiAuditLogger {
556    loggers: Vec<Arc<dyn AuditLogger>>,
557}
558
559impl MultiAuditLogger {
560    /// Create a new multi-logger
561    pub fn new(loggers: Vec<Arc<dyn AuditLogger>>) -> Self {
562        Self { loggers }
563    }
564}
565
566impl AuditLogger for MultiAuditLogger {
567    fn log(&self, event: AuditEvent) {
568        for logger in &self.loggers {
569            logger.log(event.clone());
570        }
571    }
572
573    fn flush(&self) {
574        for logger in &self.loggers {
575            logger.flush();
576        }
577    }
578
579    fn min_severity(&self) -> AuditSeverity {
580        // Return the lowest (most permissive) severity
581        self.loggers
582            .iter()
583            .map(|l| l.min_severity())
584            .min_by(|a, b| {
585                let order = |s: &AuditSeverity| match s {
586                    AuditSeverity::Debug => 0,
587                    AuditSeverity::Info => 1,
588                    AuditSeverity::Warning => 2,
589                    AuditSeverity::High => 3,
590                    AuditSeverity::Critical => 4,
591                };
592                order(a).cmp(&order(b))
593            })
594            .unwrap_or(AuditSeverity::Info)
595    }
596}
597
598/// Global audit logger registry
599static GLOBAL_LOGGER: RwLock<Option<Arc<dyn AuditLogger>>> = RwLock::new(None);
600
601/// Set the global audit logger
602pub fn set_global_logger(logger: Arc<dyn AuditLogger>) {
603    if let Ok(mut global) = GLOBAL_LOGGER.write() {
604        *global = Some(logger);
605    }
606}
607
608/// Get the global audit logger (returns NullAuditLogger if not set)
609pub fn get_global_logger() -> Arc<dyn AuditLogger> {
610    GLOBAL_LOGGER
611        .read()
612        .ok()
613        .and_then(|g| g.clone())
614        .unwrap_or_else(|| Arc::new(NullAuditLogger))
615}
616
617/// Log an event to the global logger
618pub fn log_event(event: AuditEvent) {
619    get_global_logger().log(event);
620}
621
622/// Convenience function to log a scan started event
623pub fn log_scan_started(repo_id: &str, user: Option<&str>, path: &str) {
624    log_event(
625        AuditEvent::new(AuditEventKind::ScanStarted, repo_id, user.map(String::from))
626            .with_detail("path", path),
627    );
628}
629
630/// Convenience function to log a scan completed event
631pub fn log_scan_completed(repo_id: &str, session_id: &str, files: usize, chunks: usize) {
632    log_event(
633        AuditEvent::with_session(AuditEventKind::ScanCompleted, repo_id, session_id, None)
634            .with_detail("files_processed", files.to_string())
635            .with_detail("chunks_generated", chunks.to_string()),
636    );
637}
638
639/// Convenience function to log a secret detection
640pub fn log_secret_detected(repo_id: &str, session_id: &str, file: &str, line: u32, kind: &str) {
641    log_event(
642        AuditEvent::with_session(AuditEventKind::SecretDetected, repo_id, session_id, None)
643            .with_detail("file", file)
644            .with_detail("line", line.to_string())
645            .with_detail("secret_kind", kind),
646    );
647}
648
649/// Convenience function to log PII detection
650pub fn log_pii_detected(repo_id: &str, session_id: &str, file: &str, line: u32, pii_type: &str) {
651    log_event(
652        AuditEvent::with_session(AuditEventKind::PiiDetected, repo_id, session_id, None)
653            .with_detail("file", file)
654            .with_detail("line", line.to_string())
655            .with_detail("pii_type", pii_type),
656    );
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_audit_event_creation() {
665        let event = AuditEvent::new(
666            AuditEventKind::ScanStarted,
667            "test-repo",
668            Some("user@test.com".to_owned()),
669        );
670
671        assert_eq!(event.event, AuditEventKind::ScanStarted);
672        assert_eq!(event.repo_id, "test-repo");
673        assert_eq!(event.user, Some("user@test.com".to_owned()));
674        assert_eq!(event.severity, AuditSeverity::Info);
675        assert!(!event.timestamp.is_empty());
676        assert!(!event.session_id.is_empty());
677    }
678
679    #[test]
680    fn test_audit_event_with_details() {
681        let event = AuditEvent::new(AuditEventKind::SecretDetected, "test-repo", None)
682            .with_detail("file", "config.py")
683            .with_detail("line", "42")
684            .with_detail("kind", "AWS Credential");
685
686        assert_eq!(event.details.get("file"), Some(&"config.py".to_owned()));
687        assert_eq!(event.details.get("line"), Some(&"42".to_owned()));
688        assert_eq!(event.details.get("kind"), Some(&"AWS Credential".to_owned()));
689    }
690
691    #[test]
692    fn test_audit_severity_ordering() {
693        let logger = MemoryAuditLogger::new().with_min_severity(AuditSeverity::Warning);
694
695        assert!(!logger.should_log(AuditSeverity::Debug));
696        assert!(!logger.should_log(AuditSeverity::Info));
697        assert!(logger.should_log(AuditSeverity::Warning));
698        assert!(logger.should_log(AuditSeverity::High));
699        assert!(logger.should_log(AuditSeverity::Critical));
700    }
701
702    #[test]
703    fn test_memory_audit_logger() {
704        let logger = MemoryAuditLogger::new();
705
706        logger.log(AuditEvent::new(AuditEventKind::ScanStarted, "repo1", None));
707        logger.log(AuditEvent::new(AuditEventKind::SecretDetected, "repo1", None));
708        logger.log(AuditEvent::new(AuditEventKind::ScanCompleted, "repo1", None));
709
710        assert_eq!(logger.len(), 3);
711
712        let secrets = logger.events_of_kind(AuditEventKind::SecretDetected);
713        assert_eq!(secrets.len(), 1);
714    }
715
716    #[test]
717    fn test_memory_logger_severity_filter() {
718        let logger = MemoryAuditLogger::new().with_min_severity(AuditSeverity::High);
719
720        // Info event - should NOT be logged
721        logger.log(AuditEvent::new(AuditEventKind::ScanStarted, "repo1", None));
722
723        // Critical event - should be logged
724        logger.log(AuditEvent::new(AuditEventKind::SecretDetected, "repo1", None));
725
726        // High event - should be logged
727        logger.log(AuditEvent::new(AuditEventKind::PiiDetected, "repo1", None));
728
729        assert_eq!(logger.len(), 2);
730    }
731
732    #[test]
733    fn test_event_json_serialization() {
734        let event = AuditEvent::new(
735            AuditEventKind::SecretDetected,
736            "test-repo",
737            Some("user@test.com".to_owned()),
738        )
739        .with_detail("file", "secret.py");
740
741        let json = event.to_json();
742
743        assert!(json.contains("\"event\":\"secret_detected\""));
744        assert!(json.contains("\"repo_id\":\"test-repo\""));
745        assert!(json.contains("\"user\":\"user@test.com\""));
746        assert!(json.contains("\"severity\":\"critical\""));
747        assert!(json.contains("\"file\":\"secret.py\""));
748    }
749
750    #[test]
751    fn test_default_severities() {
752        assert_eq!(AuditEventKind::SecretDetected.default_severity(), AuditSeverity::Critical);
753        assert_eq!(AuditEventKind::PiiDetected.default_severity(), AuditSeverity::High);
754        assert_eq!(AuditEventKind::ScanStarted.default_severity(), AuditSeverity::Info);
755        assert_eq!(AuditEventKind::ChunkGenerated.default_severity(), AuditSeverity::Debug);
756    }
757
758    #[test]
759    fn test_null_audit_logger() {
760        let logger = NullAuditLogger;
761
762        // Should not panic
763        logger.log(AuditEvent::new(AuditEventKind::SecretDetected, "repo", None));
764        logger.flush();
765
766        // Only logs critical
767        assert_eq!(logger.min_severity(), AuditSeverity::Critical);
768    }
769
770    #[test]
771    fn test_global_logger_api() {
772        // Test that set/get global logger APIs work correctly.
773        // Note: Due to parallel test execution, we can't use log_event() reliably
774        // because another test might replace the global logger between set and log.
775        // Instead, we test the logger itself works correctly.
776
777        let memory_logger = Arc::new(MemoryAuditLogger::new());
778
779        // Test that set_global_logger and get_global_logger work
780        set_global_logger(memory_logger.clone());
781
782        // Log directly to our logger (not via global) to verify it works
783        memory_logger.log(AuditEvent::new(AuditEventKind::ScanStarted, "test", None));
784        assert_eq!(memory_logger.len(), 1);
785
786        // Verify the logger has expected properties
787        assert_eq!(memory_logger.min_severity(), AuditSeverity::Debug);
788    }
789
790    #[test]
791    fn test_convenience_function_events() {
792        // Test that convenience functions create correct event structures.
793        // We test directly on a local logger to avoid global state race conditions.
794
795        let logger = MemoryAuditLogger::new();
796
797        // Test scan started event structure
798        let event = AuditEvent::new(AuditEventKind::ScanStarted, "repo", Some("user".to_owned()))
799            .with_detail("path", "/path/to/repo");
800        logger.log(event);
801
802        // Test secret detected event structure
803        let event =
804            AuditEvent::with_session(AuditEventKind::SecretDetected, "repo", "session123", None)
805                .with_detail("file", "config.py")
806                .with_detail("line", "42")
807                .with_detail("secret_kind", "AWS Key");
808        logger.log(event);
809
810        // Test PII detected event structure
811        let event =
812            AuditEvent::with_session(AuditEventKind::PiiDetected, "repo", "session123", None)
813                .with_detail("file", "data.txt")
814                .with_detail("line", "10")
815                .with_detail("pii_type", "SSN");
816        logger.log(event);
817
818        // Test scan completed event structure
819        let event =
820            AuditEvent::with_session(AuditEventKind::ScanCompleted, "repo", "session123", None)
821                .with_detail("files_processed", "100")
822                .with_detail("chunks_generated", "500");
823        logger.log(event);
824
825        assert_eq!(logger.len(), 4);
826
827        // Verify event kinds were logged correctly
828        let events = logger.events();
829        assert_eq!(events[0].event, AuditEventKind::ScanStarted);
830        assert_eq!(events[1].event, AuditEventKind::SecretDetected);
831        assert_eq!(events[2].event, AuditEventKind::PiiDetected);
832        assert_eq!(events[3].event, AuditEventKind::ScanCompleted);
833
834        // Verify details are captured
835        assert_eq!(events[0].details.get("path"), Some(&"/path/to/repo".to_owned()));
836        assert_eq!(events[1].details.get("secret_kind"), Some(&"AWS Key".to_owned()));
837        assert_eq!(events[2].details.get("pii_type"), Some(&"SSN".to_owned()));
838        assert_eq!(events[3].details.get("files_processed"), Some(&"100".to_owned()));
839    }
840
841    #[test]
842    fn test_iso8601_timestamp_format() {
843        let event = AuditEvent::new(AuditEventKind::ScanStarted, "repo", None);
844
845        // Should match ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ
846        let re = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$").unwrap();
847        assert!(
848            re.is_match(&event.timestamp),
849            "Timestamp {} doesn't match ISO 8601",
850            event.timestamp
851        );
852    }
853
854    #[test]
855    fn test_session_correlation() {
856        let session_id = "test-session-123";
857
858        let event1 =
859            AuditEvent::with_session(AuditEventKind::ScanStarted, "repo", session_id, None);
860
861        let event2 =
862            AuditEvent::with_session(AuditEventKind::ScanCompleted, "repo", session_id, None);
863
864        assert_eq!(event1.session_id, event2.session_id);
865    }
866}