rust_threat_detector/
behavioral_analytics.rs

1//! # Behavioral Analytics Module
2//!
3//! User and Entity Behavior Analytics (UEBA) for detecting anomalous behavior
4//! patterns that may indicate insider threats or compromised accounts.
5
6use crate::{LogEntry, ThreatAlert, ThreatCategory, ThreatSeverity};
7use chrono::{DateTime, Duration, Timelike, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// User behavior profile
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct UserProfile {
14    pub user_id: String,
15    pub first_seen: DateTime<Utc>,
16    pub last_seen: DateTime<Utc>,
17    pub total_events: usize,
18    pub failed_logins: usize,
19    pub successful_logins: usize,
20    pub typical_login_hours: Vec<u32>,
21    pub typical_source_ips: Vec<String>,
22    pub average_session_duration_minutes: f64,
23    pub accessed_resources: HashMap<String, usize>,
24}
25
26impl UserProfile {
27    pub fn new(user_id: String) -> Self {
28        Self {
29            user_id,
30            first_seen: Utc::now(),
31            last_seen: Utc::now(),
32            total_events: 0,
33            failed_logins: 0,
34            successful_logins: 0,
35            typical_login_hours: Vec::new(),
36            typical_source_ips: Vec::new(),
37            average_session_duration_minutes: 30.0,
38            accessed_resources: HashMap::new(),
39        }
40    }
41
42    /// Update profile with new event
43    pub fn update(&mut self, log: &LogEntry) {
44        self.last_seen = log.timestamp;
45        self.total_events += 1;
46
47        // Track login hours
48        let hour = log.timestamp.hour();
49        if !self.typical_login_hours.contains(&hour) && self.typical_login_hours.len() < 10 {
50            self.typical_login_hours.push(hour);
51        }
52
53        // Track source IPs
54        if let Some(ref ip) = log.source_ip {
55            if !self.typical_source_ips.contains(ip) && self.typical_source_ips.len() < 20 {
56                self.typical_source_ips.push(ip.clone());
57            }
58        }
59
60        // Track login attempts
61        if log.message.to_lowercase().contains("failed")
62            && log.message.to_lowercase().contains("login")
63        {
64            self.failed_logins += 1;
65        } else if log.message.to_lowercase().contains("successful")
66            && log.message.to_lowercase().contains("login")
67        {
68            self.successful_logins += 1;
69        }
70
71        // Track resource access
72        if let Some(resource) = log.metadata.get("resource") {
73            *self.accessed_resources.entry(resource.clone()).or_insert(0) += 1;
74        }
75    }
76
77    /// Calculate anomaly score for this event
78    pub fn calculate_anomaly_score(&self, log: &LogEntry) -> f64 {
79        let mut score = 0.0;
80
81        // Check if login hour is unusual (30% weight)
82        let hour = log.timestamp.hour();
83        if !self.typical_login_hours.is_empty() && !self.typical_login_hours.contains(&hour) {
84            score += 30.0;
85        }
86
87        // Check if source IP is unusual (30% weight)
88        if let Some(ref ip) = log.source_ip {
89            if !self.typical_source_ips.is_empty() && !self.typical_source_ips.contains(ip) {
90                score += 30.0;
91            }
92        }
93
94        // Check for unusual resource access (20% weight)
95        if let Some(resource) = log.metadata.get("resource") {
96            if !self.accessed_resources.contains_key(resource) {
97                score += 20.0;
98            }
99        }
100
101        // Check failure rate (20% weight)
102        if self.total_events > 10 {
103            let failure_rate = self.failed_logins as f64 / self.total_events as f64;
104            if failure_rate > 0.3 {
105                score += 20.0;
106            }
107        }
108
109        score
110    }
111}
112
113/// Behavioral analytics engine
114pub struct BehavioralAnalytics {
115    user_profiles: HashMap<String, UserProfile>,
116    entity_profiles: HashMap<String, EntityProfile>,
117    anomaly_threshold: f64,
118}
119
120/// Entity (host/service) behavior profile
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct EntityProfile {
123    pub entity_id: String,
124    pub first_seen: DateTime<Utc>,
125    pub last_seen: DateTime<Utc>,
126    pub typical_event_rate: f64, // Events per hour
127    pub typical_error_rate: f64, // Percentage
128    pub unusual_processes: Vec<String>,
129    pub network_connections: HashMap<String, usize>,
130}
131
132impl EntityProfile {
133    pub fn new(entity_id: String) -> Self {
134        Self {
135            entity_id,
136            first_seen: Utc::now(),
137            last_seen: Utc::now(),
138            typical_event_rate: 100.0,
139            typical_error_rate: 0.05,
140            unusual_processes: Vec::new(),
141            network_connections: HashMap::new(),
142        }
143    }
144}
145
146impl BehavioralAnalytics {
147    /// Create new behavioral analytics engine
148    pub fn new(anomaly_threshold: f64) -> Self {
149        Self {
150            user_profiles: HashMap::new(),
151            entity_profiles: HashMap::new(),
152            anomaly_threshold,
153        }
154    }
155
156    /// Analyze log for behavioral anomalies
157    pub fn analyze(&mut self, log: &LogEntry) -> Option<ThreatAlert> {
158        // Get or create user profile
159        let user_id = log.user.clone().unwrap_or_else(|| "unknown".to_string());
160        let profile = self
161            .user_profiles
162            .entry(user_id.clone())
163            .or_insert_with(|| UserProfile::new(user_id.clone()));
164
165        // Calculate anomaly score before updating profile
166        let anomaly_score = profile.calculate_anomaly_score(log);
167
168        // Update profile
169        profile.update(log);
170
171        // Generate alert if score exceeds threshold
172        if anomaly_score >= self.anomaly_threshold {
173            let severity = if anomaly_score >= 80.0 {
174                ThreatSeverity::Critical
175            } else if anomaly_score >= 60.0 {
176                ThreatSeverity::High
177            } else if anomaly_score >= 40.0 {
178                ThreatSeverity::Medium
179            } else {
180                ThreatSeverity::Low
181            };
182
183            Some(ThreatAlert {
184                alert_id: format!("UEBA-{}", chrono::Utc::now().timestamp()),
185                timestamp: Utc::now(),
186                severity,
187                category: ThreatCategory::AnomalousActivity,
188                description: format!(
189                    "Anomalous behavior detected for user '{}' (score: {:.1})",
190                    user_id, anomaly_score
191                ),
192                source_log: format!("{} - {}", log.timestamp, log.message),
193                indicators: self.build_indicators(&user_id, log, anomaly_score),
194                recommended_action:
195                    "Review user activity, verify identity, check for compromised credentials"
196                        .to_string(),
197                threat_score: anomaly_score as u32,
198                correlated_alerts: vec![],
199            })
200        } else {
201            None
202        }
203    }
204
205    /// Build anomaly indicators
206    fn build_indicators(&self, user_id: &str, log: &LogEntry, score: f64) -> Vec<String> {
207        let mut indicators = Vec::new();
208
209        if let Some(profile) = self.user_profiles.get(user_id) {
210            let hour = log.timestamp.hour();
211            if !profile.typical_login_hours.is_empty()
212                && !profile.typical_login_hours.contains(&hour)
213            {
214                indicators.push(format!("Unusual login hour: {}", hour));
215            }
216
217            if let Some(ref ip) = log.source_ip {
218                if !profile.typical_source_ips.is_empty()
219                    && !profile.typical_source_ips.contains(ip)
220                {
221                    indicators.push(format!("Unusual source IP: {}", ip));
222                }
223            }
224
225            if profile.total_events > 10 {
226                let failure_rate = profile.failed_logins as f64 / profile.total_events as f64;
227                if failure_rate > 0.3 {
228                    indicators.push(format!("High failure rate: {:.1}%", failure_rate * 100.0));
229                }
230            }
231        }
232
233        if indicators.is_empty() {
234            indicators.push(format!("Anomaly score: {:.1}", score));
235        }
236
237        indicators
238    }
239
240    /// Get user profile
241    pub fn get_user_profile(&self, user_id: &str) -> Option<&UserProfile> {
242        self.user_profiles.get(user_id)
243    }
244
245    /// Get all user profiles
246    pub fn get_all_profiles(&self) -> Vec<&UserProfile> {
247        self.user_profiles.values().collect()
248    }
249
250    /// Get high-risk users (those with many anomalies)
251    pub fn get_high_risk_users(&self, min_failed_logins: usize) -> Vec<&UserProfile> {
252        self.user_profiles
253            .values()
254            .filter(|profile| profile.failed_logins >= min_failed_logins)
255            .collect()
256    }
257
258    /// Clear old profiles to manage memory
259    pub fn clear_old_profiles(&mut self, before: DateTime<Utc>) {
260        self.user_profiles
261            .retain(|_, profile| profile.last_seen >= before);
262        self.entity_profiles
263            .retain(|_, profile| profile.last_seen >= before);
264    }
265
266    /// Get statistics
267    pub fn get_stats(&self) -> HashMap<String, usize> {
268        let mut stats = HashMap::new();
269        stats.insert("total_users".to_string(), self.user_profiles.len());
270        stats.insert("total_entities".to_string(), self.entity_profiles.len());
271
272        let active_users = self
273            .user_profiles
274            .values()
275            .filter(|p| p.last_seen >= Utc::now() - Duration::hours(24))
276            .count();
277        stats.insert("active_users_24h".to_string(), active_users);
278
279        stats
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use std::collections::HashMap;
287
288    fn create_log(user: &str, ip: &str, hour: u32) -> LogEntry {
289        let mut timestamp = Utc::now();
290        timestamp = timestamp
291            .date_naive()
292            .and_hms_opt(hour, 0, 0)
293            .unwrap()
294            .and_utc();
295
296        LogEntry {
297            timestamp,
298            source_ip: Some(ip.to_string()),
299            user: Some(user.to_string()),
300            event_type: "login".to_string(),
301            message: "User login attempt".to_string(),
302            metadata: HashMap::new(),
303        }
304    }
305
306    #[test]
307    fn test_user_profile_creation() {
308        let profile = UserProfile::new("test_user".to_string());
309        assert_eq!(profile.user_id, "test_user");
310        assert_eq!(profile.total_events, 0);
311    }
312
313    #[test]
314    fn test_user_profile_update() {
315        let mut profile = UserProfile::new("test_user".to_string());
316        let log = create_log("test_user", "192.168.1.1", 9);
317
318        profile.update(&log);
319
320        assert_eq!(profile.total_events, 1);
321        assert!(profile.typical_login_hours.contains(&9));
322        assert!(profile
323            .typical_source_ips
324            .contains(&"192.168.1.1".to_string()));
325    }
326
327    #[test]
328    fn test_anomaly_detection_unusual_hour() {
329        let mut analytics = BehavioralAnalytics::new(25.0); // Low threshold for testing
330
331        // Establish baseline - user logs in at 9 AM
332        for _ in 0..5 {
333            let log = create_log("alice", "192.168.1.100", 9);
334            analytics.analyze(&log);
335        }
336
337        // Login at unusual hour (3 AM)
338        let unusual_log = create_log("alice", "192.168.1.100", 3);
339        let alert = analytics.analyze(&unusual_log);
340
341        assert!(alert.is_some());
342        let alert = alert.unwrap();
343        assert_eq!(alert.category, ThreatCategory::AnomalousActivity);
344        assert!(alert.threat_score >= 25);
345    }
346
347    #[test]
348    fn test_anomaly_detection_unusual_ip() {
349        let mut analytics = BehavioralAnalytics::new(25.0);
350
351        // Establish baseline - user from office IP
352        for _ in 0..5 {
353            let log = create_log("bob", "192.168.1.50", 10);
354            analytics.analyze(&log);
355        }
356
357        // Login from unusual IP
358        let unusual_log = create_log("bob", "1.2.3.4", 10);
359        let alert = analytics.analyze(&unusual_log);
360
361        assert!(alert.is_some());
362    }
363
364    #[test]
365    fn test_no_anomaly_normal_behavior() {
366        let mut analytics = BehavioralAnalytics::new(50.0);
367
368        // Establish baseline
369        for _ in 0..10 {
370            let log = create_log("carol", "192.168.1.75", 14);
371            analytics.analyze(&log);
372        }
373
374        // Normal login
375        let normal_log = create_log("carol", "192.168.1.75", 14);
376        let alert = analytics.analyze(&normal_log);
377
378        assert!(alert.is_none());
379    }
380
381    #[test]
382    fn test_get_user_profile() {
383        let mut analytics = BehavioralAnalytics::new(50.0);
384
385        let log = create_log("dave", "192.168.1.88", 11);
386        analytics.analyze(&log);
387
388        let profile = analytics.get_user_profile("dave");
389        assert!(profile.is_some());
390        assert_eq!(profile.unwrap().user_id, "dave");
391    }
392
393    #[test]
394    fn test_high_risk_users() {
395        let mut analytics = BehavioralAnalytics::new(50.0);
396
397        // Create user with many failed logins
398        for _ in 0..10 {
399            let mut log = create_log("risky_user", "192.168.1.99", 10);
400            log.message = "Failed login attempt".to_string();
401            analytics.analyze(&log);
402        }
403
404        let high_risk = analytics.get_high_risk_users(5);
405        assert_eq!(high_risk.len(), 1);
406        assert_eq!(high_risk[0].user_id, "risky_user");
407    }
408
409    #[test]
410    fn test_clear_old_profiles() {
411        let mut analytics = BehavioralAnalytics::new(50.0);
412
413        let old_time = Utc::now() - Duration::hours(25);
414        let mut log = create_log("old_user", "192.168.1.1", 10);
415        log.timestamp = old_time;
416        analytics.analyze(&log);
417
418        assert_eq!(analytics.user_profiles.len(), 1);
419
420        let cutoff = Utc::now() - Duration::hours(24);
421        analytics.clear_old_profiles(cutoff);
422
423        assert_eq!(analytics.user_profiles.len(), 0);
424    }
425
426    #[test]
427    fn test_stats() {
428        let mut analytics = BehavioralAnalytics::new(50.0);
429
430        let log1 = create_log("user1", "192.168.1.1", 10);
431        let log2 = create_log("user2", "192.168.1.2", 11);
432
433        analytics.analyze(&log1);
434        analytics.analyze(&log2);
435
436        let stats = analytics.get_stats();
437        assert_eq!(stats.get("total_users"), Some(&2));
438    }
439}