1use serde::{Deserialize, Serialize};
12use std::collections::{HashSet, VecDeque};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[repr(u8)]
17#[derive(Default)]
18pub enum StuffingSeverity {
19 Low = 0,
21 #[default]
23 Medium = 1,
24 High = 2,
26 Critical = 3,
28}
29
30impl StuffingSeverity {
31 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 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#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum StuffingVerdict {
55 Allow,
57 Suspicious {
59 reason: String,
60 risk_delta: i32,
61 severity: StuffingSeverity,
62 },
63 Block { reason: String },
65}
66
67impl StuffingVerdict {
68 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 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 pub fn block(reason: impl Into<String>) -> Self {
92 StuffingVerdict::Block {
93 reason: reason.into(),
94 }
95 }
96
97 pub fn is_allow(&self) -> bool {
99 matches!(self, StuffingVerdict::Allow)
100 }
101
102 pub fn is_block(&self) -> bool {
104 matches!(self, StuffingVerdict::Block { .. })
105 }
106
107 pub fn risk_delta(&self) -> i32 {
109 match self {
110 StuffingVerdict::Suspicious { risk_delta, .. } => *risk_delta,
111 _ => 0,
112 }
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct AuthMetrics {
121 pub entity_id: String,
123 pub endpoint: String,
125
126 pub failures: u32,
129 pub successes: u32,
131 pub window_start: u64,
133 pub last_attempt: u64,
135
136 pub total_failures: u64,
139 pub total_successes: u64,
141
142 pub hourly_failures: [u32; 24],
145 pub current_hour_index: u8,
147 pub last_hour_rotation: u64,
149}
150
151impl AuthMetrics {
152 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 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 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 pub fn reset_window(&mut self, now: u64) {
186 self.failures = 0;
187 self.successes = 0;
188 self.window_start = now;
189 }
190
191 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 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 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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct DistributedAttack {
239 pub fingerprint: String,
241 pub endpoint: String,
243 pub entities: HashSet<String>,
245 pub total_failures: u64,
247 pub window_start: u64,
249 pub last_activity: u64,
251 pub correlation_score: f32,
253}
254
255impl DistributedAttack {
256 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 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 pub fn record_failure(&mut self, now: u64) {
280 self.total_failures += 1;
281 self.last_activity = now;
282 }
283
284 pub fn entity_count(&self) -> usize {
286 self.entities.len()
287 }
288
289 fn update_correlation_score(&mut self) {
291 let entity_factor = (self.entities.len() as f32 / 10.0).min(1.0);
293 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#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct TakeoverAlert {
302 pub entity_id: String,
304 pub endpoint: String,
306 pub prior_failures: u32,
308 pub failure_window_ms: u64,
310 pub success_at: u64,
312 pub severity: StuffingSeverity,
314}
315
316impl TakeoverAlert {
317 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 let severity = if prior_failures >= 50 {
327 StuffingSeverity::Critical
328 } else if prior_failures >= 20 {
329 StuffingSeverity::High
330 } else {
331 StuffingSeverity::Critical };
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#[derive(Debug, Clone, Serialize, Deserialize)]
347#[serde(tag = "type", rename_all = "snake_case")]
348pub enum StuffingEvent {
349 SuspiciousFailureRate {
351 entity_id: String,
352 endpoint: String,
353 failures: u32,
354 window_ms: u64,
355 severity: StuffingSeverity,
356 },
357 DistributedAttackDetected {
359 fingerprint: String,
360 endpoint: String,
361 ip_count: usize,
362 total_failures: u64,
363 severity: StuffingSeverity,
364 },
365 UsernameTargetedAttack {
370 username: String,
371 endpoint: String,
372 ip_count: usize,
373 total_failures: u64,
374 severity: StuffingSeverity,
375 },
376 GlobalVelocitySpike {
381 failure_rate: f64,
382 failure_count: usize,
383 threshold_rate: f64,
384 severity: StuffingSeverity,
385 },
386 AccountTakeover {
388 entity_id: String,
389 endpoint: String,
390 prior_failures: u32,
391 severity: StuffingSeverity,
392 },
393 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 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 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#[derive(Debug, Clone)]
431pub struct StuffingConfig {
432 pub failure_window_ms: u64,
435 pub failure_threshold_suspicious: u32,
437 pub failure_threshold_high: u32,
439 pub failure_threshold_block: u32,
441
442 pub distributed_min_ips: usize,
445 pub distributed_window_ms: u64,
447
448 pub username_targeted_min_ips: usize,
451 pub username_targeted_min_failures: u64,
453 pub username_targeted_window_ms: u64,
455
456 pub global_velocity_threshold_rate: f64,
459 pub global_velocity_window_ms: u64,
461 pub global_velocity_max_track: usize,
463
464 pub takeover_window_ms: u64,
467 pub takeover_min_failures: u32,
469
470 pub low_slow_min_hours: usize,
473 pub low_slow_min_per_hour: u32,
475
476 pub auth_path_patterns: Vec<String>,
479
480 pub max_entities: usize,
483 pub max_distributed_attacks: usize,
485 pub max_takeover_alerts: usize,
487 pub cleanup_interval_ms: u64,
489}
490
491impl StuffingConfig {
492 pub fn validated(mut self) -> Self {
497 self.failure_threshold_block = self.failure_threshold_block.max(3);
499
500 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 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 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 self.distributed_min_ips = self.distributed_min_ips.max(2);
520
521 self.takeover_min_failures = self.takeover_min_failures.max(1);
523
524 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 failure_window_ms: 5 * 60 * 1000, failure_threshold_suspicious: 5,
539 failure_threshold_high: 20,
540 failure_threshold_block: 50,
541
542 distributed_min_ips: 3,
544 distributed_window_ms: 15 * 60 * 1000, username_targeted_min_ips: 5, username_targeted_min_failures: 10, username_targeted_window_ms: 10 * 60 * 1000, global_velocity_threshold_rate: 10.0, global_velocity_window_ms: 60 * 1000, global_velocity_max_track: 5000, takeover_window_ms: 5 * 60 * 1000, takeover_min_failures: 5,
559
560 low_slow_min_hours: 3,
562 low_slow_min_per_hour: 2,
563
564 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 max_entities: 100_000,
576 max_distributed_attacks: 1_000,
577 max_takeover_alerts: 1_000,
578 cleanup_interval_ms: 5 * 60 * 1000, }
580 }
581}
582
583#[derive(Debug, Clone)]
585pub struct AuthAttempt {
586 pub entity_id: String,
588 pub endpoint: String,
590 pub fingerprint: Option<String>,
592 pub username: Option<String>,
594 pub timestamp: u64,
596}
597
598impl AuthAttempt {
599 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 pub fn with_fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
612 self.fingerprint = Some(fingerprint.into());
613 self
614 }
615
616 pub fn with_username(mut self, username: impl Into<String>) -> Self {
618 self.username = Some(username.into());
619 self
620 }
621}
622
623#[derive(Debug, Clone)]
625pub struct AuthResult {
626 pub entity_id: String,
628 pub endpoint: String,
630 pub success: bool,
632 pub username: Option<String>,
634 pub timestamp: u64,
636}
637
638impl AuthResult {
639 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 pub fn with_username(mut self, username: impl Into<String>) -> Self {
657 self.username = Some(username.into());
658 self
659 }
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize)]
667pub struct UsernameTargetedAttack {
668 pub username: String,
670 pub endpoint: String,
672 pub attacking_ips: HashSet<String>,
674 pub total_failures: u64,
676 pub window_start: u64,
678 pub last_activity: u64,
680}
681
682impl UsernameTargetedAttack {
683 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 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 pub fn record_failure(&mut self, now: u64) {
705 self.total_failures += 1;
706 self.last_activity = now;
707 }
708
709 pub fn ip_count(&self) -> usize {
711 self.attacking_ips.len()
712 }
713}
714
715#[derive(Debug, Clone)]
720pub struct GlobalVelocityTracker {
721 failure_times: VecDeque<u64>,
723 max_window_size: usize,
725 window_ms: u64,
727}
728
729impl Default for GlobalVelocityTracker {
730 fn default() -> Self {
731 Self::new(1000, 60_000) }
733}
734
735impl GlobalVelocityTracker {
736 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 pub fn record_failure(&mut self, now: u64) {
747 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 if self.failure_times.len() < self.max_window_size {
759 self.failure_times.push_back(now);
760 }
761 }
762
763 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 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#[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); }
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 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 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 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 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 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 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}