1use crate::{LogEntry, ThreatAlert, ThreatCategory, ThreatSeverity};
7use chrono::{DateTime, Duration, Timelike, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[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 pub fn update(&mut self, log: &LogEntry) {
44 self.last_seen = log.timestamp;
45 self.total_events += 1;
46
47 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 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 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 if let Some(resource) = log.metadata.get("resource") {
73 *self.accessed_resources.entry(resource.clone()).or_insert(0) += 1;
74 }
75 }
76
77 pub fn calculate_anomaly_score(&self, log: &LogEntry) -> f64 {
79 let mut score = 0.0;
80
81 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 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 if let Some(resource) = log.metadata.get("resource") {
96 if !self.accessed_resources.contains_key(resource) {
97 score += 20.0;
98 }
99 }
100
101 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
113pub struct BehavioralAnalytics {
115 user_profiles: HashMap<String, UserProfile>,
116 entity_profiles: HashMap<String, EntityProfile>,
117 anomaly_threshold: f64,
118}
119
120#[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, pub typical_error_rate: f64, 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 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 pub fn analyze(&mut self, log: &LogEntry) -> Option<ThreatAlert> {
158 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 let anomaly_score = profile.calculate_anomaly_score(log);
167
168 profile.update(log);
170
171 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 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 pub fn get_user_profile(&self, user_id: &str) -> Option<&UserProfile> {
242 self.user_profiles.get(user_id)
243 }
244
245 pub fn get_all_profiles(&self) -> Vec<&UserProfile> {
247 self.user_profiles.values().collect()
248 }
249
250 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 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 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); for _ in 0..5 {
333 let log = create_log("alice", "192.168.1.100", 9);
334 analytics.analyze(&log);
335 }
336
337 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 for _ in 0..5 {
353 let log = create_log("bob", "192.168.1.50", 10);
354 analytics.analyze(&log);
355 }
356
357 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 for _ in 0..10 {
370 let log = create_log("carol", "192.168.1.75", 14);
371 analytics.analyze(&log);
372 }
373
374 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 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}