Skip to main content

kimberlite_compliance/
breach.rs

1//! Breach detection and notification for HIPAA Section 164.404 and GDPR Article 33.
2//!
3//! This module provides automated breach detection with 6 indicators and enforces
4//! the 72-hour notification deadline required by both HIPAA and GDPR.
5//!
6//! # Indicators
7//!
8//! - **Mass Data Export**: Records exported exceed configurable threshold
9//! - **Unauthorized Access Pattern**: Denied access attempts exceed threshold in window
10//! - **Privilege Escalation**: Any role escalation triggers a breach event
11//! - **Anomalous Query Volume**: Query rate exceeds baseline multiplier
12//! - **Unusual Access Time**: Access outside business hours (09:00-17:00)
13//! - **Data Exfiltration Pattern**: Bytes exported exceed configurable threshold
14//!
15//! # Lifecycle
16//!
17//! ```text
18//! Detected -> UnderInvestigation -> Confirmed { notification } -> Resolved
19//!                                -> FalsePositive
20//! ```
21
22use chrono::{DateTime, Duration, Utc};
23use kimberlite_types::DataClass;
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26use uuid::Uuid;
27
28/// Notification deadline: 72 hours per HIPAA Section 164.404 and GDPR Article 33.
29const NOTIFICATION_DEADLINE_HOURS: i64 = 72;
30
31/// Business hours start (inclusive).
32const BUSINESS_HOURS_START: u8 = 9;
33
34/// Business hours end (exclusive).
35const 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/// Indicator that triggered a breach detection event.
48///
49/// Each variant captures the specific metrics that exceeded thresholds.
50#[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/// Severity level for a breach event, ordered from lowest to highest.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
80pub enum BreachSeverity {
81    Low,
82    Medium,
83    High,
84    Critical,
85}
86
87/// Status of a breach event through its lifecycle.
88#[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/// A single breach detection event with full audit trail metadata.
106#[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    /// 72-hour deadline from detection (HIPAA Section 164.404 / GDPR Article 33).
115    pub notification_deadline: DateTime<Utc>,
116    pub status: BreachStatus,
117}
118
119/// Configurable thresholds for breach detection indicators.
120///
121/// All fields are private and immutable after construction. Use
122/// [`BreachThresholdsBuilder`] to create custom thresholds.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct BreachThresholds {
125    /// Number of records exported that triggers a mass export alert. Default: 1000.
126    mass_export_records: u64,
127    /// Number of denied access attempts within the window. Default: 10.
128    denied_attempts_window: u64,
129    /// Multiplier over baseline query volume to trigger alert. Default: 5.0x.
130    query_volume_multiplier: f64,
131    /// Bytes exported that triggers an exfiltration alert. Default: 100MB.
132    export_bytes_threshold: u64,
133    /// Business hours start (inclusive, 0-23 UTC). Default: 9.
134    business_hours_start: u8,
135    /// Business hours end (exclusive, 0-23 UTC). Default: 17.
136    business_hours_end: u8,
137}
138
139impl BreachThresholds {
140    /// Returns the mass export record threshold.
141    pub fn mass_export_records(&self) -> u64 {
142        self.mass_export_records
143    }
144
145    /// Returns the denied access attempts threshold.
146    pub fn denied_attempts_window(&self) -> u64 {
147        self.denied_attempts_window
148    }
149
150    /// Returns the query volume multiplier threshold.
151    pub fn query_volume_multiplier(&self) -> f64 {
152        self.query_volume_multiplier
153    }
154
155    /// Returns the export bytes threshold.
156    pub fn export_bytes_threshold(&self) -> u64 {
157        self.export_bytes_threshold
158    }
159
160    /// Returns the business hours start (inclusive, 0-23 UTC).
161    pub fn business_hours_start(&self) -> u8 {
162        self.business_hours_start
163    }
164
165    /// Returns the business hours end (exclusive, 0-23 UTC).
166    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/// Builder for [`BreachThresholds`].
185#[derive(Debug, Clone)]
186pub struct BreachThresholdsBuilder {
187    thresholds: BreachThresholds,
188}
189
190impl BreachThresholdsBuilder {
191    /// Creates a new builder with default thresholds.
192    pub fn new() -> Self {
193        Self {
194            thresholds: BreachThresholds::default(),
195        }
196    }
197
198    /// Sets the mass export record threshold.
199    pub fn mass_export_records(mut self, value: u64) -> Self {
200        self.thresholds.mass_export_records = value;
201        self
202    }
203
204    /// Sets the denied access attempts threshold.
205    pub fn denied_attempts_window(mut self, value: u64) -> Self {
206        self.thresholds.denied_attempts_window = value;
207        self
208    }
209
210    /// Sets the query volume multiplier threshold.
211    pub fn query_volume_multiplier(mut self, value: f64) -> Self {
212        self.thresholds.query_volume_multiplier = value;
213        self
214    }
215
216    /// Sets the export bytes threshold.
217    pub fn export_bytes_threshold(mut self, value: u64) -> Self {
218        self.thresholds.export_bytes_threshold = value;
219        self
220    }
221
222    /// Sets the business hours start (inclusive, 0-23 UTC).
223    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    /// Sets the business hours end (exclusive, 0-23 UTC).
230    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    /// Builds the immutable `BreachThresholds`.
237    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/// Comprehensive breach report for regulatory notification.
249#[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/// Automated breach detector implementing HIPAA Section 164.404 and GDPR Article 33.
260///
261/// Tracks 6 breach indicators with configurable thresholds and manages the
262/// breach lifecycle from detection through resolution or dismissal.
263#[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    /// Creates a new breach detector with default thresholds.
282    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    /// Creates a new breach detector with custom thresholds.
295    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    /// Checks whether a mass data export exceeds the configured threshold.
308    ///
309    /// Returns a breach event if `records_exported` exceeds `mass_export_records`.
310    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    /// Records a denied access attempt and checks if the threshold is breached.
329    ///
330    /// Resets the window counter if 60 seconds have elapsed since the window start.
331    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        // Reset window if 60 seconds have elapsed
336        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        // Reset after triggering
355        self.denied_access_count = 0;
356        self.denied_access_window_start = None;
357
358        Some(event)
359    }
360
361    /// Detects privilege escalation attempts, which always trigger a breach event.
362    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    /// Records a query and checks whether volume is anomalous.
380    ///
381    /// Triggers if queries per minute exceed `baseline * query_volume_multiplier`.
382    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        // Reset window if 60 seconds have elapsed
387        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        // Reset after triggering
410        self.query_count = 0;
411        self.query_window_start = None;
412
413        Some(event)
414    }
415
416    /// Checks whether access is occurring outside business hours (09:00-17:00).
417    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    /// Checks whether bytes exported exceed the exfiltration threshold.
438    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    /// Moves a breach event from `Detected` to `UnderInvestigation`.
457    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    /// Moves a breach event to `Confirmed` with an optional notification timestamp.
472    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    /// Dismisses a breach event as a false positive.
491    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    /// Marks a confirmed breach event as resolved with remediation notes.
514    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    /// Returns breach events that are past the 72-hour notification deadline
536    /// without a notification being sent.
537    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    /// Generates a comprehensive breach report for the specified event.
545    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    /// Looks up a breach event by its ID.
565    pub fn get_event(&self, event_id: Uuid) -> Option<&BreachEvent> {
566        self.events.iter().find(|e| e.event_id == event_id)
567    }
568
569    // ========================================================================
570    // Private helpers
571    // ========================================================================
572
573    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        // Postcondition: deadline is exactly 72 hours after detection
594        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        // ALWAYS: notification deadline is exactly 72h after detection
601        // (HIPAA ยง164.404 / GDPR Article 33).
602        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        // SOMETIMES: each severity level should be exercised across simulation runs.
610        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
738/// Classifies breach severity based on the indicator type and affected data classes.
739///
740/// - **Critical**: PHI or PCI data involved
741/// - **High**: PII or Sensitive data involved
742/// - **Medium**: Confidential data involved
743/// - **Low**: No regulated data involved
744pub fn classify_severity(
745    indicator: &BreachIndicator,
746    data_classes: &[DataClass],
747) -> BreachSeverity {
748    // Privilege escalation is always at least High severity
749    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    // Return the higher of the two
757    if data_severity > base_severity {
758        data_severity
759    } else {
760        base_severity
761    }
762}
763
764/// Determines severity from the most sensitive data class present.
765fn 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        // Below threshold: no event
793        let result = detector.check_mass_export(500, &[DataClass::PII]);
794        assert!(result.is_none());
795
796        // At threshold: no event (must exceed, not equal)
797        let result = detector.check_mass_export(1000, &[DataClass::PII]);
798        assert!(result.is_none());
799
800        // Above threshold: triggers event
801        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); // PHI = Critical
806        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        // 9 attempts: below threshold (default 10)
822        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        // 10th attempt: triggers
828        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        // Business hours: no event
858        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        // Outside business hours: triggers
864        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        // Detect a breach
876        let event = detector
877            .check_privilege_escalation("user", "admin")
878            .expect("should trigger");
879        let event_id = event.event_id;
880
881        // Detected -> UnderInvestigation
882        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        // UnderInvestigation -> Confirmed
889        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        // Confirmed -> Resolved
899        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        // Detected -> FalsePositive
916        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        // Cannot resolve a false positive
930        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        // Deadline is 72 hours after detection
943        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        // Before deadline: no overdue events
953        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        // After deadline without notification: overdue
958        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        // PHI -> Critical
967        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        // PCI -> Critical
979        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        // PII -> High
991        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        // Sensitive -> High
1003        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        // Confidential -> Medium
1015        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        // Public -> Low (no regulated data)
1027        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        // Privilege escalation with no data classes -> High (base severity)
1039        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        // Mixed: highest wins (PHI + Public -> Critical)
1051        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        // Report for nonexistent event should fail
1083        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        // Cannot resolve from Detected (must be Confirmed first)
1098        let result = detector.resolve(event_id, "fix");
1099        assert!(result.is_err());
1100
1101        // Escalate to UnderInvestigation
1102        detector.escalate(event_id).expect("should succeed");
1103
1104        // Cannot escalate again
1105        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        // Below threshold: no event
1114        let result = detector.check_data_exfiltration(50_000_000, &[DataClass::PII]);
1115        assert!(result.is_none());
1116
1117        // Above threshold: triggers
1118        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); // PCI = Critical
1123    }
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        // Lower threshold: triggers sooner
1136        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        // 5 attempts in first window
1146        for i in 0..5 {
1147            detector.check_denied_access(start + Duration::seconds(i));
1148        }
1149
1150        // Jump past 60-second window: counter resets
1151        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        // 10th attempt in new window: triggers
1161        let result = detector.check_denied_access(new_window + Duration::seconds(9));
1162        assert!(result.is_some());
1163    }
1164}