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!(
318            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
319            year, month, day, hours, minutes, seconds
320        )
321    }
322
323    /// Convert days since Unix epoch to year/month/day
324    fn days_to_ymd(days: u64) -> (i32, u32, u32) {
325        // Simplified calculation (not accounting for all edge cases)
326        let mut remaining_days = days as i64;
327        let mut year = 1970i32;
328
329        loop {
330            let days_in_year = if Self::is_leap_year(year) { 366 } else { 365 };
331            if remaining_days < days_in_year {
332                break;
333            }
334            remaining_days -= days_in_year;
335            year += 1;
336        }
337
338        let is_leap = Self::is_leap_year(year);
339        let month_days: [i64; 12] = if is_leap {
340            [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
341        } else {
342            [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
343        };
344
345        let mut month = 1u32;
346        for &days in &month_days {
347            if remaining_days < days {
348                break;
349            }
350            remaining_days -= days;
351            month += 1;
352        }
353
354        (year, month, (remaining_days + 1) as u32)
355    }
356
357    fn is_leap_year(year: i32) -> bool {
358        (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
359    }
360
361    /// Generate a unique session ID
362    fn generate_session_id() -> String {
363        use std::hash::{Hash, Hasher};
364        use std::time::Instant;
365
366        let mut hasher = std::collections::hash_map::DefaultHasher::new();
367        Instant::now().hash(&mut hasher);
368        std::thread::current().id().hash(&mut hasher);
369
370        let hash = hasher.finish();
371        format!("{:016x}", hash)
372    }
373
374    /// Serialize to JSON
375    pub fn to_json(&self) -> String {
376        serde_json::to_string(self).unwrap_or_else(|_| String::from("{}"))
377    }
378}
379
380/// Trait for audit log backends
381pub trait AuditLogger: Send + Sync {
382    /// Log an audit event
383    fn log(&self, event: AuditEvent);
384
385    /// Flush any buffered events
386    fn flush(&self);
387
388    /// Get the minimum severity level to log
389    fn min_severity(&self) -> AuditSeverity;
390
391    /// Check if an event should be logged based on severity
392    fn should_log(&self, severity: AuditSeverity) -> bool {
393        let min = self.min_severity();
394        match (min, severity) {
395            (AuditSeverity::Debug, _) => true,
396            (AuditSeverity::Info, AuditSeverity::Debug) => false,
397            (AuditSeverity::Info, _) => true,
398            (AuditSeverity::Warning, AuditSeverity::Debug | AuditSeverity::Info) => false,
399            (AuditSeverity::Warning, _) => true,
400            (AuditSeverity::High, AuditSeverity::High | AuditSeverity::Critical) => true,
401            (AuditSeverity::High, _) => false,
402            (AuditSeverity::Critical, AuditSeverity::Critical) => true,
403            (AuditSeverity::Critical, _) => false,
404        }
405    }
406}
407
408/// File-based audit logger (JSONL format)
409pub struct FileAuditLogger {
410    writer: Mutex<BufWriter<File>>,
411    min_severity: AuditSeverity,
412}
413
414impl FileAuditLogger {
415    /// Create a new file-based audit logger
416    pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
417        let file = OpenOptions::new().create(true).append(true).open(path)?;
418        Ok(Self {
419            writer: Mutex::new(BufWriter::new(file)),
420            min_severity: AuditSeverity::Info,
421        })
422    }
423
424    /// Create with a specific minimum severity level
425    pub fn with_min_severity(mut self, severity: AuditSeverity) -> Self {
426        self.min_severity = severity;
427        self
428    }
429}
430
431impl AuditLogger for FileAuditLogger {
432    fn log(&self, event: AuditEvent) {
433        if !self.should_log(event.severity) {
434            return;
435        }
436
437        let json = event.to_json();
438        if let Ok(mut writer) = self.writer.lock() {
439            // Ignore write errors - audit logging should not crash the app
440            drop(writeln!(writer, "{}", json));
441        }
442    }
443
444    fn flush(&self) {
445        if let Ok(mut writer) = self.writer.lock() {
446            // Ignore flush errors - audit logging should not crash the app
447            drop(writer.flush());
448        }
449    }
450
451    fn min_severity(&self) -> AuditSeverity {
452        self.min_severity
453    }
454}
455
456/// In-memory audit logger for testing
457pub struct MemoryAuditLogger {
458    events: RwLock<Vec<AuditEvent>>,
459    min_severity: AuditSeverity,
460}
461
462impl Default for MemoryAuditLogger {
463    fn default() -> Self {
464        Self::new()
465    }
466}
467
468impl MemoryAuditLogger {
469    /// Create a new in-memory audit logger
470    pub fn new() -> Self {
471        Self {
472            events: RwLock::new(Vec::new()),
473            min_severity: AuditSeverity::Debug,
474        }
475    }
476
477    /// Create with a specific minimum severity level
478    pub fn with_min_severity(mut self, severity: AuditSeverity) -> Self {
479        self.min_severity = severity;
480        self
481    }
482
483    /// Get all logged events
484    pub fn events(&self) -> Vec<AuditEvent> {
485        self.events.read().map(|e| e.clone()).unwrap_or_default()
486    }
487
488    /// Get events of a specific kind
489    pub fn events_of_kind(&self, kind: AuditEventKind) -> Vec<AuditEvent> {
490        self.events()
491            .into_iter()
492            .filter(|e| e.event == kind)
493            .collect()
494    }
495
496    /// Get events with severity >= threshold
497    pub fn events_with_min_severity(&self, severity: AuditSeverity) -> Vec<AuditEvent> {
498        self.events()
499            .into_iter()
500            .filter(|e| {
501                let temp_logger = MemoryAuditLogger {
502                    events: RwLock::new(Vec::new()),
503                    min_severity: severity,
504                };
505                temp_logger.should_log(e.severity)
506            })
507            .collect()
508    }
509
510    /// Clear all events
511    pub fn clear(&self) {
512        if let Ok(mut events) = self.events.write() {
513            events.clear();
514        }
515    }
516
517    /// Get event count
518    pub fn len(&self) -> usize {
519        self.events.read().map(|e| e.len()).unwrap_or(0)
520    }
521
522    /// Check if empty
523    pub fn is_empty(&self) -> bool {
524        self.len() == 0
525    }
526}
527
528impl AuditLogger for MemoryAuditLogger {
529    fn log(&self, event: AuditEvent) {
530        if !self.should_log(event.severity) {
531            return;
532        }
533
534        if let Ok(mut events) = self.events.write() {
535            events.push(event);
536        }
537    }
538
539    fn flush(&self) {
540        // No-op for in-memory logger
541    }
542
543    fn min_severity(&self) -> AuditSeverity {
544        self.min_severity
545    }
546}
547
548/// No-op audit logger (disables auditing)
549pub struct NullAuditLogger;
550
551impl AuditLogger for NullAuditLogger {
552    fn log(&self, _event: AuditEvent) {
553        // No-op
554    }
555
556    fn flush(&self) {
557        // No-op
558    }
559
560    fn min_severity(&self) -> AuditSeverity {
561        AuditSeverity::Critical // Only log critical events (effectively disabled)
562    }
563}
564
565/// Multi-logger that fans out to multiple backends
566pub struct MultiAuditLogger {
567    loggers: Vec<Arc<dyn AuditLogger>>,
568}
569
570impl MultiAuditLogger {
571    /// Create a new multi-logger
572    pub fn new(loggers: Vec<Arc<dyn AuditLogger>>) -> Self {
573        Self { loggers }
574    }
575}
576
577impl AuditLogger for MultiAuditLogger {
578    fn log(&self, event: AuditEvent) {
579        for logger in &self.loggers {
580            logger.log(event.clone());
581        }
582    }
583
584    fn flush(&self) {
585        for logger in &self.loggers {
586            logger.flush();
587        }
588    }
589
590    fn min_severity(&self) -> AuditSeverity {
591        // Return the lowest (most permissive) severity
592        self.loggers
593            .iter()
594            .map(|l| l.min_severity())
595            .min_by(|a, b| {
596                let order = |s: &AuditSeverity| match s {
597                    AuditSeverity::Debug => 0,
598                    AuditSeverity::Info => 1,
599                    AuditSeverity::Warning => 2,
600                    AuditSeverity::High => 3,
601                    AuditSeverity::Critical => 4,
602                };
603                order(a).cmp(&order(b))
604            })
605            .unwrap_or(AuditSeverity::Info)
606    }
607}
608
609/// Global audit logger registry
610static GLOBAL_LOGGER: RwLock<Option<Arc<dyn AuditLogger>>> = RwLock::new(None);
611
612/// Set the global audit logger
613pub fn set_global_logger(logger: Arc<dyn AuditLogger>) {
614    if let Ok(mut global) = GLOBAL_LOGGER.write() {
615        *global = Some(logger);
616    }
617}
618
619/// Get the global audit logger (returns NullAuditLogger if not set)
620pub fn get_global_logger() -> Arc<dyn AuditLogger> {
621    GLOBAL_LOGGER
622        .read()
623        .ok()
624        .and_then(|g| g.clone())
625        .unwrap_or_else(|| Arc::new(NullAuditLogger))
626}
627
628/// Log an event to the global logger
629pub fn log_event(event: AuditEvent) {
630    get_global_logger().log(event);
631}
632
633/// Convenience function to log a scan started event
634pub fn log_scan_started(repo_id: &str, user: Option<&str>, path: &str) {
635    log_event(
636        AuditEvent::new(AuditEventKind::ScanStarted, repo_id, user.map(String::from))
637            .with_detail("path", path),
638    );
639}
640
641/// Convenience function to log a scan completed event
642pub fn log_scan_completed(repo_id: &str, session_id: &str, files: usize, chunks: usize) {
643    log_event(
644        AuditEvent::with_session(
645            AuditEventKind::ScanCompleted,
646            repo_id,
647            session_id,
648            None,
649        )
650        .with_detail("files_processed", files.to_string())
651        .with_detail("chunks_generated", chunks.to_string()),
652    );
653}
654
655/// Convenience function to log a secret detection
656pub fn log_secret_detected(
657    repo_id: &str,
658    session_id: &str,
659    file: &str,
660    line: u32,
661    kind: &str,
662) {
663    log_event(
664        AuditEvent::with_session(
665            AuditEventKind::SecretDetected,
666            repo_id,
667            session_id,
668            None,
669        )
670        .with_detail("file", file)
671        .with_detail("line", line.to_string())
672        .with_detail("secret_kind", kind),
673    );
674}
675
676/// Convenience function to log PII detection
677pub fn log_pii_detected(
678    repo_id: &str,
679    session_id: &str,
680    file: &str,
681    line: u32,
682    pii_type: &str,
683) {
684    log_event(
685        AuditEvent::with_session(
686            AuditEventKind::PiiDetected,
687            repo_id,
688            session_id,
689            None,
690        )
691        .with_detail("file", file)
692        .with_detail("line", line.to_string())
693        .with_detail("pii_type", pii_type),
694    );
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn test_audit_event_creation() {
703        let event = AuditEvent::new(AuditEventKind::ScanStarted, "test-repo", Some("user@test.com".to_string()));
704
705        assert_eq!(event.event, AuditEventKind::ScanStarted);
706        assert_eq!(event.repo_id, "test-repo");
707        assert_eq!(event.user, Some("user@test.com".to_string()));
708        assert_eq!(event.severity, AuditSeverity::Info);
709        assert!(!event.timestamp.is_empty());
710        assert!(!event.session_id.is_empty());
711    }
712
713    #[test]
714    fn test_audit_event_with_details() {
715        let event = AuditEvent::new(AuditEventKind::SecretDetected, "test-repo", None)
716            .with_detail("file", "config.py")
717            .with_detail("line", "42")
718            .with_detail("kind", "AWS Credential");
719
720        assert_eq!(event.details.get("file"), Some(&"config.py".to_string()));
721        assert_eq!(event.details.get("line"), Some(&"42".to_string()));
722        assert_eq!(event.details.get("kind"), Some(&"AWS Credential".to_string()));
723    }
724
725    #[test]
726    fn test_audit_severity_ordering() {
727        let logger = MemoryAuditLogger::new().with_min_severity(AuditSeverity::Warning);
728
729        assert!(!logger.should_log(AuditSeverity::Debug));
730        assert!(!logger.should_log(AuditSeverity::Info));
731        assert!(logger.should_log(AuditSeverity::Warning));
732        assert!(logger.should_log(AuditSeverity::High));
733        assert!(logger.should_log(AuditSeverity::Critical));
734    }
735
736    #[test]
737    fn test_memory_audit_logger() {
738        let logger = MemoryAuditLogger::new();
739
740        logger.log(AuditEvent::new(AuditEventKind::ScanStarted, "repo1", None));
741        logger.log(AuditEvent::new(AuditEventKind::SecretDetected, "repo1", None));
742        logger.log(AuditEvent::new(AuditEventKind::ScanCompleted, "repo1", None));
743
744        assert_eq!(logger.len(), 3);
745
746        let secrets = logger.events_of_kind(AuditEventKind::SecretDetected);
747        assert_eq!(secrets.len(), 1);
748    }
749
750    #[test]
751    fn test_memory_logger_severity_filter() {
752        let logger = MemoryAuditLogger::new().with_min_severity(AuditSeverity::High);
753
754        // Info event - should NOT be logged
755        logger.log(AuditEvent::new(AuditEventKind::ScanStarted, "repo1", None));
756
757        // Critical event - should be logged
758        logger.log(AuditEvent::new(AuditEventKind::SecretDetected, "repo1", None));
759
760        // High event - should be logged
761        logger.log(AuditEvent::new(AuditEventKind::PiiDetected, "repo1", None));
762
763        assert_eq!(logger.len(), 2);
764    }
765
766    #[test]
767    fn test_event_json_serialization() {
768        let event = AuditEvent::new(AuditEventKind::SecretDetected, "test-repo", Some("user@test.com".to_string()))
769            .with_detail("file", "secret.py");
770
771        let json = event.to_json();
772
773        assert!(json.contains("\"event\":\"secret_detected\""));
774        assert!(json.contains("\"repo_id\":\"test-repo\""));
775        assert!(json.contains("\"user\":\"user@test.com\""));
776        assert!(json.contains("\"severity\":\"critical\""));
777        assert!(json.contains("\"file\":\"secret.py\""));
778    }
779
780    #[test]
781    fn test_default_severities() {
782        assert_eq!(
783            AuditEventKind::SecretDetected.default_severity(),
784            AuditSeverity::Critical
785        );
786        assert_eq!(
787            AuditEventKind::PiiDetected.default_severity(),
788            AuditSeverity::High
789        );
790        assert_eq!(
791            AuditEventKind::ScanStarted.default_severity(),
792            AuditSeverity::Info
793        );
794        assert_eq!(
795            AuditEventKind::ChunkGenerated.default_severity(),
796            AuditSeverity::Debug
797        );
798    }
799
800    #[test]
801    fn test_null_audit_logger() {
802        let logger = NullAuditLogger;
803
804        // Should not panic
805        logger.log(AuditEvent::new(AuditEventKind::SecretDetected, "repo", None));
806        logger.flush();
807
808        // Only logs critical
809        assert_eq!(logger.min_severity(), AuditSeverity::Critical);
810    }
811
812    #[test]
813    fn test_global_logger_api() {
814        // Test that set/get global logger APIs work correctly.
815        // Note: Due to parallel test execution, we can't use log_event() reliably
816        // because another test might replace the global logger between set and log.
817        // Instead, we test the logger itself works correctly.
818
819        let memory_logger = Arc::new(MemoryAuditLogger::new());
820
821        // Test that set_global_logger and get_global_logger work
822        set_global_logger(memory_logger.clone());
823
824        // Log directly to our logger (not via global) to verify it works
825        memory_logger.log(AuditEvent::new(AuditEventKind::ScanStarted, "test", None));
826        assert_eq!(memory_logger.len(), 1);
827
828        // Verify the logger has expected properties
829        assert_eq!(memory_logger.min_severity(), AuditSeverity::Debug);
830    }
831
832    #[test]
833    fn test_convenience_function_events() {
834        // Test that convenience functions create correct event structures.
835        // We test directly on a local logger to avoid global state race conditions.
836
837        let logger = MemoryAuditLogger::new();
838
839        // Test scan started event structure
840        let event = AuditEvent::new(AuditEventKind::ScanStarted, "repo", Some("user".to_string()))
841            .with_detail("path", "/path/to/repo");
842        logger.log(event);
843
844        // Test secret detected event structure
845        let event = AuditEvent::with_session(AuditEventKind::SecretDetected, "repo", "session123", None)
846            .with_detail("file", "config.py")
847            .with_detail("line", "42")
848            .with_detail("secret_kind", "AWS Key");
849        logger.log(event);
850
851        // Test PII detected event structure
852        let event = AuditEvent::with_session(AuditEventKind::PiiDetected, "repo", "session123", None)
853            .with_detail("file", "data.txt")
854            .with_detail("line", "10")
855            .with_detail("pii_type", "SSN");
856        logger.log(event);
857
858        // Test scan completed event structure
859        let event = AuditEvent::with_session(AuditEventKind::ScanCompleted, "repo", "session123", None)
860            .with_detail("files_processed", "100")
861            .with_detail("chunks_generated", "500");
862        logger.log(event);
863
864        assert_eq!(logger.len(), 4);
865
866        // Verify event kinds were logged correctly
867        let events = logger.events();
868        assert_eq!(events[0].event, AuditEventKind::ScanStarted);
869        assert_eq!(events[1].event, AuditEventKind::SecretDetected);
870        assert_eq!(events[2].event, AuditEventKind::PiiDetected);
871        assert_eq!(events[3].event, AuditEventKind::ScanCompleted);
872
873        // Verify details are captured
874        assert_eq!(events[0].details.get("path"), Some(&"/path/to/repo".to_string()));
875        assert_eq!(events[1].details.get("secret_kind"), Some(&"AWS Key".to_string()));
876        assert_eq!(events[2].details.get("pii_type"), Some(&"SSN".to_string()));
877        assert_eq!(events[3].details.get("files_processed"), Some(&"100".to_string()));
878    }
879
880    #[test]
881    fn test_iso8601_timestamp_format() {
882        let event = AuditEvent::new(AuditEventKind::ScanStarted, "repo", None);
883
884        // Should match ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ
885        let re = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$").unwrap();
886        assert!(re.is_match(&event.timestamp), "Timestamp {} doesn't match ISO 8601", event.timestamp);
887    }
888
889    #[test]
890    fn test_session_correlation() {
891        let session_id = "test-session-123";
892
893        let event1 = AuditEvent::with_session(
894            AuditEventKind::ScanStarted,
895            "repo",
896            session_id,
897            None,
898        );
899
900        let event2 = AuditEvent::with_session(
901            AuditEventKind::ScanCompleted,
902            "repo",
903            session_id,
904            None,
905        );
906
907        assert_eq!(event1.session_id, event2.session_id);
908    }
909}