1use chrono::{DateTime, Duration, Utc};
23use kimberlite_types::DataClass;
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26use uuid::Uuid;
27
28const NOTIFICATION_DEADLINE_HOURS: i64 = 72;
30
31const BUSINESS_HOURS_START: u8 = 9;
33
34const BUSINESS_HOURS_END: u8 = 17;
36
37#[derive(Debug, Error)]
38pub enum BreachError {
39 #[error("Breach event not found: {0}")]
40 EventNotFound(Uuid),
41 #[error("Invalid state transition: {0}")]
42 InvalidTransition(String),
43}
44
45pub type Result<T> = std::result::Result<T, BreachError>;
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub enum BreachIndicator {
52 MassDataExport {
53 records: u64,
54 threshold: u64,
55 },
56 UnauthorizedAccessPattern {
57 denied_attempts: u64,
58 window_secs: u64,
59 },
60 PrivilegeEscalation {
61 from_role: String,
62 to_role: String,
63 },
64 AnomalousQueryVolume {
65 queries_per_min: u64,
66 baseline: u64,
67 },
68 UnusualAccessTime {
69 hour: u8,
70 is_business_hours: bool,
71 },
72 DataExfiltrationPattern {
73 bytes_exported: u64,
74 threshold: u64,
75 },
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
80pub enum BreachSeverity {
81 Low,
82 Medium,
83 High,
84 Critical,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub enum BreachStatus {
90 Detected,
91 UnderInvestigation,
92 Confirmed {
93 notification_sent_at: Option<DateTime<Utc>>,
94 },
95 FalsePositive {
96 dismissed_by: String,
97 reason: String,
98 },
99 Resolved {
100 resolved_at: DateTime<Utc>,
101 remediation: String,
102 },
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct BreachEvent {
108 pub event_id: Uuid,
109 pub detected_at: DateTime<Utc>,
110 pub indicator: BreachIndicator,
111 pub severity: BreachSeverity,
112 pub affected_subjects: Option<u64>,
113 pub affected_data_classes: Vec<DataClass>,
114 pub notification_deadline: DateTime<Utc>,
116 pub status: BreachStatus,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct BreachThresholds {
125 mass_export_records: u64,
127 denied_attempts_window: u64,
129 query_volume_multiplier: f64,
131 export_bytes_threshold: u64,
133 business_hours_start: u8,
135 business_hours_end: u8,
137}
138
139impl BreachThresholds {
140 pub fn mass_export_records(&self) -> u64 {
142 self.mass_export_records
143 }
144
145 pub fn denied_attempts_window(&self) -> u64 {
147 self.denied_attempts_window
148 }
149
150 pub fn query_volume_multiplier(&self) -> f64 {
152 self.query_volume_multiplier
153 }
154
155 pub fn export_bytes_threshold(&self) -> u64 {
157 self.export_bytes_threshold
158 }
159
160 pub fn business_hours_start(&self) -> u8 {
162 self.business_hours_start
163 }
164
165 pub fn business_hours_end(&self) -> u8 {
167 self.business_hours_end
168 }
169}
170
171impl Default for BreachThresholds {
172 fn default() -> Self {
173 Self {
174 mass_export_records: 1000,
175 denied_attempts_window: 10,
176 query_volume_multiplier: 5.0,
177 export_bytes_threshold: 104_857_600,
178 business_hours_start: BUSINESS_HOURS_START,
179 business_hours_end: BUSINESS_HOURS_END,
180 }
181 }
182}
183
184#[derive(Debug, Clone)]
186pub struct BreachThresholdsBuilder {
187 thresholds: BreachThresholds,
188}
189
190impl BreachThresholdsBuilder {
191 pub fn new() -> Self {
193 Self {
194 thresholds: BreachThresholds::default(),
195 }
196 }
197
198 pub fn mass_export_records(mut self, value: u64) -> Self {
200 self.thresholds.mass_export_records = value;
201 self
202 }
203
204 pub fn denied_attempts_window(mut self, value: u64) -> Self {
206 self.thresholds.denied_attempts_window = value;
207 self
208 }
209
210 pub fn query_volume_multiplier(mut self, value: f64) -> Self {
212 self.thresholds.query_volume_multiplier = value;
213 self
214 }
215
216 pub fn export_bytes_threshold(mut self, value: u64) -> Self {
218 self.thresholds.export_bytes_threshold = value;
219 self
220 }
221
222 pub fn business_hours_start(mut self, hour: u8) -> Self {
224 assert!(hour < 24, "business_hours_start must be 0-23, got {hour}");
225 self.thresholds.business_hours_start = hour;
226 self
227 }
228
229 pub fn business_hours_end(mut self, hour: u8) -> Self {
231 assert!(hour < 24, "business_hours_end must be 0-23, got {hour}");
232 self.thresholds.business_hours_end = hour;
233 self
234 }
235
236 pub fn build(self) -> BreachThresholds {
238 self.thresholds
239 }
240}
241
242impl Default for BreachThresholdsBuilder {
243 fn default() -> Self {
244 Self::new()
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct BreachReport {
251 pub event: BreachEvent,
252 pub timeline: Vec<String>,
253 pub affected_subject_count: u64,
254 pub data_categories: Vec<DataClass>,
255 pub remediation_steps: Vec<String>,
256 pub notification_status: String,
257}
258
259#[derive(Debug)]
264pub struct BreachDetector {
265 events: Vec<BreachEvent>,
266 thresholds: BreachThresholds,
267 denied_access_count: u64,
268 denied_access_window_start: Option<DateTime<Utc>>,
269 query_count: u64,
270 query_window_start: Option<DateTime<Utc>>,
271 baseline_queries_per_min: u64,
272}
273
274impl Default for BreachDetector {
275 fn default() -> Self {
276 Self::new()
277 }
278}
279
280impl BreachDetector {
281 pub fn new() -> Self {
283 Self {
284 events: Vec::new(),
285 thresholds: BreachThresholds::default(),
286 denied_access_count: 0,
287 denied_access_window_start: None,
288 query_count: 0,
289 query_window_start: None,
290 baseline_queries_per_min: 100,
291 }
292 }
293
294 pub fn with_thresholds(thresholds: BreachThresholds) -> Self {
296 Self {
297 events: Vec::new(),
298 thresholds,
299 denied_access_count: 0,
300 denied_access_window_start: None,
301 query_count: 0,
302 query_window_start: None,
303 baseline_queries_per_min: 100,
304 }
305 }
306
307 pub fn check_mass_export(
311 &mut self,
312 records_exported: u64,
313 data_classes: &[DataClass],
314 ) -> Option<BreachEvent> {
315 if records_exported <= self.thresholds.mass_export_records {
316 return None;
317 }
318
319 let indicator = BreachIndicator::MassDataExport {
320 records: records_exported,
321 threshold: self.thresholds.mass_export_records,
322 };
323 let severity = classify_severity(&indicator, data_classes);
324 let event = self.create_event(indicator, severity, data_classes);
325 Some(event)
326 }
327
328 pub fn check_denied_access(&mut self, now: DateTime<Utc>) -> Option<BreachEvent> {
332 let window_start = self.denied_access_window_start.get_or_insert(now);
333 let elapsed = now.signed_duration_since(*window_start);
334
335 if elapsed > Duration::seconds(60) {
337 self.denied_access_count = 0;
338 self.denied_access_window_start = Some(now);
339 }
340
341 self.denied_access_count += 1;
342
343 if self.denied_access_count < self.thresholds.denied_attempts_window {
344 return None;
345 }
346
347 let indicator = BreachIndicator::UnauthorizedAccessPattern {
348 denied_attempts: self.denied_access_count,
349 window_secs: 60,
350 };
351 let severity = classify_severity(&indicator, &[]);
352 let event = self.create_event(indicator, severity, &[]);
353
354 self.denied_access_count = 0;
356 self.denied_access_window_start = None;
357
358 Some(event)
359 }
360
361 pub fn check_privilege_escalation(
363 &mut self,
364 from_role: &str,
365 to_role: &str,
366 ) -> Option<BreachEvent> {
367 assert!(!from_role.is_empty(), "from_role must not be empty");
368 assert!(!to_role.is_empty(), "to_role must not be empty");
369
370 let indicator = BreachIndicator::PrivilegeEscalation {
371 from_role: from_role.to_string(),
372 to_role: to_role.to_string(),
373 };
374 let severity = classify_severity(&indicator, &[]);
375 let event = self.create_event(indicator, severity, &[]);
376 Some(event)
377 }
378
379 pub fn check_query_volume(&mut self, now: DateTime<Utc>) -> Option<BreachEvent> {
383 let window_start = self.query_window_start.get_or_insert(now);
384 let elapsed = now.signed_duration_since(*window_start);
385
386 if elapsed > Duration::seconds(60) {
388 self.query_count = 0;
389 self.query_window_start = Some(now);
390 }
391
392 self.query_count += 1;
393
394 #[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
395 let threshold =
396 (self.baseline_queries_per_min as f64 * self.thresholds.query_volume_multiplier) as u64;
397
398 if self.query_count < threshold {
399 return None;
400 }
401
402 let indicator = BreachIndicator::AnomalousQueryVolume {
403 queries_per_min: self.query_count,
404 baseline: self.baseline_queries_per_min,
405 };
406 let severity = classify_severity(&indicator, &[]);
407 let event = self.create_event(indicator, severity, &[]);
408
409 self.query_count = 0;
411 self.query_window_start = None;
412
413 Some(event)
414 }
415
416 pub fn check_unusual_access_time(&mut self, hour: u8) -> Option<BreachEvent> {
418 assert!(hour < 24, "hour must be 0-23, got {hour}");
419
420 let is_business_hours = (self.thresholds.business_hours_start()
421 ..self.thresholds.business_hours_end())
422 .contains(&hour);
423
424 if is_business_hours {
425 return None;
426 }
427
428 let indicator = BreachIndicator::UnusualAccessTime {
429 hour,
430 is_business_hours,
431 };
432 let severity = classify_severity(&indicator, &[]);
433 let event = self.create_event(indicator, severity, &[]);
434 Some(event)
435 }
436
437 pub fn check_data_exfiltration(
439 &mut self,
440 bytes_exported: u64,
441 data_classes: &[DataClass],
442 ) -> Option<BreachEvent> {
443 if bytes_exported <= self.thresholds.export_bytes_threshold {
444 return None;
445 }
446
447 let indicator = BreachIndicator::DataExfiltrationPattern {
448 bytes_exported,
449 threshold: self.thresholds.export_bytes_threshold,
450 };
451 let severity = classify_severity(&indicator, data_classes);
452 let event = self.create_event(indicator, severity, data_classes);
453 Some(event)
454 }
455
456 pub fn escalate(&mut self, event_id: Uuid) -> Result<()> {
458 let event = self.find_event_mut(event_id)?;
459
460 if event.status != BreachStatus::Detected {
461 let status = &event.status;
462 return Err(BreachError::InvalidTransition(format!(
463 "cannot escalate from {status:?}, expected Detected"
464 )));
465 }
466
467 event.status = BreachStatus::UnderInvestigation;
468 Ok(())
469 }
470
471 pub fn confirm(&mut self, event_id: Uuid) -> Result<()> {
473 let event = self.find_event_mut(event_id)?;
474
475 match &event.status {
476 BreachStatus::Detected | BreachStatus::UnderInvestigation => {}
477 other => {
478 return Err(BreachError::InvalidTransition(format!(
479 "cannot confirm from {other:?}, expected Detected or UnderInvestigation"
480 )));
481 }
482 }
483
484 event.status = BreachStatus::Confirmed {
485 notification_sent_at: Some(Utc::now()),
486 };
487 Ok(())
488 }
489
490 pub fn dismiss(&mut self, event_id: Uuid, dismissed_by: &str, reason: &str) -> Result<()> {
492 assert!(!dismissed_by.is_empty(), "dismissed_by must not be empty");
493 assert!(!reason.is_empty(), "reason must not be empty");
494
495 let event = self.find_event_mut(event_id)?;
496
497 match &event.status {
498 BreachStatus::Detected | BreachStatus::UnderInvestigation => {}
499 other => {
500 return Err(BreachError::InvalidTransition(format!(
501 "cannot dismiss from {other:?}, expected Detected or UnderInvestigation"
502 )));
503 }
504 }
505
506 event.status = BreachStatus::FalsePositive {
507 dismissed_by: dismissed_by.to_string(),
508 reason: reason.to_string(),
509 };
510 Ok(())
511 }
512
513 pub fn resolve(&mut self, event_id: Uuid, remediation: &str) -> Result<()> {
515 assert!(!remediation.is_empty(), "remediation must not be empty");
516
517 let event = self.find_event_mut(event_id)?;
518
519 match &event.status {
520 BreachStatus::Confirmed { .. } => {}
521 other => {
522 return Err(BreachError::InvalidTransition(format!(
523 "cannot resolve from {other:?}, expected Confirmed"
524 )));
525 }
526 }
527
528 event.status = BreachStatus::Resolved {
529 resolved_at: Utc::now(),
530 remediation: remediation.to_string(),
531 };
532 Ok(())
533 }
534
535 pub fn check_notification_deadlines(&self, now: DateTime<Utc>) -> Vec<&BreachEvent> {
538 self.events
539 .iter()
540 .filter(|e| now > e.notification_deadline && !Self::has_notification(e))
541 .collect()
542 }
543
544 pub fn generate_report(&self, event_id: Uuid) -> Result<BreachReport> {
546 let event = self
547 .get_event(event_id)
548 .ok_or(BreachError::EventNotFound(event_id))?;
549
550 let notification_status = Self::format_notification_status(event);
551 let timeline = Self::build_timeline(event);
552 let remediation_steps = Self::build_remediation_steps(&event.indicator);
553
554 Ok(BreachReport {
555 event: event.clone(),
556 timeline,
557 affected_subject_count: event.affected_subjects.unwrap_or(0),
558 data_categories: event.affected_data_classes.clone(),
559 remediation_steps,
560 notification_status,
561 })
562 }
563
564 pub fn get_event(&self, event_id: Uuid) -> Option<&BreachEvent> {
566 self.events.iter().find(|e| e.event_id == event_id)
567 }
568
569 fn create_event(
574 &mut self,
575 indicator: BreachIndicator,
576 severity: BreachSeverity,
577 data_classes: &[DataClass],
578 ) -> BreachEvent {
579 let now = Utc::now();
580 let deadline = now + Duration::hours(NOTIFICATION_DEADLINE_HOURS);
581
582 let event = BreachEvent {
583 event_id: Uuid::new_v4(),
584 detected_at: now,
585 indicator,
586 severity,
587 affected_subjects: None,
588 affected_data_classes: data_classes.to_vec(),
589 notification_deadline: deadline,
590 status: BreachStatus::Detected,
591 };
592
593 debug_assert_eq!(
595 event.notification_deadline,
596 event.detected_at + Duration::hours(NOTIFICATION_DEADLINE_HOURS),
597 "notification deadline must be 72h after detection"
598 );
599
600 kimberlite_properties::always!(
603 (event.notification_deadline - event.detected_at).num_hours()
604 == NOTIFICATION_DEADLINE_HOURS,
605 "compliance.breach.notification_72h",
606 "breach notification deadline must be exactly 72 hours after detection"
607 );
608
609 kimberlite_properties::sometimes!(
611 matches!(event.severity, BreachSeverity::Low),
612 "compliance.breach.severity_low",
613 "Low severity breach exercised at least once"
614 );
615 kimberlite_properties::sometimes!(
616 matches!(event.severity, BreachSeverity::Medium),
617 "compliance.breach.severity_medium",
618 "Medium severity breach exercised at least once"
619 );
620 kimberlite_properties::sometimes!(
621 matches!(event.severity, BreachSeverity::High),
622 "compliance.breach.severity_high",
623 "High severity breach exercised at least once"
624 );
625 kimberlite_properties::sometimes!(
626 matches!(event.severity, BreachSeverity::Critical),
627 "compliance.breach.severity_critical",
628 "Critical severity breach exercised at least once"
629 );
630
631 self.events.push(event.clone());
632 event
633 }
634
635 fn find_event_mut(&mut self, event_id: Uuid) -> Result<&mut BreachEvent> {
636 self.events
637 .iter_mut()
638 .find(|e| e.event_id == event_id)
639 .ok_or(BreachError::EventNotFound(event_id))
640 }
641
642 fn has_notification(event: &BreachEvent) -> bool {
643 matches!(
644 &event.status,
645 BreachStatus::Confirmed {
646 notification_sent_at: Some(_)
647 } | BreachStatus::Resolved { .. }
648 | BreachStatus::FalsePositive { .. }
649 )
650 }
651
652 fn format_notification_status(event: &BreachEvent) -> String {
653 match &event.status {
654 BreachStatus::Detected => "Pending - not yet investigated".to_string(),
655 BreachStatus::UnderInvestigation => "Pending - under investigation".to_string(),
656 BreachStatus::Confirmed {
657 notification_sent_at: Some(sent_at),
658 } => format!("Notification sent at {sent_at}"),
659 BreachStatus::Confirmed {
660 notification_sent_at: None,
661 } => "Confirmed - notification pending".to_string(),
662 BreachStatus::FalsePositive { .. } => "Dismissed as false positive".to_string(),
663 BreachStatus::Resolved { .. } => "Resolved - notification completed".to_string(),
664 }
665 }
666
667 fn build_timeline(event: &BreachEvent) -> Vec<String> {
668 let mut timeline = vec![format!("Detected at {}", event.detected_at)];
669
670 match &event.status {
671 BreachStatus::Detected => {}
672 BreachStatus::UnderInvestigation => {
673 timeline.push("Escalated to investigation".to_string());
674 }
675 BreachStatus::Confirmed {
676 notification_sent_at,
677 } => {
678 timeline.push("Escalated to investigation".to_string());
679 timeline.push("Breach confirmed".to_string());
680 if let Some(sent_at) = notification_sent_at {
681 timeline.push(format!("Notification sent at {sent_at}"));
682 }
683 }
684 BreachStatus::FalsePositive {
685 dismissed_by,
686 reason,
687 } => {
688 timeline.push(format!("Dismissed by {dismissed_by}: {reason}"));
689 }
690 BreachStatus::Resolved {
691 resolved_at,
692 remediation,
693 } => {
694 timeline.push("Breach confirmed".to_string());
695 timeline.push(format!("Resolved at {resolved_at}: {remediation}"));
696 }
697 }
698
699 timeline
700 }
701
702 fn build_remediation_steps(indicator: &BreachIndicator) -> Vec<String> {
703 match indicator {
704 BreachIndicator::MassDataExport { .. } => vec![
705 "Revoke export permissions for affected accounts".to_string(),
706 "Audit all recent export operations".to_string(),
707 "Review data loss prevention policies".to_string(),
708 ],
709 BreachIndicator::UnauthorizedAccessPattern { .. } => vec![
710 "Lock affected accounts pending investigation".to_string(),
711 "Review access control policies".to_string(),
712 "Enable additional authentication factors".to_string(),
713 ],
714 BreachIndicator::PrivilegeEscalation { .. } => vec![
715 "Revoke escalated privileges immediately".to_string(),
716 "Audit all actions taken with elevated privileges".to_string(),
717 "Review role assignment procedures".to_string(),
718 ],
719 BreachIndicator::AnomalousQueryVolume { .. } => vec![
720 "Throttle affected accounts".to_string(),
721 "Review query patterns for data harvesting".to_string(),
722 "Implement rate limiting controls".to_string(),
723 ],
724 BreachIndicator::UnusualAccessTime { .. } => vec![
725 "Verify identity of accessing user".to_string(),
726 "Review access logs for the session".to_string(),
727 "Consider implementing time-based access controls".to_string(),
728 ],
729 BreachIndicator::DataExfiltrationPattern { .. } => vec![
730 "Block outbound data transfers immediately".to_string(),
731 "Identify destination of exported data".to_string(),
732 "Engage incident response team".to_string(),
733 ],
734 }
735 }
736}
737
738pub fn classify_severity(
745 indicator: &BreachIndicator,
746 data_classes: &[DataClass],
747) -> BreachSeverity {
748 let base_severity = match indicator {
750 BreachIndicator::PrivilegeEscalation { .. } => BreachSeverity::High,
751 _ => BreachSeverity::Low,
752 };
753
754 let data_severity = data_class_severity(data_classes);
755
756 if data_severity > base_severity {
758 data_severity
759 } else {
760 base_severity
761 }
762}
763
764fn data_class_severity(data_classes: &[DataClass]) -> BreachSeverity {
766 let mut severity = BreachSeverity::Low;
767
768 for dc in data_classes {
769 let class_severity = match dc {
770 DataClass::PHI | DataClass::PCI => BreachSeverity::Critical,
771 DataClass::PII | DataClass::Sensitive => BreachSeverity::High,
772 DataClass::Confidential | DataClass::Financial => BreachSeverity::Medium,
773 DataClass::Deidentified | DataClass::Public => BreachSeverity::Low,
774 };
775
776 if class_severity > severity {
777 severity = class_severity;
778 }
779 }
780
781 severity
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787
788 #[test]
789 fn test_mass_export_detection() {
790 let mut detector = BreachDetector::new();
791
792 let result = detector.check_mass_export(500, &[DataClass::PII]);
794 assert!(result.is_none());
795
796 let result = detector.check_mass_export(1000, &[DataClass::PII]);
798 assert!(result.is_none());
799
800 let result = detector.check_mass_export(1500, &[DataClass::PHI]);
802 assert!(result.is_some());
803
804 let event = result.expect("should have breach event");
805 assert_eq!(event.severity, BreachSeverity::Critical); assert!(matches!(
807 event.indicator,
808 BreachIndicator::MassDataExport {
809 records: 1500,
810 threshold: 1000
811 }
812 ));
813 assert_eq!(event.status, BreachStatus::Detected);
814 }
815
816 #[test]
817 fn test_denied_access_threshold() {
818 let mut detector = BreachDetector::new();
819 let now = Utc::now();
820
821 for i in 0..9 {
823 let result = detector.check_denied_access(now + Duration::seconds(i));
824 assert!(result.is_none(), "attempt {i} should not trigger");
825 }
826
827 let result = detector.check_denied_access(now + Duration::seconds(9));
829 assert!(result.is_some(), "10th attempt should trigger");
830
831 let event = result.expect("should have breach event");
832 assert!(matches!(
833 event.indicator,
834 BreachIndicator::UnauthorizedAccessPattern { .. }
835 ));
836 }
837
838 #[test]
839 fn test_privilege_escalation_always_triggers() {
840 let mut detector = BreachDetector::new();
841
842 let result = detector.check_privilege_escalation("reader", "admin");
843 assert!(result.is_some(), "privilege escalation must always trigger");
844
845 let event = result.expect("should have breach event");
846 assert!(event.severity >= BreachSeverity::High);
847 assert!(matches!(
848 event.indicator,
849 BreachIndicator::PrivilegeEscalation { .. }
850 ));
851 }
852
853 #[test]
854 fn test_unusual_access_time() {
855 let mut detector = BreachDetector::new();
856
857 for hour in BUSINESS_HOURS_START..BUSINESS_HOURS_END {
859 let result = detector.check_unusual_access_time(hour);
860 assert!(result.is_none(), "hour {hour} is business hours");
861 }
862
863 let result = detector.check_unusual_access_time(3);
865 assert!(result.is_some(), "hour 3 is outside business hours");
866
867 let result = detector.check_unusual_access_time(22);
868 assert!(result.is_some(), "hour 22 is outside business hours");
869 }
870
871 #[test]
872 fn test_breach_lifecycle() {
873 let mut detector = BreachDetector::new();
874
875 let event = detector
877 .check_privilege_escalation("user", "admin")
878 .expect("should trigger");
879 let event_id = event.event_id;
880
881 detector
883 .escalate(event_id)
884 .expect("escalate should succeed");
885 let event = detector.get_event(event_id).expect("event should exist");
886 assert_eq!(event.status, BreachStatus::UnderInvestigation);
887
888 detector.confirm(event_id).expect("confirm should succeed");
890 let event = detector.get_event(event_id).expect("event should exist");
891 assert!(matches!(
892 event.status,
893 BreachStatus::Confirmed {
894 notification_sent_at: Some(_)
895 }
896 ));
897
898 detector
900 .resolve(event_id, "Revoked admin privileges and rotated credentials")
901 .expect("resolve should succeed");
902 let event = detector.get_event(event_id).expect("event should exist");
903 assert!(matches!(event.status, BreachStatus::Resolved { .. }));
904 }
905
906 #[test]
907 fn test_false_positive_dismissal() {
908 let mut detector = BreachDetector::new();
909
910 let event = detector
911 .check_unusual_access_time(2)
912 .expect("should trigger");
913 let event_id = event.event_id;
914
915 detector
917 .dismiss(event_id, "security-team", "Scheduled maintenance window")
918 .expect("dismiss should succeed");
919
920 let event = detector.get_event(event_id).expect("event should exist");
921 assert!(matches!(
922 event.status,
923 BreachStatus::FalsePositive {
924 ref dismissed_by,
925 ref reason,
926 } if dismissed_by == "security-team" && reason == "Scheduled maintenance window"
927 ));
928
929 let result = detector.resolve(event_id, "n/a");
931 assert!(result.is_err());
932 }
933
934 #[test]
935 fn test_72h_deadline() {
936 let mut detector = BreachDetector::new();
937
938 let event = detector
939 .check_privilege_escalation("viewer", "admin")
940 .expect("should trigger");
941
942 let deadline_diff = event
944 .notification_deadline
945 .signed_duration_since(event.detected_at);
946 assert_eq!(
947 deadline_diff.num_hours(),
948 NOTIFICATION_DEADLINE_HOURS,
949 "deadline must be exactly 72h after detection"
950 );
951
952 let now = event.detected_at + Duration::hours(71);
954 let overdue = detector.check_notification_deadlines(now);
955 assert!(overdue.is_empty(), "should not be overdue before 72h");
956
957 let now = event.detected_at + Duration::hours(73);
959 let overdue = detector.check_notification_deadlines(now);
960 assert_eq!(overdue.len(), 1, "should be overdue after 72h");
961 assert_eq!(overdue[0].event_id, event.event_id);
962 }
963
964 #[test]
965 fn test_severity_classification() {
966 assert_eq!(
968 classify_severity(
969 &BreachIndicator::MassDataExport {
970 records: 5000,
971 threshold: 1000
972 },
973 &[DataClass::PHI]
974 ),
975 BreachSeverity::Critical
976 );
977
978 assert_eq!(
980 classify_severity(
981 &BreachIndicator::DataExfiltrationPattern {
982 bytes_exported: 200_000_000,
983 threshold: 100_000_000
984 },
985 &[DataClass::PCI]
986 ),
987 BreachSeverity::Critical
988 );
989
990 assert_eq!(
992 classify_severity(
993 &BreachIndicator::MassDataExport {
994 records: 5000,
995 threshold: 1000
996 },
997 &[DataClass::PII]
998 ),
999 BreachSeverity::High
1000 );
1001
1002 assert_eq!(
1004 classify_severity(
1005 &BreachIndicator::MassDataExport {
1006 records: 5000,
1007 threshold: 1000
1008 },
1009 &[DataClass::Sensitive]
1010 ),
1011 BreachSeverity::High
1012 );
1013
1014 assert_eq!(
1016 classify_severity(
1017 &BreachIndicator::MassDataExport {
1018 records: 5000,
1019 threshold: 1000
1020 },
1021 &[DataClass::Confidential]
1022 ),
1023 BreachSeverity::Medium
1024 );
1025
1026 assert_eq!(
1028 classify_severity(
1029 &BreachIndicator::MassDataExport {
1030 records: 5000,
1031 threshold: 1000
1032 },
1033 &[DataClass::Public]
1034 ),
1035 BreachSeverity::Low
1036 );
1037
1038 assert_eq!(
1040 classify_severity(
1041 &BreachIndicator::PrivilegeEscalation {
1042 from_role: "user".to_string(),
1043 to_role: "admin".to_string()
1044 },
1045 &[]
1046 ),
1047 BreachSeverity::High
1048 );
1049
1050 assert_eq!(
1052 classify_severity(
1053 &BreachIndicator::MassDataExport {
1054 records: 5000,
1055 threshold: 1000
1056 },
1057 &[DataClass::Public, DataClass::PHI]
1058 ),
1059 BreachSeverity::Critical
1060 );
1061 }
1062
1063 #[test]
1064 fn test_breach_report_generation() {
1065 let mut detector = BreachDetector::new();
1066
1067 let event = detector
1068 .check_mass_export(5000, &[DataClass::PHI, DataClass::PII])
1069 .expect("should trigger");
1070 let event_id = event.event_id;
1071
1072 let report = detector
1073 .generate_report(event_id)
1074 .expect("report should succeed");
1075
1076 assert_eq!(report.event.event_id, event_id);
1077 assert!(!report.timeline.is_empty());
1078 assert!(!report.remediation_steps.is_empty());
1079 assert_eq!(report.data_categories.len(), 2);
1080 assert!(report.notification_status.contains("Pending"));
1081
1082 let bad_id = Uuid::new_v4();
1084 let result = detector.generate_report(bad_id);
1085 assert!(result.is_err());
1086 }
1087
1088 #[test]
1089 fn test_invalid_transitions() {
1090 let mut detector = BreachDetector::new();
1091
1092 let event = detector
1093 .check_privilege_escalation("user", "admin")
1094 .expect("should trigger");
1095 let event_id = event.event_id;
1096
1097 let result = detector.resolve(event_id, "fix");
1099 assert!(result.is_err());
1100
1101 detector.escalate(event_id).expect("should succeed");
1103
1104 let result = detector.escalate(event_id);
1106 assert!(result.is_err());
1107 }
1108
1109 #[test]
1110 fn test_data_exfiltration_detection() {
1111 let mut detector = BreachDetector::new();
1112
1113 let result = detector.check_data_exfiltration(50_000_000, &[DataClass::PII]);
1115 assert!(result.is_none());
1116
1117 let result = detector.check_data_exfiltration(200_000_000, &[DataClass::PCI]);
1119 assert!(result.is_some());
1120
1121 let event = result.expect("should have breach event");
1122 assert_eq!(event.severity, BreachSeverity::Critical); }
1124
1125 #[test]
1126 fn test_custom_thresholds() {
1127 let thresholds = BreachThresholdsBuilder::new()
1128 .mass_export_records(50)
1129 .denied_attempts_window(3)
1130 .query_volume_multiplier(2.0)
1131 .export_bytes_threshold(1_000_000)
1132 .build();
1133 let mut detector = BreachDetector::with_thresholds(thresholds);
1134
1135 let result = detector.check_mass_export(100, &[DataClass::Public]);
1137 assert!(result.is_some());
1138 }
1139
1140 #[test]
1141 fn test_denied_access_window_reset() {
1142 let mut detector = BreachDetector::new();
1143 let start = Utc::now();
1144
1145 for i in 0..5 {
1147 detector.check_denied_access(start + Duration::seconds(i));
1148 }
1149
1150 let new_window = start + Duration::seconds(120);
1152 for i in 0..9 {
1153 let result = detector.check_denied_access(new_window + Duration::seconds(i));
1154 assert!(
1155 result.is_none(),
1156 "attempt {i} in new window should not trigger"
1157 );
1158 }
1159
1160 let result = detector.check_denied_access(new_window + Duration::seconds(9));
1162 assert!(result.is_some());
1163 }
1164}