mrapids 0.1.31

Your OpenAPI, but executable
Documentation
// Anomaly detection for agent behavior patterns
//
// Tracks session-level metrics (endpoint spray, write spikes, denial spikes,
// rapid access) and surfaces warnings when thresholds are exceeded.

use serde::{Deserialize, Serialize};
use std::collections::HashSet;

/// Tracks agent behavior patterns and detects anomalies
#[derive(Debug, Clone)]
pub struct AnomalyDetector {
    /// Baseline: typical operations accessed per session
    typical_ops: Vec<String>,
    /// Baseline: typical tags accessed
    typical_tags: Vec<String>,
    /// Operations accessed this session (may contain duplicates for total count)
    ops_accessed: Vec<String>,
    /// Unique endpoints hit this session
    unique_endpoints: HashSet<String>,
    /// Count of write attempts (POST, PUT, PATCH, DELETE)
    write_attempts: u32,
    /// Count of denied requests
    denied_count: u32,
    /// Whether agent has attempted unusual tag access
    unusual_tag_access: bool,
}

/// A single anomaly warning produced by the detector
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnomalyWarning {
    /// Severity level: "high", "medium", "low"
    pub severity: String,
    /// Category: "scope_creep", "write_spike", "denial_spike", "endpoint_spray", "rapid_access"
    pub category: String,
    /// Human-readable description
    pub message: String,
    /// Structured details for logging
    pub details: serde_json::Value,
}

impl AnomalyDetector {
    /// Create a new detector with all counters zeroed.
    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,
        }
    }

    /// Reset all counters (call on session end to prevent cross-session contamination).
    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;
    }

    /// Record that an operation was accessed.
    pub fn record_access(&mut self, operation_id: &str, method: &str, _tags: &[String]) {
        // Cap ops_accessed to prevent unbounded memory growth
        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;
        }
    }

    /// Record a denied request (policy block, budget exceeded, etc.).
    pub fn record_denial(&mut self, _operation_id: &str, _reason: &str) {
        self.denied_count += 1;
    }

    /// Check current session metrics against thresholds and return any warnings.
    pub fn check_anomalies(&self) -> Vec<AnomalyWarning> {
        let mut warnings = Vec::new();

        // 1. Endpoint spray: accessing >20 unique endpoints in one session
        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()}),
            });
        }

        // 2. Write spike: >5 write attempts in a session
        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}),
            });
        }

        // 3. Denial spike: >3 denied requests (possible probing)
        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}),
            });
        }

        // 4. Rapid access: >50 total operations in a session
        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
    }

    /// Get a JSON summary of current session metrics.
    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()
    }
}

// =============================================================================
// Tests
// =============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_no_anomaly_normal_usage() {
        let mut detector = AnomalyDetector::new();
        // 5 GETs should not trigger any warnings
        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();
        // 25 unique endpoints should trigger endpoint_spray
        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();
        // 10 POSTs and DELETEs should trigger write_spike
        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();
        // 5 denials should trigger denial_spike
        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();
        // 60 total operations (can repeat endpoints) should trigger rapid_access
        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);
    }
}