use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct AnomalyDetector {
typical_ops: Vec<String>,
typical_tags: Vec<String>,
ops_accessed: Vec<String>,
unique_endpoints: HashSet<String>,
write_attempts: u32,
denied_count: u32,
unusual_tag_access: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnomalyWarning {
pub severity: String,
pub category: String,
pub message: String,
pub details: serde_json::Value,
}
impl AnomalyDetector {
pub fn new() -> Self {
Self {
typical_ops: Vec::new(),
typical_tags: Vec::new(),
ops_accessed: Vec::new(),
unique_endpoints: HashSet::new(),
write_attempts: 0,
denied_count: 0,
unusual_tag_access: false,
}
}
pub fn reset(&mut self) {
self.ops_accessed.clear();
self.unique_endpoints.clear();
self.write_attempts = 0;
self.denied_count = 0;
self.unusual_tag_access = false;
}
pub fn record_access(&mut self, operation_id: &str, method: &str, _tags: &[String]) {
if self.ops_accessed.len() < 1000 {
self.ops_accessed.push(operation_id.to_string());
}
self.unique_endpoints.insert(operation_id.to_string());
if !matches!(method.to_uppercase().as_str(), "GET" | "HEAD" | "OPTIONS") {
self.write_attempts += 1;
}
}
pub fn record_denial(&mut self, _operation_id: &str, _reason: &str) {
self.denied_count += 1;
}
pub fn check_anomalies(&self) -> Vec<AnomalyWarning> {
let mut warnings = Vec::new();
if self.unique_endpoints.len() > 20 {
warnings.push(AnomalyWarning {
severity: "medium".to_string(),
category: "endpoint_spray".to_string(),
message: format!(
"Agent accessed {} unique endpoints (threshold: 20)",
self.unique_endpoints.len()
),
details: serde_json::json!({"unique_endpoints": self.unique_endpoints.len()}),
});
}
if self.write_attempts > 5 {
warnings.push(AnomalyWarning {
severity: "high".to_string(),
category: "write_spike".to_string(),
message: format!(
"Agent made {} write attempts (threshold: 5)",
self.write_attempts
),
details: serde_json::json!({"write_attempts": self.write_attempts}),
});
}
if self.denied_count > 3 {
warnings.push(AnomalyWarning {
severity: "high".to_string(),
category: "denial_spike".to_string(),
message: format!(
"Agent hit {} denials (threshold: 3) — possible probing",
self.denied_count
),
details: serde_json::json!({"denied_count": self.denied_count}),
});
}
if self.ops_accessed.len() > 50 {
warnings.push(AnomalyWarning {
severity: "medium".to_string(),
category: "rapid_access".to_string(),
message: format!(
"Agent accessed {} operations total (threshold: 50)",
self.ops_accessed.len()
),
details: serde_json::json!({"total_accesses": self.ops_accessed.len()}),
});
}
warnings
}
pub fn summary(&self) -> serde_json::Value {
serde_json::json!({
"total_accesses": self.ops_accessed.len(),
"unique_endpoints": self.unique_endpoints.len(),
"write_attempts": self.write_attempts,
"denied_count": self.denied_count,
"anomaly_count": self.check_anomalies().len(),
})
}
}
impl Default for AnomalyDetector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_anomaly_normal_usage() {
let mut detector = AnomalyDetector::new();
for i in 0..5 {
detector.record_access(&format!("op_{}", i), "GET", &[]);
}
let warnings = detector.check_anomalies();
assert!(
warnings.is_empty(),
"Expected no warnings for normal usage, got {:?}",
warnings
);
}
#[test]
fn test_endpoint_spray_detection() {
let mut detector = AnomalyDetector::new();
for i in 0..25 {
detector.record_access(&format!("op_{}", i), "GET", &[]);
}
let warnings = detector.check_anomalies();
assert!(
warnings.iter().any(|w| w.category == "endpoint_spray"),
"Expected endpoint_spray warning, got {:?}",
warnings
);
}
#[test]
fn test_write_spike_detection() {
let mut detector = AnomalyDetector::new();
for i in 0..5 {
detector.record_access(&format!("post_op_{}", i), "POST", &[]);
}
for i in 0..5 {
detector.record_access(&format!("delete_op_{}", i), "DELETE", &[]);
}
let warnings = detector.check_anomalies();
assert!(
warnings.iter().any(|w| w.category == "write_spike"),
"Expected write_spike warning, got {:?}",
warnings
);
}
#[test]
fn test_denial_spike_detection() {
let mut detector = AnomalyDetector::new();
for i in 0..5 {
detector.record_denial(&format!("op_{}", i), "policy_denied");
}
let warnings = detector.check_anomalies();
assert!(
warnings.iter().any(|w| w.category == "denial_spike"),
"Expected denial_spike warning, got {:?}",
warnings
);
}
#[test]
fn test_rapid_access_detection() {
let mut detector = AnomalyDetector::new();
for i in 0..60 {
detector.record_access(&format!("op_{}", i % 10), "GET", &[]);
}
let warnings = detector.check_anomalies();
assert!(
warnings.iter().any(|w| w.category == "rapid_access"),
"Expected rapid_access warning, got {:?}",
warnings
);
}
#[test]
fn test_summary() {
let mut detector = AnomalyDetector::new();
detector.record_access("op_a", "GET", &[]);
detector.record_access("op_a", "POST", &[]);
detector.record_access("op_b", "DELETE", &[]);
detector.record_denial("op_c", "policy_denied");
let summary = detector.summary();
assert_eq!(summary["total_accesses"], 3);
assert_eq!(summary["unique_endpoints"], 2);
assert_eq!(summary["write_attempts"], 2);
assert_eq!(summary["denied_count"], 1);
assert_eq!(summary["anomaly_count"], 0);
}
}