1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum AuditSeverity {
80 Debug,
82 Info,
84 Warning,
86 High,
88 Critical,
90}
91
92impl AuditSeverity {
93 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum AuditEventKind {
109 ScanStarted,
112 ScanCompleted,
114 ScanFailed,
116
117 SecretDetected,
120 PiiDetected,
122 SecretRedacted,
124 LicenseViolation,
126
127 ChunkGenerated,
130 ChunkSplit,
132 ChunkSkipped,
134
135 ManifestCreated,
138 ManifestUpdated,
140 ManifestDiffComputed,
142
143 AccessDenied,
146 PathTraversalBlocked,
148
149 ConfigLoaded,
152 ConfigChanged,
154
155 DataExported,
158 DataTransmitted,
160}
161
162impl AuditEventKind {
163 pub fn default_severity(&self) -> AuditSeverity {
165 match self {
166 Self::SecretDetected => AuditSeverity::Critical,
168 Self::PathTraversalBlocked => AuditSeverity::Critical,
169
170 Self::PiiDetected => AuditSeverity::High,
172 Self::LicenseViolation => AuditSeverity::High,
173 Self::AccessDenied => AuditSeverity::High,
174
175 Self::SecretRedacted => AuditSeverity::Warning,
177 Self::ScanFailed => AuditSeverity::Warning,
178 Self::ChunkSkipped => AuditSeverity::Warning,
179
180 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 Self::ChunkGenerated | Self::ChunkSplit => AuditSeverity::Debug,
193 }
194 }
195
196 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#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct AuditEvent {
225 pub timestamp: String,
227
228 pub event: AuditEventKind,
230
231 pub session_id: String,
233
234 pub repo_id: String,
236
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub user: Option<String>,
240
241 pub severity: AuditSeverity,
243
244 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
246 pub details: HashMap<String, String>,
247}
248
249impl AuditEvent {
250 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 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 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 pub fn with_details(mut self, details: impl IntoIterator<Item = (String, String)>) -> Self {
289 self.details.extend(details);
290 self
291 }
292
293 pub fn with_severity(mut self, severity: AuditSeverity) -> Self {
295 self.severity = severity;
296 self
297 }
298
299 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 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 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 fn days_to_ymd(days: u64) -> (i32, u32, u32) {
325 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 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 pub fn to_json(&self) -> String {
376 serde_json::to_string(self).unwrap_or_else(|_| String::from("{}"))
377 }
378}
379
380pub trait AuditLogger: Send + Sync {
382 fn log(&self, event: AuditEvent);
384
385 fn flush(&self);
387
388 fn min_severity(&self) -> AuditSeverity;
390
391 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
408pub struct FileAuditLogger {
410 writer: Mutex<BufWriter<File>>,
411 min_severity: AuditSeverity,
412}
413
414impl FileAuditLogger {
415 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 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 drop(writeln!(writer, "{}", json));
441 }
442 }
443
444 fn flush(&self) {
445 if let Ok(mut writer) = self.writer.lock() {
446 drop(writer.flush());
448 }
449 }
450
451 fn min_severity(&self) -> AuditSeverity {
452 self.min_severity
453 }
454}
455
456pub 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 pub fn new() -> Self {
471 Self {
472 events: RwLock::new(Vec::new()),
473 min_severity: AuditSeverity::Debug,
474 }
475 }
476
477 pub fn with_min_severity(mut self, severity: AuditSeverity) -> Self {
479 self.min_severity = severity;
480 self
481 }
482
483 pub fn events(&self) -> Vec<AuditEvent> {
485 self.events.read().map(|e| e.clone()).unwrap_or_default()
486 }
487
488 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 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 pub fn clear(&self) {
512 if let Ok(mut events) = self.events.write() {
513 events.clear();
514 }
515 }
516
517 pub fn len(&self) -> usize {
519 self.events.read().map(|e| e.len()).unwrap_or(0)
520 }
521
522 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 }
542
543 fn min_severity(&self) -> AuditSeverity {
544 self.min_severity
545 }
546}
547
548pub struct NullAuditLogger;
550
551impl AuditLogger for NullAuditLogger {
552 fn log(&self, _event: AuditEvent) {
553 }
555
556 fn flush(&self) {
557 }
559
560 fn min_severity(&self) -> AuditSeverity {
561 AuditSeverity::Critical }
563}
564
565pub struct MultiAuditLogger {
567 loggers: Vec<Arc<dyn AuditLogger>>,
568}
569
570impl MultiAuditLogger {
571 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 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
609static GLOBAL_LOGGER: RwLock<Option<Arc<dyn AuditLogger>>> = RwLock::new(None);
611
612pub fn set_global_logger(logger: Arc<dyn AuditLogger>) {
614 if let Ok(mut global) = GLOBAL_LOGGER.write() {
615 *global = Some(logger);
616 }
617}
618
619pub 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
628pub fn log_event(event: AuditEvent) {
630 get_global_logger().log(event);
631}
632
633pub 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
641pub 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
655pub 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
676pub 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 logger.log(AuditEvent::new(AuditEventKind::ScanStarted, "repo1", None));
756
757 logger.log(AuditEvent::new(AuditEventKind::SecretDetected, "repo1", None));
759
760 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 logger.log(AuditEvent::new(AuditEventKind::SecretDetected, "repo", None));
806 logger.flush();
807
808 assert_eq!(logger.min_severity(), AuditSeverity::Critical);
810 }
811
812 #[test]
813 fn test_global_logger_api() {
814 let memory_logger = Arc::new(MemoryAuditLogger::new());
820
821 set_global_logger(memory_logger.clone());
823
824 memory_logger.log(AuditEvent::new(AuditEventKind::ScanStarted, "test", None));
826 assert_eq!(memory_logger.len(), 1);
827
828 assert_eq!(memory_logger.min_severity(), AuditSeverity::Debug);
830 }
831
832 #[test]
833 fn test_convenience_function_events() {
834 let logger = MemoryAuditLogger::new();
838
839 let event = AuditEvent::new(AuditEventKind::ScanStarted, "repo", Some("user".to_string()))
841 .with_detail("path", "/path/to/repo");
842 logger.log(event);
843
844 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 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 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 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 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 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}