Skip to main content

synapse_pingora/detection/
types.rs

1//! Credential stuffing detection types and configuration.
2//!
3//! Provides types for detecting credential stuffing attacks:
4//! - Per-entity auth failure tracking
5//! - Distributed attack correlation via fingerprint
6//! - Account takeover detection (success after failures)
7//! - Low-and-slow pattern detection
8//!
9//! Not on hot path - runs per-auth-request, not every request.
10
11use serde::{Deserialize, Serialize};
12use std::collections::{HashSet, VecDeque};
13
14/// Severity levels for credential stuffing events.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[repr(u8)]
17#[derive(Default)]
18pub enum StuffingSeverity {
19    /// Low severity - informational
20    Low = 0,
21    /// Medium severity - worth monitoring
22    #[default]
23    Medium = 1,
24    /// High severity - likely attack
25    High = 2,
26    /// Critical severity - confirmed attack or takeover
27    Critical = 3,
28}
29
30impl StuffingSeverity {
31    /// Get string name for severity.
32    pub const fn as_str(&self) -> &'static str {
33        match self {
34            StuffingSeverity::Low => "low",
35            StuffingSeverity::Medium => "medium",
36            StuffingSeverity::High => "high",
37            StuffingSeverity::Critical => "critical",
38        }
39    }
40
41    /// Default risk delta for each severity level.
42    pub const fn default_risk_delta(&self) -> i32 {
43        match self {
44            StuffingSeverity::Low => 5,
45            StuffingSeverity::Medium => 10,
46            StuffingSeverity::High => 25,
47            StuffingSeverity::Critical => 50,
48        }
49    }
50}
51
52/// Verdict from credential stuffing analysis.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum StuffingVerdict {
55    /// Allow the request
56    Allow,
57    /// Flag as suspicious with reason and risk adjustment
58    Suspicious {
59        reason: String,
60        risk_delta: i32,
61        severity: StuffingSeverity,
62    },
63    /// Block the request
64    Block { reason: String },
65}
66
67impl StuffingVerdict {
68    /// Create a suspicious verdict with default risk delta from severity.
69    pub fn suspicious(reason: impl Into<String>, severity: StuffingSeverity) -> Self {
70        StuffingVerdict::Suspicious {
71            reason: reason.into(),
72            risk_delta: severity.default_risk_delta(),
73            severity,
74        }
75    }
76
77    /// Create a suspicious verdict with custom risk delta.
78    pub fn suspicious_with_risk(
79        reason: impl Into<String>,
80        severity: StuffingSeverity,
81        risk_delta: i32,
82    ) -> Self {
83        StuffingVerdict::Suspicious {
84            reason: reason.into(),
85            risk_delta,
86            severity,
87        }
88    }
89
90    /// Create a block verdict.
91    pub fn block(reason: impl Into<String>) -> Self {
92        StuffingVerdict::Block {
93            reason: reason.into(),
94        }
95    }
96
97    /// Check if this verdict is Allow.
98    pub fn is_allow(&self) -> bool {
99        matches!(self, StuffingVerdict::Allow)
100    }
101
102    /// Check if this verdict is Block.
103    pub fn is_block(&self) -> bool {
104        matches!(self, StuffingVerdict::Block { .. })
105    }
106
107    /// Get the risk delta (0 for Allow/Block).
108    pub fn risk_delta(&self) -> i32 {
109        match self {
110            StuffingVerdict::Suspicious { risk_delta, .. } => *risk_delta,
111            _ => 0,
112        }
113    }
114}
115
116/// Per-entity authentication metrics.
117///
118/// Tracks auth attempts for a single entity (IP) against auth endpoints.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct AuthMetrics {
121    /// Entity identifier (IP address)
122    pub entity_id: String,
123    /// Endpoint being tracked (e.g., "/api/login")
124    pub endpoint: String,
125
126    // Sliding window metrics
127    /// Failed auth attempts in current window
128    pub failures: u32,
129    /// Successful auth attempts in current window
130    pub successes: u32,
131    /// Window start timestamp (ms since epoch)
132    pub window_start: u64,
133    /// Last attempt timestamp (ms since epoch)
134    pub last_attempt: u64,
135
136    // Historical totals
137    /// Total failed attempts (lifetime)
138    pub total_failures: u64,
139    /// Total successful attempts (lifetime)
140    pub total_successes: u64,
141
142    // Low-and-slow detection
143    /// Hourly failure buckets (24 hours)
144    pub hourly_failures: [u32; 24],
145    /// Current hour index (0-23)
146    pub current_hour_index: u8,
147    /// Last hour rotation timestamp
148    pub last_hour_rotation: u64,
149}
150
151impl AuthMetrics {
152    /// Create new auth metrics for an entity/endpoint pair.
153    pub fn new(entity_id: String, endpoint: String, now: u64) -> Self {
154        Self {
155            entity_id,
156            endpoint,
157            failures: 0,
158            successes: 0,
159            window_start: now,
160            last_attempt: now,
161            total_failures: 0,
162            total_successes: 0,
163            hourly_failures: [0; 24],
164            current_hour_index: 0,
165            last_hour_rotation: now,
166        }
167    }
168
169    /// Record a failed attempt.
170    pub fn record_failure(&mut self, now: u64) {
171        self.failures += 1;
172        self.total_failures += 1;
173        self.last_attempt = now;
174        self.update_hourly(now, true);
175    }
176
177    /// Record a successful attempt.
178    pub fn record_success(&mut self, now: u64) {
179        self.successes += 1;
180        self.total_successes += 1;
181        self.last_attempt = now;
182    }
183
184    /// Reset sliding window.
185    pub fn reset_window(&mut self, now: u64) {
186        self.failures = 0;
187        self.successes = 0;
188        self.window_start = now;
189    }
190
191    /// Update hourly buckets for low-and-slow detection.
192    fn update_hourly(&mut self, now: u64, is_failure: bool) {
193        const HOUR_MS: u64 = 60 * 60 * 1000;
194
195        let hours_elapsed = now.saturating_sub(self.last_hour_rotation) / HOUR_MS;
196
197        if hours_elapsed > 0 {
198            // Rotate buckets
199            let rotations = hours_elapsed.min(24) as usize;
200            for _ in 0..rotations {
201                self.current_hour_index = (self.current_hour_index + 1) % 24;
202                self.hourly_failures[self.current_hour_index as usize] = 0;
203            }
204            self.last_hour_rotation = now;
205        }
206
207        if is_failure {
208            self.hourly_failures[self.current_hour_index as usize] += 1;
209        }
210    }
211
212    /// Detect low-and-slow pattern (consistent failures over hours).
213    ///
214    /// Returns true if failures are spread evenly across multiple hours.
215    pub fn detect_low_and_slow(&self, min_hours: usize, min_failures_per_hour: u32) -> bool {
216        let active_hours: usize = self
217            .hourly_failures
218            .iter()
219            .filter(|&&f| f >= min_failures_per_hour)
220            .count();
221
222        active_hours >= min_hours
223    }
224
225    /// Get failure rate (failures per second in current window).
226    #[allow(dead_code)]
227    pub fn failure_rate(&self, now: u64) -> f64 {
228        let window_duration = now.saturating_sub(self.window_start);
229        if window_duration == 0 {
230            return 0.0;
231        }
232        (self.failures as f64) / (window_duration as f64 / 1000.0)
233    }
234}
235
236/// Distributed attack tracking (same fingerprint, multiple IPs).
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct DistributedAttack {
239    /// Fingerprint ID correlating the attack
240    pub fingerprint: String,
241    /// Target endpoint
242    pub endpoint: String,
243    /// Entity IDs (IPs) participating
244    pub entities: HashSet<String>,
245    /// Total failures across all entities
246    pub total_failures: u64,
247    /// Window start timestamp
248    pub window_start: u64,
249    /// Last activity timestamp
250    pub last_activity: u64,
251    /// Correlation confidence score (0.0-1.0)
252    pub correlation_score: f32,
253}
254
255impl DistributedAttack {
256    /// Create a new distributed attack record.
257    pub fn new(fingerprint: String, endpoint: String, entity_id: String, now: u64) -> Self {
258        let mut entities = HashSet::new();
259        entities.insert(entity_id);
260        Self {
261            fingerprint,
262            endpoint,
263            entities,
264            total_failures: 0,
265            window_start: now,
266            last_activity: now,
267            correlation_score: 0.0,
268        }
269    }
270
271    /// Add an entity to the attack.
272    pub fn add_entity(&mut self, entity_id: String, now: u64) {
273        self.entities.insert(entity_id);
274        self.last_activity = now;
275        self.update_correlation_score();
276    }
277
278    /// Record a failure.
279    pub fn record_failure(&mut self, now: u64) {
280        self.total_failures += 1;
281        self.last_activity = now;
282    }
283
284    /// Get number of participating entities (IPs).
285    pub fn entity_count(&self) -> usize {
286        self.entities.len()
287    }
288
289    /// Update correlation score based on entity count and failure rate.
290    fn update_correlation_score(&mut self) {
291        // Score increases with more entities
292        let entity_factor = (self.entities.len() as f32 / 10.0).min(1.0);
293        // Score increases with more failures
294        let failure_factor = (self.total_failures as f32 / 100.0).min(1.0);
295        self.correlation_score = (entity_factor + failure_factor) / 2.0;
296    }
297}
298
299/// Account takeover alert.
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct TakeoverAlert {
302    /// Entity ID (IP) that succeeded
303    pub entity_id: String,
304    /// Endpoint where takeover occurred
305    pub endpoint: String,
306    /// Number of failures before success
307    pub prior_failures: u32,
308    /// Window duration of failures (ms)
309    pub failure_window_ms: u64,
310    /// Success timestamp
311    pub success_at: u64,
312    /// Severity of the alert
313    pub severity: StuffingSeverity,
314}
315
316impl TakeoverAlert {
317    /// Create a new takeover alert.
318    pub fn new(
319        entity_id: String,
320        endpoint: String,
321        prior_failures: u32,
322        failure_window_ms: u64,
323        success_at: u64,
324    ) -> Self {
325        // Severity based on failure count
326        let severity = if prior_failures >= 50 {
327            StuffingSeverity::Critical
328        } else if prior_failures >= 20 {
329            StuffingSeverity::High
330        } else {
331            StuffingSeverity::Critical // Any takeover is critical
332        };
333
334        Self {
335            entity_id,
336            endpoint,
337            prior_failures,
338            failure_window_ms,
339            success_at,
340            severity,
341        }
342    }
343}
344
345/// Credential stuffing event types for alerting.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347#[serde(tag = "type", rename_all = "snake_case")]
348pub enum StuffingEvent {
349    /// Suspicious failure rate from single entity
350    SuspiciousFailureRate {
351        entity_id: String,
352        endpoint: String,
353        failures: u32,
354        window_ms: u64,
355        severity: StuffingSeverity,
356    },
357    /// Distributed attack detected (multiple IPs, same fingerprint)
358    DistributedAttackDetected {
359        fingerprint: String,
360        endpoint: String,
361        ip_count: usize,
362        total_failures: u64,
363        severity: StuffingSeverity,
364    },
365    /// Username-targeted attack detected (multiple IPs targeting same username)
366    ///
367    /// SECURITY: This indicates a botnet specifically targeting a username,
368    /// possibly a high-value account or known credential from a breach.
369    UsernameTargetedAttack {
370        username: String,
371        endpoint: String,
372        ip_count: usize,
373        total_failures: u64,
374        severity: StuffingSeverity,
375    },
376    /// Global velocity spike detected (abnormal auth failure rate)
377    ///
378    /// SECURITY: A sudden spike in global auth failures may indicate
379    /// a coordinated credential stuffing attack across many targets.
380    GlobalVelocitySpike {
381        failure_rate: f64,
382        failure_count: usize,
383        threshold_rate: f64,
384        severity: StuffingSeverity,
385    },
386    /// Account takeover (success after many failures)
387    AccountTakeover {
388        entity_id: String,
389        endpoint: String,
390        prior_failures: u32,
391        severity: StuffingSeverity,
392    },
393    /// Low-and-slow attack pattern detected
394    LowAndSlow {
395        entity_id: String,
396        endpoint: String,
397        hours_active: usize,
398        total_failures: u64,
399        severity: StuffingSeverity,
400    },
401}
402
403impl StuffingEvent {
404    /// Get the severity of this event.
405    pub fn severity(&self) -> StuffingSeverity {
406        match self {
407            StuffingEvent::SuspiciousFailureRate { severity, .. } => *severity,
408            StuffingEvent::DistributedAttackDetected { severity, .. } => *severity,
409            StuffingEvent::UsernameTargetedAttack { severity, .. } => *severity,
410            StuffingEvent::GlobalVelocitySpike { severity, .. } => *severity,
411            StuffingEvent::AccountTakeover { severity, .. } => *severity,
412            StuffingEvent::LowAndSlow { severity, .. } => *severity,
413        }
414    }
415
416    /// Get the entity ID if applicable.
417    pub fn entity_id(&self) -> Option<&str> {
418        match self {
419            StuffingEvent::SuspiciousFailureRate { entity_id, .. } => Some(entity_id),
420            StuffingEvent::AccountTakeover { entity_id, .. } => Some(entity_id),
421            StuffingEvent::LowAndSlow { entity_id, .. } => Some(entity_id),
422            StuffingEvent::DistributedAttackDetected { .. } => None,
423            StuffingEvent::UsernameTargetedAttack { .. } => None,
424            StuffingEvent::GlobalVelocitySpike { .. } => None,
425        }
426    }
427}
428
429/// Configuration for credential stuffing detection.
430#[derive(Debug, Clone)]
431pub struct StuffingConfig {
432    // Single IP thresholds
433    /// Sliding window duration (ms) - default 5 minutes
434    pub failure_window_ms: u64,
435    /// Failures to flag as suspicious
436    pub failure_threshold_suspicious: u32,
437    /// Failures to flag as high risk
438    pub failure_threshold_high: u32,
439    /// Failures to trigger block
440    pub failure_threshold_block: u32,
441
442    // Distributed attack thresholds
443    /// Minimum IPs for distributed attack (fingerprint-based)
444    pub distributed_min_ips: usize,
445    /// Window for distributed attack correlation (ms)
446    pub distributed_window_ms: u64,
447
448    // Username-targeted attack thresholds
449    /// Minimum IPs targeting same username for alert
450    pub username_targeted_min_ips: usize,
451    /// Minimum failures against username for alert
452    pub username_targeted_min_failures: u64,
453    /// Window for username-targeted correlation (ms)
454    pub username_targeted_window_ms: u64,
455
456    // Global velocity thresholds
457    /// Failure rate (per second) that triggers velocity alert
458    pub global_velocity_threshold_rate: f64,
459    /// Window for global velocity tracking (ms)
460    pub global_velocity_window_ms: u64,
461    /// Maximum failures to track in global velocity window
462    pub global_velocity_max_track: usize,
463
464    // Takeover detection
465    /// Window to check failures before success (ms)
466    pub takeover_window_ms: u64,
467    /// Minimum failures before success to flag takeover
468    pub takeover_min_failures: u32,
469
470    // Low-and-slow detection
471    /// Minimum hours with failures for low-and-slow
472    pub low_slow_min_hours: usize,
473    /// Minimum failures per hour for low-and-slow
474    pub low_slow_min_per_hour: u32,
475
476    // Auth endpoint patterns (regex strings)
477    /// Path patterns that indicate auth endpoints
478    pub auth_path_patterns: Vec<String>,
479
480    // Limits
481    /// Maximum entities to track
482    pub max_entities: usize,
483    /// Maximum distributed attacks to track
484    pub max_distributed_attacks: usize,
485    /// Maximum takeover alerts to retain
486    pub max_takeover_alerts: usize,
487    /// Cleanup interval (ms)
488    pub cleanup_interval_ms: u64,
489}
490
491impl StuffingConfig {
492    /// Validate configuration values and return a sanitized config.
493    ///
494    /// Ensures thresholds are in ascending order and within reasonable bounds.
495    /// Windows under 100ms are allowed for testing purposes.
496    pub fn validated(mut self) -> Self {
497        // First, ensure block threshold has a minimum (highest threshold)
498        self.failure_threshold_block = self.failure_threshold_block.max(3);
499
500        // Then constrain high to be less than block
501        if self.failure_threshold_high >= self.failure_threshold_block {
502            self.failure_threshold_high = self.failure_threshold_block.saturating_sub(1);
503        }
504        self.failure_threshold_high = self.failure_threshold_high.max(2);
505
506        // Finally constrain suspicious to be less than high
507        if self.failure_threshold_suspicious >= self.failure_threshold_high {
508            self.failure_threshold_suspicious = self.failure_threshold_high.saturating_sub(1);
509        }
510        self.failure_threshold_suspicious = self.failure_threshold_suspicious.max(1);
511
512        // Ensure windows are reasonable (min 10ms for testing, typically much higher)
513        self.failure_window_ms = self.failure_window_ms.max(10);
514        self.distributed_window_ms = self.distributed_window_ms.max(10);
515        self.takeover_window_ms = self.takeover_window_ms.max(10);
516        self.cleanup_interval_ms = self.cleanup_interval_ms.max(10);
517
518        // Ensure distributed attack needs at least 2 IPs
519        self.distributed_min_ips = self.distributed_min_ips.max(2);
520
521        // Ensure takeover needs at least 1 failure
522        self.takeover_min_failures = self.takeover_min_failures.max(1);
523
524        // Cap limits to prevent memory exhaustion
525        self.max_entities = self.max_entities.min(10_000_000);
526        self.max_distributed_attacks = self.max_distributed_attacks.min(100_000);
527        self.max_takeover_alerts = self.max_takeover_alerts.min(100_000);
528
529        self
530    }
531}
532
533impl Default for StuffingConfig {
534    fn default() -> Self {
535        Self {
536            // Single IP thresholds
537            failure_window_ms: 5 * 60 * 1000, // 5 minutes
538            failure_threshold_suspicious: 5,
539            failure_threshold_high: 20,
540            failure_threshold_block: 50,
541
542            // Distributed attack (fingerprint-based)
543            distributed_min_ips: 3,
544            distributed_window_ms: 15 * 60 * 1000, // 15 minutes
545
546            // Username-targeted attack detection
547            username_targeted_min_ips: 5,       // 5 different IPs
548            username_targeted_min_failures: 10, // 10 failures total
549            username_targeted_window_ms: 10 * 60 * 1000, // 10 minutes
550
551            // Global velocity detection
552            global_velocity_threshold_rate: 10.0, // 10 failures/sec
553            global_velocity_window_ms: 60 * 1000, // 1 minute
554            global_velocity_max_track: 5000,      // Track up to 5000 failures
555
556            // Takeover detection
557            takeover_window_ms: 5 * 60 * 1000, // 5 minutes
558            takeover_min_failures: 5,
559
560            // Low-and-slow
561            low_slow_min_hours: 3,
562            low_slow_min_per_hour: 2,
563
564            // Default auth patterns
565            auth_path_patterns: vec![
566                r"(?i)/login".to_string(),
567                r"(?i)/auth".to_string(),
568                r"(?i)/signin".to_string(),
569                r"(?i)/token".to_string(),
570                r"(?i)/oauth".to_string(),
571                r"(?i)/session".to_string(),
572            ],
573
574            // Limits
575            max_entities: 100_000,
576            max_distributed_attacks: 1_000,
577            max_takeover_alerts: 1_000,
578            cleanup_interval_ms: 5 * 60 * 1000, // 5 minutes
579        }
580    }
581}
582
583/// Auth attempt input for recording.
584#[derive(Debug, Clone)]
585pub struct AuthAttempt {
586    /// Entity ID (IP address)
587    pub entity_id: String,
588    /// Endpoint path
589    pub endpoint: String,
590    /// Optional fingerprint for correlation
591    pub fingerprint: Option<String>,
592    /// Optional username for targeted attack detection
593    pub username: Option<String>,
594    /// Timestamp (ms since epoch)
595    pub timestamp: u64,
596}
597
598impl AuthAttempt {
599    /// Create a new auth attempt.
600    pub fn new(entity_id: impl Into<String>, endpoint: impl Into<String>, now: u64) -> Self {
601        Self {
602            entity_id: entity_id.into(),
603            endpoint: endpoint.into(),
604            fingerprint: None,
605            username: None,
606            timestamp: now,
607        }
608    }
609
610    /// Set fingerprint.
611    pub fn with_fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
612        self.fingerprint = Some(fingerprint.into());
613        self
614    }
615
616    /// Set username for targeted attack detection.
617    pub fn with_username(mut self, username: impl Into<String>) -> Self {
618        self.username = Some(username.into());
619        self
620    }
621}
622
623/// Auth result input for recording success/failure.
624#[derive(Debug, Clone)]
625pub struct AuthResult {
626    /// Entity ID (IP address)
627    pub entity_id: String,
628    /// Endpoint path
629    pub endpoint: String,
630    /// Whether auth succeeded
631    pub success: bool,
632    /// Optional username for targeted attack detection
633    pub username: Option<String>,
634    /// Timestamp (ms since epoch)
635    pub timestamp: u64,
636}
637
638impl AuthResult {
639    /// Create a new auth result.
640    pub fn new(
641        entity_id: impl Into<String>,
642        endpoint: impl Into<String>,
643        success: bool,
644        now: u64,
645    ) -> Self {
646        Self {
647            entity_id: entity_id.into(),
648            endpoint: endpoint.into(),
649            success,
650            username: None,
651            timestamp: now,
652        }
653    }
654
655    /// Set username for targeted attack detection.
656    pub fn with_username(mut self, username: impl Into<String>) -> Self {
657        self.username = Some(username.into());
658        self
659    }
660}
661
662/// Username-targeted attack tracking (multiple IPs targeting same username).
663///
664/// SECURITY: Detects distributed credential stuffing where a botnet targets
665/// the same username(s) from many different IPs to evade per-IP rate limiting.
666#[derive(Debug, Clone, Serialize, Deserialize)]
667pub struct UsernameTargetedAttack {
668    /// Target username
669    pub username: String,
670    /// Endpoint being targeted
671    pub endpoint: String,
672    /// Entity IDs (IPs) attempting this username
673    pub attacking_ips: HashSet<String>,
674    /// Total failure count
675    pub total_failures: u64,
676    /// Window start timestamp
677    pub window_start: u64,
678    /// Last activity timestamp
679    pub last_activity: u64,
680}
681
682impl UsernameTargetedAttack {
683    /// Create a new username-targeted attack record.
684    pub fn new(username: String, endpoint: String, entity_id: String, now: u64) -> Self {
685        let mut attacking_ips = HashSet::new();
686        attacking_ips.insert(entity_id);
687        Self {
688            username,
689            endpoint,
690            attacking_ips,
691            total_failures: 0,
692            window_start: now,
693            last_activity: now,
694        }
695    }
696
697    /// Add an IP attempting this username.
698    pub fn add_ip(&mut self, entity_id: String, now: u64) {
699        self.attacking_ips.insert(entity_id);
700        self.last_activity = now;
701    }
702
703    /// Record a failure.
704    pub fn record_failure(&mut self, now: u64) {
705        self.total_failures += 1;
706        self.last_activity = now;
707    }
708
709    /// Get number of unique IPs attacking this username.
710    pub fn ip_count(&self) -> usize {
711        self.attacking_ips.len()
712    }
713}
714
715/// Global velocity tracking for overall auth failure rate.
716///
717/// SECURITY: Detects sudden spikes in global auth failure rate that may
718/// indicate a coordinated attack across many IPs/usernames.
719#[derive(Debug, Clone)]
720pub struct GlobalVelocityTracker {
721    /// Sliding window of failure timestamps (ring buffer)
722    failure_times: VecDeque<u64>,
723    /// Maximum window size
724    max_window_size: usize,
725    /// Window duration in milliseconds
726    window_ms: u64,
727}
728
729impl Default for GlobalVelocityTracker {
730    fn default() -> Self {
731        Self::new(1000, 60_000) // 1000 failures, 60 second window
732    }
733}
734
735impl GlobalVelocityTracker {
736    /// Create a new global velocity tracker.
737    pub fn new(max_window_size: usize, window_ms: u64) -> Self {
738        Self {
739            failure_times: VecDeque::with_capacity(max_window_size),
740            max_window_size,
741            window_ms,
742        }
743    }
744
745    /// Record a failure.
746    pub fn record_failure(&mut self, now: u64) {
747        // Evict old entries
748        let threshold = now.saturating_sub(self.window_ms);
749        while let Some(&oldest) = self.failure_times.front() {
750            if oldest < threshold {
751                self.failure_times.pop_front();
752            } else {
753                break;
754            }
755        }
756
757        // Add new entry (bounded)
758        if self.failure_times.len() < self.max_window_size {
759            self.failure_times.push_back(now);
760        }
761    }
762
763    /// Get current failure rate (failures per second in window).
764    pub fn failure_rate(&self, now: u64) -> f64 {
765        let threshold = now.saturating_sub(self.window_ms);
766        let recent_count = self
767            .failure_times
768            .iter()
769            .filter(|&&t| t >= threshold)
770            .count();
771
772        if self.window_ms == 0 {
773            return 0.0;
774        }
775
776        (recent_count as f64) / (self.window_ms as f64 / 1000.0)
777    }
778
779    /// Get failure count in window.
780    pub fn failure_count(&self, now: u64) -> usize {
781        let threshold = now.saturating_sub(self.window_ms);
782        self.failure_times
783            .iter()
784            .filter(|&&t| t >= threshold)
785            .count()
786    }
787}
788
789/// Composite key for entity+endpoint tracking.
790#[derive(Debug, Clone, Hash, PartialEq, Eq)]
791pub struct EntityEndpointKey {
792    pub entity_id: String,
793    pub endpoint: String,
794}
795
796impl EntityEndpointKey {
797    pub fn new(entity_id: impl Into<String>, endpoint: impl Into<String>) -> Self {
798        Self {
799            entity_id: entity_id.into(),
800            endpoint: endpoint.into(),
801        }
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    #[test]
810    fn test_severity_default_risk() {
811        assert_eq!(StuffingSeverity::Low.default_risk_delta(), 5);
812        assert_eq!(StuffingSeverity::Medium.default_risk_delta(), 10);
813        assert_eq!(StuffingSeverity::High.default_risk_delta(), 25);
814        assert_eq!(StuffingSeverity::Critical.default_risk_delta(), 50);
815    }
816
817    #[test]
818    fn test_verdict_creation() {
819        let allow = StuffingVerdict::Allow;
820        assert!(allow.is_allow());
821        assert!(!allow.is_block());
822        assert_eq!(allow.risk_delta(), 0);
823
824        let suspicious = StuffingVerdict::suspicious("test", StuffingSeverity::High);
825        assert!(!suspicious.is_allow());
826        assert_eq!(suspicious.risk_delta(), 25);
827
828        let block = StuffingVerdict::block("blocked");
829        assert!(block.is_block());
830    }
831
832    #[test]
833    fn test_auth_metrics_failure_recording() {
834        let mut metrics = AuthMetrics::new("1.2.3.4".to_string(), "/login".to_string(), 1000);
835
836        metrics.record_failure(1000);
837        metrics.record_failure(2000);
838        metrics.record_failure(3000);
839
840        assert_eq!(metrics.failures, 3);
841        assert_eq!(metrics.total_failures, 3);
842        assert_eq!(metrics.successes, 0);
843    }
844
845    #[test]
846    fn test_auth_metrics_window_reset() {
847        let mut metrics = AuthMetrics::new("1.2.3.4".to_string(), "/login".to_string(), 1000);
848
849        metrics.record_failure(1000);
850        metrics.record_failure(2000);
851        assert_eq!(metrics.failures, 2);
852
853        metrics.reset_window(5000);
854        assert_eq!(metrics.failures, 0);
855        assert_eq!(metrics.total_failures, 2); // Total preserved
856    }
857
858    #[test]
859    fn test_distributed_attack() {
860        let mut attack = DistributedAttack::new(
861            "fp123".to_string(),
862            "/login".to_string(),
863            "1.1.1.1".to_string(),
864            1000,
865        );
866
867        attack.add_entity("2.2.2.2".to_string(), 2000);
868        attack.add_entity("3.3.3.3".to_string(), 3000);
869        attack.record_failure(3000);
870        attack.record_failure(3000);
871
872        assert_eq!(attack.entity_count(), 3);
873        assert_eq!(attack.total_failures, 2);
874        assert!(attack.correlation_score > 0.0);
875    }
876
877    #[test]
878    fn test_takeover_alert_severity() {
879        let alert = TakeoverAlert::new("1.2.3.4".to_string(), "/login".to_string(), 5, 60000, 1000);
880        assert_eq!(alert.severity, StuffingSeverity::Critical);
881
882        let high_alert =
883            TakeoverAlert::new("1.2.3.4".to_string(), "/login".to_string(), 25, 60000, 1000);
884        assert_eq!(high_alert.severity, StuffingSeverity::High);
885
886        let critical_alert = TakeoverAlert::new(
887            "1.2.3.4".to_string(),
888            "/login".to_string(),
889            100,
890            60000,
891            1000,
892        );
893        assert_eq!(critical_alert.severity, StuffingSeverity::Critical);
894    }
895
896    #[test]
897    fn test_stuffing_event_entity_id() {
898        let event = StuffingEvent::SuspiciousFailureRate {
899            entity_id: "1.2.3.4".to_string(),
900            endpoint: "/login".to_string(),
901            failures: 10,
902            window_ms: 60000,
903            severity: StuffingSeverity::Medium,
904        };
905        assert_eq!(event.entity_id(), Some("1.2.3.4"));
906
907        let distributed = StuffingEvent::DistributedAttackDetected {
908            fingerprint: "fp123".to_string(),
909            endpoint: "/login".to_string(),
910            ip_count: 5,
911            total_failures: 100,
912            severity: StuffingSeverity::High,
913        };
914        assert_eq!(distributed.entity_id(), None);
915    }
916
917    #[test]
918    fn test_config_defaults() {
919        let config = StuffingConfig::default();
920        assert_eq!(config.failure_window_ms, 5 * 60 * 1000);
921        assert_eq!(config.failure_threshold_suspicious, 5);
922        assert_eq!(config.failure_threshold_high, 20);
923        assert_eq!(config.failure_threshold_block, 50);
924        assert_eq!(config.distributed_min_ips, 3);
925        assert!(!config.auth_path_patterns.is_empty());
926    }
927
928    #[test]
929    fn test_auth_attempt_builder() {
930        let attempt = AuthAttempt::new("1.2.3.4", "/login", 1000).with_fingerprint("fp123");
931        assert_eq!(attempt.entity_id, "1.2.3.4");
932        assert_eq!(attempt.endpoint, "/login");
933        assert_eq!(attempt.fingerprint, Some("fp123".to_string()));
934    }
935
936    #[test]
937    fn test_config_validation_thresholds() {
938        // Invalid: suspicious > high > block (inverted)
939        let config = StuffingConfig {
940            failure_threshold_suspicious: 100,
941            failure_threshold_high: 50,
942            failure_threshold_block: 10,
943            ..Default::default()
944        };
945
946        let validated = config.validated();
947
948        // Should be corrected to ascending order
949        assert!(validated.failure_threshold_suspicious < validated.failure_threshold_high);
950        assert!(validated.failure_threshold_high < validated.failure_threshold_block);
951        assert!(validated.failure_threshold_suspicious >= 1);
952    }
953
954    #[test]
955    fn test_config_validation_windows() {
956        // Invalid: zero windows
957        let config = StuffingConfig {
958            failure_window_ms: 0,
959            distributed_window_ms: 0,
960            takeover_window_ms: 0,
961            cleanup_interval_ms: 0,
962            ..Default::default()
963        };
964
965        let validated = config.validated();
966
967        // Should be at least 10ms (allows testing with small windows)
968        assert!(validated.failure_window_ms >= 10);
969        assert!(validated.distributed_window_ms >= 10);
970        assert!(validated.takeover_window_ms >= 10);
971        assert!(validated.cleanup_interval_ms >= 10);
972    }
973
974    #[test]
975    fn test_config_validation_limits() {
976        // Invalid: very large limits
977        let config = StuffingConfig {
978            max_entities: usize::MAX,
979            max_distributed_attacks: usize::MAX,
980            max_takeover_alerts: usize::MAX,
981            ..Default::default()
982        };
983
984        let validated = config.validated();
985
986        // Should be capped
987        assert!(validated.max_entities <= 10_000_000);
988        assert!(validated.max_distributed_attacks <= 100_000);
989        assert!(validated.max_takeover_alerts <= 100_000);
990    }
991}