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!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", year, month, day, hours, minutes, seconds)
318 }
319
320 fn days_to_ymd(days: u64) -> (i32, u32, u32) {
322 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 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 pub fn to_json(&self) -> String {
373 serde_json::to_string(self).unwrap_or_else(|_| String::from("{}"))
374 }
375}
376
377pub trait AuditLogger: Send + Sync {
379 fn log(&self, event: AuditEvent);
381
382 fn flush(&self);
384
385 fn min_severity(&self) -> AuditSeverity;
387
388 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
405pub struct FileAuditLogger {
407 writer: Mutex<BufWriter<File>>,
408 min_severity: AuditSeverity,
409}
410
411impl FileAuditLogger {
412 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 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 drop(writeln!(writer, "{}", json));
435 }
436 }
437
438 fn flush(&self) {
439 if let Ok(mut writer) = self.writer.lock() {
440 drop(writer.flush());
442 }
443 }
444
445 fn min_severity(&self) -> AuditSeverity {
446 self.min_severity
447 }
448}
449
450pub 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 pub fn new() -> Self {
465 Self { events: RwLock::new(Vec::new()), min_severity: AuditSeverity::Debug }
466 }
467
468 pub fn with_min_severity(mut self, severity: AuditSeverity) -> Self {
470 self.min_severity = severity;
471 self
472 }
473
474 pub fn events(&self) -> Vec<AuditEvent> {
476 self.events.read().map(|e| e.clone()).unwrap_or_default()
477 }
478
479 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 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 pub fn clear(&self) {
501 if let Ok(mut events) = self.events.write() {
502 events.clear();
503 }
504 }
505
506 pub fn len(&self) -> usize {
508 self.events.read().map(|e| e.len()).unwrap_or(0)
509 }
510
511 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 }
531
532 fn min_severity(&self) -> AuditSeverity {
533 self.min_severity
534 }
535}
536
537pub struct NullAuditLogger;
539
540impl AuditLogger for NullAuditLogger {
541 fn log(&self, _event: AuditEvent) {
542 }
544
545 fn flush(&self) {
546 }
548
549 fn min_severity(&self) -> AuditSeverity {
550 AuditSeverity::Critical }
552}
553
554pub struct MultiAuditLogger {
556 loggers: Vec<Arc<dyn AuditLogger>>,
557}
558
559impl MultiAuditLogger {
560 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 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
598static GLOBAL_LOGGER: RwLock<Option<Arc<dyn AuditLogger>>> = RwLock::new(None);
600
601pub fn set_global_logger(logger: Arc<dyn AuditLogger>) {
603 if let Ok(mut global) = GLOBAL_LOGGER.write() {
604 *global = Some(logger);
605 }
606}
607
608pub 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
617pub fn log_event(event: AuditEvent) {
619 get_global_logger().log(event);
620}
621
622pub 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
630pub 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
639pub 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
649pub 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 logger.log(AuditEvent::new(AuditEventKind::ScanStarted, "repo1", None));
722
723 logger.log(AuditEvent::new(AuditEventKind::SecretDetected, "repo1", None));
725
726 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 logger.log(AuditEvent::new(AuditEventKind::SecretDetected, "repo", None));
764 logger.flush();
765
766 assert_eq!(logger.min_severity(), AuditSeverity::Critical);
768 }
769
770 #[test]
771 fn test_global_logger_api() {
772 let memory_logger = Arc::new(MemoryAuditLogger::new());
778
779 set_global_logger(memory_logger.clone());
781
782 memory_logger.log(AuditEvent::new(AuditEventKind::ScanStarted, "test", None));
784 assert_eq!(memory_logger.len(), 1);
785
786 assert_eq!(memory_logger.min_severity(), AuditSeverity::Debug);
788 }
789
790 #[test]
791 fn test_convenience_function_events() {
792 let logger = MemoryAuditLogger::new();
796
797 let event = AuditEvent::new(AuditEventKind::ScanStarted, "repo", Some("user".to_owned()))
799 .with_detail("path", "/path/to/repo");
800 logger.log(event);
801
802 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 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 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 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 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 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}