use serde::{Deserialize, Serialize};
use std::collections::{HashSet, VecDeque};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(u8)]
#[derive(Default)]
pub enum StuffingSeverity {
Low = 0,
#[default]
Medium = 1,
High = 2,
Critical = 3,
}
impl StuffingSeverity {
pub const fn as_str(&self) -> &'static str {
match self {
StuffingSeverity::Low => "low",
StuffingSeverity::Medium => "medium",
StuffingSeverity::High => "high",
StuffingSeverity::Critical => "critical",
}
}
pub const fn default_risk_delta(&self) -> i32 {
match self {
StuffingSeverity::Low => 5,
StuffingSeverity::Medium => 10,
StuffingSeverity::High => 25,
StuffingSeverity::Critical => 50,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StuffingVerdict {
Allow,
Suspicious {
reason: String,
risk_delta: i32,
severity: StuffingSeverity,
},
Block { reason: String },
}
impl StuffingVerdict {
pub fn suspicious(reason: impl Into<String>, severity: StuffingSeverity) -> Self {
StuffingVerdict::Suspicious {
reason: reason.into(),
risk_delta: severity.default_risk_delta(),
severity,
}
}
pub fn suspicious_with_risk(
reason: impl Into<String>,
severity: StuffingSeverity,
risk_delta: i32,
) -> Self {
StuffingVerdict::Suspicious {
reason: reason.into(),
risk_delta,
severity,
}
}
pub fn block(reason: impl Into<String>) -> Self {
StuffingVerdict::Block {
reason: reason.into(),
}
}
pub fn is_allow(&self) -> bool {
matches!(self, StuffingVerdict::Allow)
}
pub fn is_block(&self) -> bool {
matches!(self, StuffingVerdict::Block { .. })
}
pub fn risk_delta(&self) -> i32 {
match self {
StuffingVerdict::Suspicious { risk_delta, .. } => *risk_delta,
_ => 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthMetrics {
pub entity_id: String,
pub endpoint: String,
pub failures: u32,
pub successes: u32,
pub window_start: u64,
pub last_attempt: u64,
pub total_failures: u64,
pub total_successes: u64,
pub hourly_failures: [u32; 24],
pub current_hour_index: u8,
pub last_hour_rotation: u64,
}
impl AuthMetrics {
pub fn new(entity_id: String, endpoint: String, now: u64) -> Self {
Self {
entity_id,
endpoint,
failures: 0,
successes: 0,
window_start: now,
last_attempt: now,
total_failures: 0,
total_successes: 0,
hourly_failures: [0; 24],
current_hour_index: 0,
last_hour_rotation: now,
}
}
pub fn record_failure(&mut self, now: u64) {
self.failures += 1;
self.total_failures += 1;
self.last_attempt = now;
self.update_hourly(now, true);
}
pub fn record_success(&mut self, now: u64) {
self.successes += 1;
self.total_successes += 1;
self.last_attempt = now;
}
pub fn reset_window(&mut self, now: u64) {
self.failures = 0;
self.successes = 0;
self.window_start = now;
}
fn update_hourly(&mut self, now: u64, is_failure: bool) {
const HOUR_MS: u64 = 60 * 60 * 1000;
let hours_elapsed = now.saturating_sub(self.last_hour_rotation) / HOUR_MS;
if hours_elapsed > 0 {
let rotations = hours_elapsed.min(24) as usize;
for _ in 0..rotations {
self.current_hour_index = (self.current_hour_index + 1) % 24;
self.hourly_failures[self.current_hour_index as usize] = 0;
}
self.last_hour_rotation = now;
}
if is_failure {
self.hourly_failures[self.current_hour_index as usize] += 1;
}
}
pub fn detect_low_and_slow(&self, min_hours: usize, min_failures_per_hour: u32) -> bool {
let active_hours: usize = self
.hourly_failures
.iter()
.filter(|&&f| f >= min_failures_per_hour)
.count();
active_hours >= min_hours
}
#[allow(dead_code)]
pub fn failure_rate(&self, now: u64) -> f64 {
let window_duration = now.saturating_sub(self.window_start);
if window_duration == 0 {
return 0.0;
}
(self.failures as f64) / (window_duration as f64 / 1000.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DistributedAttack {
pub fingerprint: String,
pub endpoint: String,
pub entities: HashSet<String>,
pub total_failures: u64,
pub window_start: u64,
pub last_activity: u64,
pub correlation_score: f32,
}
impl DistributedAttack {
pub fn new(fingerprint: String, endpoint: String, entity_id: String, now: u64) -> Self {
let mut entities = HashSet::new();
entities.insert(entity_id);
Self {
fingerprint,
endpoint,
entities,
total_failures: 0,
window_start: now,
last_activity: now,
correlation_score: 0.0,
}
}
pub fn add_entity(&mut self, entity_id: String, now: u64) {
self.entities.insert(entity_id);
self.last_activity = now;
self.update_correlation_score();
}
pub fn record_failure(&mut self, now: u64) {
self.total_failures += 1;
self.last_activity = now;
}
pub fn entity_count(&self) -> usize {
self.entities.len()
}
fn update_correlation_score(&mut self) {
let entity_factor = (self.entities.len() as f32 / 10.0).min(1.0);
let failure_factor = (self.total_failures as f32 / 100.0).min(1.0);
self.correlation_score = (entity_factor + failure_factor) / 2.0;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TakeoverAlert {
pub entity_id: String,
pub endpoint: String,
pub prior_failures: u32,
pub failure_window_ms: u64,
pub success_at: u64,
pub severity: StuffingSeverity,
}
impl TakeoverAlert {
pub fn new(
entity_id: String,
endpoint: String,
prior_failures: u32,
failure_window_ms: u64,
success_at: u64,
) -> Self {
let severity = if prior_failures >= 50 {
StuffingSeverity::Critical
} else if prior_failures >= 20 {
StuffingSeverity::High
} else {
StuffingSeverity::Critical };
Self {
entity_id,
endpoint,
prior_failures,
failure_window_ms,
success_at,
severity,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StuffingEvent {
SuspiciousFailureRate {
entity_id: String,
endpoint: String,
failures: u32,
window_ms: u64,
severity: StuffingSeverity,
},
DistributedAttackDetected {
fingerprint: String,
endpoint: String,
ip_count: usize,
total_failures: u64,
severity: StuffingSeverity,
},
UsernameTargetedAttack {
username: String,
endpoint: String,
ip_count: usize,
total_failures: u64,
severity: StuffingSeverity,
},
GlobalVelocitySpike {
failure_rate: f64,
failure_count: usize,
threshold_rate: f64,
severity: StuffingSeverity,
},
AccountTakeover {
entity_id: String,
endpoint: String,
prior_failures: u32,
severity: StuffingSeverity,
},
LowAndSlow {
entity_id: String,
endpoint: String,
hours_active: usize,
total_failures: u64,
severity: StuffingSeverity,
},
}
impl StuffingEvent {
pub fn severity(&self) -> StuffingSeverity {
match self {
StuffingEvent::SuspiciousFailureRate { severity, .. } => *severity,
StuffingEvent::DistributedAttackDetected { severity, .. } => *severity,
StuffingEvent::UsernameTargetedAttack { severity, .. } => *severity,
StuffingEvent::GlobalVelocitySpike { severity, .. } => *severity,
StuffingEvent::AccountTakeover { severity, .. } => *severity,
StuffingEvent::LowAndSlow { severity, .. } => *severity,
}
}
pub fn entity_id(&self) -> Option<&str> {
match self {
StuffingEvent::SuspiciousFailureRate { entity_id, .. } => Some(entity_id),
StuffingEvent::AccountTakeover { entity_id, .. } => Some(entity_id),
StuffingEvent::LowAndSlow { entity_id, .. } => Some(entity_id),
StuffingEvent::DistributedAttackDetected { .. } => None,
StuffingEvent::UsernameTargetedAttack { .. } => None,
StuffingEvent::GlobalVelocitySpike { .. } => None,
}
}
}
#[derive(Debug, Clone)]
pub struct StuffingConfig {
pub failure_window_ms: u64,
pub failure_threshold_suspicious: u32,
pub failure_threshold_high: u32,
pub failure_threshold_block: u32,
pub distributed_min_ips: usize,
pub distributed_window_ms: u64,
pub username_targeted_min_ips: usize,
pub username_targeted_min_failures: u64,
pub username_targeted_window_ms: u64,
pub global_velocity_threshold_rate: f64,
pub global_velocity_window_ms: u64,
pub global_velocity_max_track: usize,
pub takeover_window_ms: u64,
pub takeover_min_failures: u32,
pub low_slow_min_hours: usize,
pub low_slow_min_per_hour: u32,
pub auth_path_patterns: Vec<String>,
pub max_entities: usize,
pub max_distributed_attacks: usize,
pub max_takeover_alerts: usize,
pub cleanup_interval_ms: u64,
}
impl StuffingConfig {
pub fn validated(mut self) -> Self {
self.failure_threshold_block = self.failure_threshold_block.max(3);
if self.failure_threshold_high >= self.failure_threshold_block {
self.failure_threshold_high = self.failure_threshold_block.saturating_sub(1);
}
self.failure_threshold_high = self.failure_threshold_high.max(2);
if self.failure_threshold_suspicious >= self.failure_threshold_high {
self.failure_threshold_suspicious = self.failure_threshold_high.saturating_sub(1);
}
self.failure_threshold_suspicious = self.failure_threshold_suspicious.max(1);
self.failure_window_ms = self.failure_window_ms.max(10);
self.distributed_window_ms = self.distributed_window_ms.max(10);
self.takeover_window_ms = self.takeover_window_ms.max(10);
self.cleanup_interval_ms = self.cleanup_interval_ms.max(10);
self.distributed_min_ips = self.distributed_min_ips.max(2);
self.takeover_min_failures = self.takeover_min_failures.max(1);
self.max_entities = self.max_entities.min(10_000_000);
self.max_distributed_attacks = self.max_distributed_attacks.min(100_000);
self.max_takeover_alerts = self.max_takeover_alerts.min(100_000);
self
}
}
impl Default for StuffingConfig {
fn default() -> Self {
Self {
failure_window_ms: 5 * 60 * 1000, failure_threshold_suspicious: 5,
failure_threshold_high: 20,
failure_threshold_block: 50,
distributed_min_ips: 3,
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,
low_slow_min_hours: 3,
low_slow_min_per_hour: 2,
auth_path_patterns: vec![
r"(?i)/login".to_string(),
r"(?i)/auth".to_string(),
r"(?i)/signin".to_string(),
r"(?i)/token".to_string(),
r"(?i)/oauth".to_string(),
r"(?i)/session".to_string(),
],
max_entities: 100_000,
max_distributed_attacks: 1_000,
max_takeover_alerts: 1_000,
cleanup_interval_ms: 5 * 60 * 1000, }
}
}
#[derive(Debug, Clone)]
pub struct AuthAttempt {
pub entity_id: String,
pub endpoint: String,
pub fingerprint: Option<String>,
pub username: Option<String>,
pub timestamp: u64,
}
impl AuthAttempt {
pub fn new(entity_id: impl Into<String>, endpoint: impl Into<String>, now: u64) -> Self {
Self {
entity_id: entity_id.into(),
endpoint: endpoint.into(),
fingerprint: None,
username: None,
timestamp: now,
}
}
pub fn with_fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
self.fingerprint = Some(fingerprint.into());
self
}
pub fn with_username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}
}
#[derive(Debug, Clone)]
pub struct AuthResult {
pub entity_id: String,
pub endpoint: String,
pub success: bool,
pub username: Option<String>,
pub timestamp: u64,
}
impl AuthResult {
pub fn new(
entity_id: impl Into<String>,
endpoint: impl Into<String>,
success: bool,
now: u64,
) -> Self {
Self {
entity_id: entity_id.into(),
endpoint: endpoint.into(),
success,
username: None,
timestamp: now,
}
}
pub fn with_username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsernameTargetedAttack {
pub username: String,
pub endpoint: String,
pub attacking_ips: HashSet<String>,
pub total_failures: u64,
pub window_start: u64,
pub last_activity: u64,
}
impl UsernameTargetedAttack {
pub fn new(username: String, endpoint: String, entity_id: String, now: u64) -> Self {
let mut attacking_ips = HashSet::new();
attacking_ips.insert(entity_id);
Self {
username,
endpoint,
attacking_ips,
total_failures: 0,
window_start: now,
last_activity: now,
}
}
pub fn add_ip(&mut self, entity_id: String, now: u64) {
self.attacking_ips.insert(entity_id);
self.last_activity = now;
}
pub fn record_failure(&mut self, now: u64) {
self.total_failures += 1;
self.last_activity = now;
}
pub fn ip_count(&self) -> usize {
self.attacking_ips.len()
}
}
#[derive(Debug, Clone)]
pub struct GlobalVelocityTracker {
failure_times: VecDeque<u64>,
max_window_size: usize,
window_ms: u64,
}
impl Default for GlobalVelocityTracker {
fn default() -> Self {
Self::new(1000, 60_000) }
}
impl GlobalVelocityTracker {
pub fn new(max_window_size: usize, window_ms: u64) -> Self {
Self {
failure_times: VecDeque::with_capacity(max_window_size),
max_window_size,
window_ms,
}
}
pub fn record_failure(&mut self, now: u64) {
let threshold = now.saturating_sub(self.window_ms);
while let Some(&oldest) = self.failure_times.front() {
if oldest < threshold {
self.failure_times.pop_front();
} else {
break;
}
}
if self.failure_times.len() < self.max_window_size {
self.failure_times.push_back(now);
}
}
pub fn failure_rate(&self, now: u64) -> f64 {
let threshold = now.saturating_sub(self.window_ms);
let recent_count = self
.failure_times
.iter()
.filter(|&&t| t >= threshold)
.count();
if self.window_ms == 0 {
return 0.0;
}
(recent_count as f64) / (self.window_ms as f64 / 1000.0)
}
pub fn failure_count(&self, now: u64) -> usize {
let threshold = now.saturating_sub(self.window_ms);
self.failure_times
.iter()
.filter(|&&t| t >= threshold)
.count()
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct EntityEndpointKey {
pub entity_id: String,
pub endpoint: String,
}
impl EntityEndpointKey {
pub fn new(entity_id: impl Into<String>, endpoint: impl Into<String>) -> Self {
Self {
entity_id: entity_id.into(),
endpoint: endpoint.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_default_risk() {
assert_eq!(StuffingSeverity::Low.default_risk_delta(), 5);
assert_eq!(StuffingSeverity::Medium.default_risk_delta(), 10);
assert_eq!(StuffingSeverity::High.default_risk_delta(), 25);
assert_eq!(StuffingSeverity::Critical.default_risk_delta(), 50);
}
#[test]
fn test_verdict_creation() {
let allow = StuffingVerdict::Allow;
assert!(allow.is_allow());
assert!(!allow.is_block());
assert_eq!(allow.risk_delta(), 0);
let suspicious = StuffingVerdict::suspicious("test", StuffingSeverity::High);
assert!(!suspicious.is_allow());
assert_eq!(suspicious.risk_delta(), 25);
let block = StuffingVerdict::block("blocked");
assert!(block.is_block());
}
#[test]
fn test_auth_metrics_failure_recording() {
let mut metrics = AuthMetrics::new("1.2.3.4".to_string(), "/login".to_string(), 1000);
metrics.record_failure(1000);
metrics.record_failure(2000);
metrics.record_failure(3000);
assert_eq!(metrics.failures, 3);
assert_eq!(metrics.total_failures, 3);
assert_eq!(metrics.successes, 0);
}
#[test]
fn test_auth_metrics_window_reset() {
let mut metrics = AuthMetrics::new("1.2.3.4".to_string(), "/login".to_string(), 1000);
metrics.record_failure(1000);
metrics.record_failure(2000);
assert_eq!(metrics.failures, 2);
metrics.reset_window(5000);
assert_eq!(metrics.failures, 0);
assert_eq!(metrics.total_failures, 2); }
#[test]
fn test_distributed_attack() {
let mut attack = DistributedAttack::new(
"fp123".to_string(),
"/login".to_string(),
"1.1.1.1".to_string(),
1000,
);
attack.add_entity("2.2.2.2".to_string(), 2000);
attack.add_entity("3.3.3.3".to_string(), 3000);
attack.record_failure(3000);
attack.record_failure(3000);
assert_eq!(attack.entity_count(), 3);
assert_eq!(attack.total_failures, 2);
assert!(attack.correlation_score > 0.0);
}
#[test]
fn test_takeover_alert_severity() {
let alert = TakeoverAlert::new("1.2.3.4".to_string(), "/login".to_string(), 5, 60000, 1000);
assert_eq!(alert.severity, StuffingSeverity::Critical);
let high_alert =
TakeoverAlert::new("1.2.3.4".to_string(), "/login".to_string(), 25, 60000, 1000);
assert_eq!(high_alert.severity, StuffingSeverity::High);
let critical_alert = TakeoverAlert::new(
"1.2.3.4".to_string(),
"/login".to_string(),
100,
60000,
1000,
);
assert_eq!(critical_alert.severity, StuffingSeverity::Critical);
}
#[test]
fn test_stuffing_event_entity_id() {
let event = StuffingEvent::SuspiciousFailureRate {
entity_id: "1.2.3.4".to_string(),
endpoint: "/login".to_string(),
failures: 10,
window_ms: 60000,
severity: StuffingSeverity::Medium,
};
assert_eq!(event.entity_id(), Some("1.2.3.4"));
let distributed = StuffingEvent::DistributedAttackDetected {
fingerprint: "fp123".to_string(),
endpoint: "/login".to_string(),
ip_count: 5,
total_failures: 100,
severity: StuffingSeverity::High,
};
assert_eq!(distributed.entity_id(), None);
}
#[test]
fn test_config_defaults() {
let config = StuffingConfig::default();
assert_eq!(config.failure_window_ms, 5 * 60 * 1000);
assert_eq!(config.failure_threshold_suspicious, 5);
assert_eq!(config.failure_threshold_high, 20);
assert_eq!(config.failure_threshold_block, 50);
assert_eq!(config.distributed_min_ips, 3);
assert!(!config.auth_path_patterns.is_empty());
}
#[test]
fn test_auth_attempt_builder() {
let attempt = AuthAttempt::new("1.2.3.4", "/login", 1000).with_fingerprint("fp123");
assert_eq!(attempt.entity_id, "1.2.3.4");
assert_eq!(attempt.endpoint, "/login");
assert_eq!(attempt.fingerprint, Some("fp123".to_string()));
}
#[test]
fn test_config_validation_thresholds() {
let config = StuffingConfig {
failure_threshold_suspicious: 100,
failure_threshold_high: 50,
failure_threshold_block: 10,
..Default::default()
};
let validated = config.validated();
assert!(validated.failure_threshold_suspicious < validated.failure_threshold_high);
assert!(validated.failure_threshold_high < validated.failure_threshold_block);
assert!(validated.failure_threshold_suspicious >= 1);
}
#[test]
fn test_config_validation_windows() {
let config = StuffingConfig {
failure_window_ms: 0,
distributed_window_ms: 0,
takeover_window_ms: 0,
cleanup_interval_ms: 0,
..Default::default()
};
let validated = config.validated();
assert!(validated.failure_window_ms >= 10);
assert!(validated.distributed_window_ms >= 10);
assert!(validated.takeover_window_ms >= 10);
assert!(validated.cleanup_interval_ms >= 10);
}
#[test]
fn test_config_validation_limits() {
let config = StuffingConfig {
max_entities: usize::MAX,
max_distributed_attacks: usize::MAX,
max_takeover_alerts: usize::MAX,
..Default::default()
};
let validated = config.validated();
assert!(validated.max_entities <= 10_000_000);
assert!(validated.max_distributed_attacks <= 100_000);
assert!(validated.max_takeover_alerts <= 100_000);
}
}