#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnomalyType {
BehaviorSpike,
UnknownExternalConnection,
CredentialLeakAttempt,
ChildProcessExecution,
DataExfiltrationAttempt,
LoopRunaway,
CrossAgentIdentitySpoofing,
}
impl AnomalyType {
pub fn description(&self) -> &'static str {
match self {
Self::BehaviorSpike => "Action rate spike exceeding behavioral baseline",
Self::UnknownExternalConnection => "Connection attempt to host not in network allowlist",
Self::CredentialLeakAttempt => "Credential pattern detected in agent payload",
Self::ChildProcessExecution => "Unauthorized child process execution",
Self::DataExfiltrationAttempt => "PII detected in payload to external API",
Self::LoopRunaway => "Repeated identical tool invocations in short window",
Self::CrossAgentIdentitySpoofing => "Agent presenting another agent's credentials",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnomalyResponse {
Pause,
Block,
Alert,
Quarantine,
}
impl AnomalyResponse {
pub fn default_for(anomaly_type: AnomalyType) -> Self {
match anomaly_type {
AnomalyType::BehaviorSpike => Self::Pause,
AnomalyType::UnknownExternalConnection => Self::Block,
AnomalyType::CredentialLeakAttempt => Self::Alert,
AnomalyType::ChildProcessExecution => Self::Block,
AnomalyType::DataExfiltrationAttempt => Self::Block,
AnomalyType::LoopRunaway => Self::Pause,
AnomalyType::CrossAgentIdentitySpoofing => Self::Alert,
}
}
}
#[derive(Debug, Clone)]
pub struct AnomalyEvent {
pub anomaly_type: AnomalyType,
pub response: AnomalyResponse,
pub agent_id: aa_core::AgentId,
pub description: String,
pub detected_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub struct AnomalyConfig {
pub baseline_window_secs: u64,
pub spike_stddev_multiplier: f64,
pub loop_threshold: u32,
pub loop_window_secs: u64,
pub credential_leak_threshold: u32,
}
impl Default for AnomalyConfig {
fn default() -> Self {
Self {
baseline_window_secs: 3600,
spike_stddev_multiplier: 3.0,
loop_threshold: 50,
loop_window_secs: 300,
credential_leak_threshold: 3,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anomaly_type_variants_are_distinct() {
let variants = [
AnomalyType::BehaviorSpike,
AnomalyType::UnknownExternalConnection,
AnomalyType::CredentialLeakAttempt,
AnomalyType::ChildProcessExecution,
AnomalyType::DataExfiltrationAttempt,
AnomalyType::LoopRunaway,
AnomalyType::CrossAgentIdentitySpoofing,
];
for (i, a) in variants.iter().enumerate() {
for (j, b) in variants.iter().enumerate() {
if i == j {
assert_eq!(a, b);
} else {
assert_ne!(a, b);
}
}
}
}
#[test]
fn anomaly_type_has_seven_variants() {
let variants = [
AnomalyType::BehaviorSpike,
AnomalyType::UnknownExternalConnection,
AnomalyType::CredentialLeakAttempt,
AnomalyType::ChildProcessExecution,
AnomalyType::DataExfiltrationAttempt,
AnomalyType::LoopRunaway,
AnomalyType::CrossAgentIdentitySpoofing,
];
assert_eq!(variants.len(), 7);
}
#[test]
fn anomaly_type_description_is_non_empty() {
let variants = [
AnomalyType::BehaviorSpike,
AnomalyType::UnknownExternalConnection,
AnomalyType::CredentialLeakAttempt,
AnomalyType::ChildProcessExecution,
AnomalyType::DataExfiltrationAttempt,
AnomalyType::LoopRunaway,
AnomalyType::CrossAgentIdentitySpoofing,
];
for v in &variants {
assert!(!v.description().is_empty(), "{:?} has empty description", v);
}
}
#[test]
fn anomaly_response_variants_are_distinct() {
let variants = [
AnomalyResponse::Pause,
AnomalyResponse::Block,
AnomalyResponse::Alert,
AnomalyResponse::Quarantine,
];
for (i, a) in variants.iter().enumerate() {
for (j, b) in variants.iter().enumerate() {
if i == j {
assert_eq!(a, b);
} else {
assert_ne!(a, b);
}
}
}
}
#[test]
fn default_response_matches_epic_table() {
assert_eq!(
AnomalyResponse::default_for(AnomalyType::BehaviorSpike),
AnomalyResponse::Pause,
);
assert_eq!(
AnomalyResponse::default_for(AnomalyType::UnknownExternalConnection),
AnomalyResponse::Block,
);
assert_eq!(
AnomalyResponse::default_for(AnomalyType::CredentialLeakAttempt),
AnomalyResponse::Alert,
);
assert_eq!(
AnomalyResponse::default_for(AnomalyType::ChildProcessExecution),
AnomalyResponse::Block,
);
assert_eq!(
AnomalyResponse::default_for(AnomalyType::DataExfiltrationAttempt),
AnomalyResponse::Block,
);
assert_eq!(
AnomalyResponse::default_for(AnomalyType::LoopRunaway),
AnomalyResponse::Pause,
);
assert_eq!(
AnomalyResponse::default_for(AnomalyType::CrossAgentIdentitySpoofing),
AnomalyResponse::Alert,
);
}
#[test]
fn anomaly_event_stores_fields() {
use aa_core::AgentId;
let event = AnomalyEvent {
anomaly_type: AnomalyType::BehaviorSpike,
response: AnomalyResponse::Pause,
agent_id: AgentId::from_bytes([1u8; 16]),
description: "rate exceeded baseline".to_string(),
detected_at: chrono::Utc::now(),
};
assert_eq!(event.anomaly_type, AnomalyType::BehaviorSpike);
assert_eq!(event.response, AnomalyResponse::Pause);
assert_eq!(event.description, "rate exceeded baseline");
}
}