1use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37use std::time::{Duration, SystemTime};
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct AnomalyConfig {
42 pub min_baseline_events: usize,
44
45 pub zscore_threshold: f64,
47
48 pub frequency_window: Duration,
50
51 pub max_access_rate: f64,
53
54 pub enable_temporal_detection: bool,
56
57 pub enable_privilege_escalation: bool,
59
60 pub retention_period: Duration,
62}
63
64impl Default for AnomalyConfig {
65 fn default() -> Self {
66 Self {
67 min_baseline_events: 100,
68 zscore_threshold: 2.5,
69 frequency_window: Duration::from_secs(3600), max_access_rate: 100.0, enable_temporal_detection: true,
72 enable_privilege_escalation: true,
73 retention_period: Duration::from_secs(30 * 24 * 3600), }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct AccessEvent {
81 pub subject_id: String,
82 pub resource_id: String,
83 pub relation: String,
84 pub granted: bool,
85 pub timestamp: SystemTime,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub enum AnomalyType {
91 UnusualFrequency,
93
94 UnusualTime,
96
97 UnusualResource,
99
100 PrivilegeEscalation,
102
103 RateLimitExceeded,
105
106 Combined(Vec<AnomalyType>),
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct Anomaly {
113 pub anomaly_type: AnomalyType,
114 pub subject_id: String,
115 pub resource_id: String,
116 pub severity: f64, pub description: String,
118 pub timestamp: SystemTime,
119}
120
121#[derive(Debug, Clone)]
123struct SubjectStats {
124 total_events: usize,
125 denied_events: usize,
126 resource_access_count: HashMap<String, usize>,
127 hourly_distribution: [usize; 24],
128 recent_events: Vec<SystemTime>,
129}
130
131impl SubjectStats {
132 fn new() -> Self {
133 Self {
134 total_events: 0,
135 denied_events: 0,
136 resource_access_count: HashMap::new(),
137 hourly_distribution: [0; 24],
138 recent_events: Vec::new(),
139 }
140 }
141}
142
143pub struct AnomalyDetector {
145 config: AnomalyConfig,
146 subject_stats: HashMap<String, SubjectStats>,
147}
148
149impl AnomalyDetector {
150 pub fn new(config: AnomalyConfig) -> Self {
152 Self {
153 config,
154 subject_stats: HashMap::new(),
155 }
156 }
157
158 pub fn check_anomaly(&mut self, event: &AccessEvent) -> Option<Anomaly> {
160 self.update_stats(event);
162
163 let stats = self.subject_stats.get(&event.subject_id)?;
165
166 let mut detected = Vec::new();
168
169 if stats.total_events < self.config.min_baseline_events {
171 return None;
172 }
173
174 if let Some(freq_anomaly) = self.check_frequency_anomaly(event, stats) {
176 detected.push(freq_anomaly);
177 }
178
179 if self.config.enable_temporal_detection {
181 if let Some(time_anomaly) = self.check_temporal_anomaly(event, stats) {
182 detected.push(time_anomaly);
183 }
184 }
185
186 if let Some(resource_anomaly) = self.check_resource_anomaly(event, stats) {
188 detected.push(resource_anomaly);
189 }
190
191 if self.config.enable_privilege_escalation {
193 if let Some(privesc_anomaly) = self.check_privilege_escalation(event, stats) {
194 detected.push(privesc_anomaly);
195 }
196 }
197
198 if let Some(rate_anomaly) = self.check_rate_limit(event, stats) {
200 detected.push(rate_anomaly);
201 }
202
203 if !detected.is_empty() {
205 let severity = detected
206 .iter()
207 .map(|a| a.severity)
208 .max_by(|a, b| a.partial_cmp(b).unwrap())
209 .unwrap_or(0.0);
210
211 let anomaly_type = if detected.len() == 1 {
212 detected[0].anomaly_type.clone()
213 } else {
214 AnomalyType::Combined(detected.iter().map(|a| a.anomaly_type.clone()).collect())
215 };
216
217 Some(Anomaly {
218 anomaly_type,
219 subject_id: event.subject_id.clone(),
220 resource_id: event.resource_id.clone(),
221 severity,
222 description: format!("Multiple anomalies detected: {} indicators", detected.len()),
223 timestamp: event.timestamp,
224 })
225 } else {
226 None
227 }
228 }
229
230 fn update_stats(&mut self, event: &AccessEvent) {
231 let stats = self
232 .subject_stats
233 .entry(event.subject_id.clone())
234 .or_insert_with(SubjectStats::new);
235
236 stats.total_events += 1;
237 if !event.granted {
238 stats.denied_events += 1;
239 }
240
241 *stats
242 .resource_access_count
243 .entry(event.resource_id.clone())
244 .or_insert(0) += 1;
245
246 if let Ok(duration) = event.timestamp.duration_since(SystemTime::UNIX_EPOCH) {
248 let hour = ((duration.as_secs() / 3600) % 24) as usize;
249 stats.hourly_distribution[hour] += 1;
250 }
251
252 stats.recent_events.push(event.timestamp);
254
255 let cutoff = event
257 .timestamp
258 .checked_sub(self.config.retention_period)
259 .unwrap_or(SystemTime::UNIX_EPOCH);
260 stats.recent_events.retain(|&t| t >= cutoff);
261 }
262
263 fn check_frequency_anomaly(
264 &self,
265 event: &AccessEvent,
266 stats: &SubjectStats,
267 ) -> Option<Anomaly> {
268 let resource_count = *stats
270 .resource_access_count
271 .get(&event.resource_id)
272 .unwrap_or(&0);
273
274 let counts: Vec<usize> = stats.resource_access_count.values().copied().collect();
276 if counts.is_empty() {
277 return None;
278 }
279
280 let mean = counts.iter().sum::<usize>() as f64 / counts.len() as f64;
281 let variance = counts
282 .iter()
283 .map(|&c| {
284 let diff = c as f64 - mean;
285 diff * diff
286 })
287 .sum::<f64>()
288 / counts.len() as f64;
289 let std_dev = variance.sqrt();
290
291 if std_dev == 0.0 {
293 return None;
294 }
295
296 let zscore = (resource_count as f64 - mean) / std_dev;
297
298 if zscore.abs() > self.config.zscore_threshold {
299 let severity = (zscore.abs() / self.config.zscore_threshold).min(1.0);
300 Some(Anomaly {
301 anomaly_type: AnomalyType::UnusualFrequency,
302 subject_id: event.subject_id.clone(),
303 resource_id: event.resource_id.clone(),
304 severity,
305 description: format!("Unusual access frequency (z-score: {:.2})", zscore),
306 timestamp: event.timestamp,
307 })
308 } else {
309 None
310 }
311 }
312
313 fn check_temporal_anomaly(&self, event: &AccessEvent, stats: &SubjectStats) -> Option<Anomaly> {
314 let hour = if let Ok(duration) = event.timestamp.duration_since(SystemTime::UNIX_EPOCH) {
316 ((duration.as_secs() / 3600) % 24) as usize
317 } else {
318 return None;
319 };
320
321 let total_accesses: usize = stats.hourly_distribution.iter().sum();
323 if total_accesses == 0 {
324 return None;
325 }
326
327 let expected_proportion = stats.hourly_distribution[hour] as f64 / total_accesses as f64;
328
329 if expected_proportion < 0.05 {
331 Some(Anomaly {
332 anomaly_type: AnomalyType::UnusualTime,
333 subject_id: event.subject_id.clone(),
334 resource_id: event.resource_id.clone(),
335 severity: 1.0 - expected_proportion,
336 description: format!(
337 "Access at unusual hour: {}:00 (only {:.1}% of normal activity)",
338 hour,
339 expected_proportion * 100.0
340 ),
341 timestamp: event.timestamp,
342 })
343 } else {
344 None
345 }
346 }
347
348 fn check_resource_anomaly(&self, event: &AccessEvent, stats: &SubjectStats) -> Option<Anomaly> {
349 let resource_count = *stats
350 .resource_access_count
351 .get(&event.resource_id)
352 .unwrap_or(&0);
353
354 if resource_count <= 1 && stats.total_events > self.config.min_baseline_events {
356 Some(Anomaly {
357 anomaly_type: AnomalyType::UnusualResource,
358 subject_id: event.subject_id.clone(),
359 resource_id: event.resource_id.clone(),
360 severity: 0.6,
361 description: "First-time access to this resource".to_string(),
362 timestamp: event.timestamp,
363 })
364 } else {
365 None
366 }
367 }
368
369 fn check_privilege_escalation(
370 &self,
371 event: &AccessEvent,
372 stats: &SubjectStats,
373 ) -> Option<Anomaly> {
374 if !event.granted && stats.total_events > 0 {
375 let denial_rate = stats.denied_events as f64 / stats.total_events as f64;
376
377 if denial_rate > 0.3 {
379 Some(Anomaly {
380 anomaly_type: AnomalyType::PrivilegeEscalation,
381 subject_id: event.subject_id.clone(),
382 resource_id: event.resource_id.clone(),
383 severity: denial_rate.min(1.0),
384 description: format!(
385 "High denial rate: {:.1}% of checks denied",
386 denial_rate * 100.0
387 ),
388 timestamp: event.timestamp,
389 })
390 } else {
391 None
392 }
393 } else {
394 None
395 }
396 }
397
398 fn check_rate_limit(&self, event: &AccessEvent, stats: &SubjectStats) -> Option<Anomaly> {
399 let one_minute_ago = event
401 .timestamp
402 .checked_sub(Duration::from_secs(60))
403 .unwrap_or(SystemTime::UNIX_EPOCH);
404 let recent_count = stats
405 .recent_events
406 .iter()
407 .filter(|&&t| t >= one_minute_ago)
408 .count();
409
410 if recent_count as f64 > self.config.max_access_rate {
411 Some(Anomaly {
412 anomaly_type: AnomalyType::RateLimitExceeded,
413 subject_id: event.subject_id.clone(),
414 resource_id: event.resource_id.clone(),
415 severity: ((recent_count as f64 / self.config.max_access_rate) - 1.0).min(1.0),
416 description: format!(
417 "Rate limit exceeded: {} requests in last minute",
418 recent_count
419 ),
420 timestamp: event.timestamp,
421 })
422 } else {
423 None
424 }
425 }
426
427 pub fn get_subject_stats(&self, subject_id: &str) -> Option<AnomalyStats> {
429 let stats = self.subject_stats.get(subject_id)?;
430
431 Some(AnomalyStats {
432 total_events: stats.total_events,
433 denied_events: stats.denied_events,
434 unique_resources: stats.resource_access_count.len(),
435 denial_rate: if stats.total_events > 0 {
436 stats.denied_events as f64 / stats.total_events as f64
437 } else {
438 0.0
439 },
440 })
441 }
442
443 pub fn cleanup(&mut self, cutoff: SystemTime) {
445 for stats in self.subject_stats.values_mut() {
446 stats.recent_events.retain(|&t| t >= cutoff);
447 }
448
449 self.subject_stats
451 .retain(|_, stats| !stats.recent_events.is_empty());
452 }
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct AnomalyStats {
458 pub total_events: usize,
459 pub denied_events: usize,
460 pub unique_resources: usize,
461 pub denial_rate: f64,
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn test_anomaly_detector_creation() {
470 let config = AnomalyConfig::default();
471 let detector = AnomalyDetector::new(config);
472 assert_eq!(detector.subject_stats.len(), 0);
473 }
474
475 #[test]
476 fn test_baseline_building() {
477 let config = AnomalyConfig {
478 min_baseline_events: 10,
479 ..Default::default()
480 };
481 let mut detector = AnomalyDetector::new(config);
482
483 for i in 0..20 {
485 let event = AccessEvent {
486 subject_id: "user:alice".to_string(),
487 resource_id: format!("doc:{}", i % 5),
488 relation: "read".to_string(),
489 granted: true,
490 timestamp: SystemTime::now(),
491 };
492 let _result = detector.check_anomaly(&event);
493 }
494
495 let stats = detector.get_subject_stats("user:alice").unwrap();
496 assert_eq!(stats.total_events, 20);
497 assert_eq!(stats.unique_resources, 5);
498 }
499
500 #[test]
501 fn test_unusual_resource_detection() {
502 let config = AnomalyConfig {
503 min_baseline_events: 10,
504 ..Default::default()
505 };
506 let mut detector = AnomalyDetector::new(config);
507
508 for _i in 0..15 {
510 let event = AccessEvent {
511 subject_id: "user:bob".to_string(),
512 resource_id: "doc:normal".to_string(),
513 relation: "read".to_string(),
514 granted: true,
515 timestamp: SystemTime::now(),
516 };
517 let _result = detector.check_anomaly(&event);
518 std::thread::sleep(Duration::from_millis(10));
519 }
520
521 let unusual_event = AccessEvent {
523 subject_id: "user:bob".to_string(),
524 resource_id: "doc:sensitive".to_string(),
525 relation: "read".to_string(),
526 granted: true,
527 timestamp: SystemTime::now(),
528 };
529
530 let anomaly = detector.check_anomaly(&unusual_event);
531 assert!(anomaly.is_some());
532 let anomaly = anomaly.unwrap();
533 assert_eq!(anomaly.anomaly_type, AnomalyType::UnusualResource);
534 }
535
536 #[test]
537 fn test_privilege_escalation_detection() {
538 let config = AnomalyConfig {
539 min_baseline_events: 10,
540 enable_privilege_escalation: true,
541 ..Default::default()
542 };
543 let mut detector = AnomalyDetector::new(config);
544
545 for _i in 0..15 {
547 let event = AccessEvent {
548 subject_id: "user:eve".to_string(),
549 resource_id: format!("doc:{}", _i),
550 relation: "admin".to_string(),
551 granted: _i % 2 == 0, timestamp: SystemTime::now(),
553 };
554 let _result = detector.check_anomaly(&event);
555 }
556
557 let denied_event = AccessEvent {
559 subject_id: "user:eve".to_string(),
560 resource_id: "doc:admin".to_string(),
561 relation: "admin".to_string(),
562 granted: false,
563 timestamp: SystemTime::now(),
564 };
565
566 let anomaly = detector.check_anomaly(&denied_event);
567 assert!(anomaly.is_some());
568 let anomaly = anomaly.unwrap();
569 match anomaly.anomaly_type {
570 AnomalyType::PrivilegeEscalation => {}
571 AnomalyType::Combined(types) => {
572 assert!(types.contains(&AnomalyType::PrivilegeEscalation));
573 }
574 _ => panic!("Expected PrivilegeEscalation anomaly"),
575 }
576 }
577
578 #[test]
579 fn test_rate_limit_detection() {
580 let config = AnomalyConfig {
581 min_baseline_events: 10,
582 max_access_rate: 5.0,
583 ..Default::default()
584 };
585 let mut detector = AnomalyDetector::new(config);
586
587 for _ in 0..15 {
589 let event = AccessEvent {
590 subject_id: "user:charlie".to_string(),
591 resource_id: "doc:test".to_string(),
592 relation: "read".to_string(),
593 granted: true,
594 timestamp: SystemTime::now(),
595 };
596 detector.check_anomaly(&event);
597 std::thread::sleep(Duration::from_millis(200)); }
599
600 for _ in 0..10 {
602 let event = AccessEvent {
603 subject_id: "user:charlie".to_string(),
604 resource_id: "doc:test".to_string(),
605 relation: "read".to_string(),
606 granted: true,
607 timestamp: SystemTime::now(),
608 };
609 let anomaly = detector.check_anomaly(&event);
610 if let Some(anomaly) = anomaly {
611 match anomaly.anomaly_type {
612 AnomalyType::RateLimitExceeded => return,
613 AnomalyType::Combined(types) => {
614 if types.contains(&AnomalyType::RateLimitExceeded) {
615 return;
616 }
617 }
618 _ => {}
619 }
620 }
621 }
622
623 panic!("Expected RateLimitExceeded anomaly");
624 }
625
626 #[test]
627 fn test_cleanup() {
628 let config = AnomalyConfig::default();
629 let mut detector = AnomalyDetector::new(config);
630
631 for i in 0..10 {
633 let event = AccessEvent {
634 subject_id: format!("user:{}", i),
635 resource_id: "doc:test".to_string(),
636 relation: "read".to_string(),
637 granted: true,
638 timestamp: SystemTime::now(),
639 };
640 detector.check_anomaly(&event);
641 }
642
643 assert_eq!(detector.subject_stats.len(), 10);
644
645 let future = SystemTime::now() + Duration::from_secs(3600);
647 detector.cleanup(future);
648
649 assert_eq!(detector.subject_stats.len(), 0);
650 }
651}