use std::{net::IpAddr, time::Duration};
use crate::{
DetectorKind, OwnedAnomaly, Timestamp,
anomaly_fields::KeyFields,
correlate::EwmaVar,
detect::registry::SrcHost,
event::{FlowStats, Severity},
extractor::L4Proto,
history::HistoryString,
};
pub struct DataExfilDetector {
baseline: EwmaVar<IpAddr>,
n_sigma: f64,
min_samples: u64,
min_bytes: u64,
counts: crate::correlate::KeyIndexed<IpAddr, u64>,
cooldown: Duration,
last_emitted: crate::correlate::KeyIndexed<IpAddr, ()>,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct ExfilScore {
pub src: IpAddr,
pub bytes: u64,
pub mean: f64,
pub sigma: f64,
pub n: u64,
}
impl DataExfilDetector {
pub fn new() -> Self {
Self {
baseline: EwmaVar::new(0.1),
n_sigma: 3.0,
min_samples: 10,
min_bytes: 1024 * 1024,
counts: crate::correlate::KeyIndexed::new(Duration::from_secs(24 * 3600), 10_000),
cooldown: Duration::from_secs(300),
last_emitted: crate::correlate::KeyIndexed::new(Duration::from_secs(300), 10_000),
}
}
pub fn with_alpha(mut self, alpha: f64) -> Self {
self.baseline = EwmaVar::new(alpha);
self
}
pub fn with_n_sigma(mut self, n: f64) -> Self {
self.n_sigma = n;
self
}
pub fn with_min_samples(mut self, n: u64) -> Self {
self.min_samples = n;
self
}
pub fn with_min_bytes(mut self, bytes: u64) -> Self {
self.min_bytes = bytes;
self
}
pub fn with_cooldown(mut self, cooldown: Duration) -> Self {
self.cooldown = cooldown;
self.last_emitted = crate::correlate::KeyIndexed::new(cooldown, 10_000);
self
}
pub fn observe(&mut self, src: IpAddr, bytes: u64, now: Timestamp) -> Option<ExfilScore> {
let n = self.counts.get(&src, now).copied().unwrap_or(0);
let baseline = self.baseline.get(&src);
let score = if n >= self.min_samples
&& bytes >= self.min_bytes
&& let Some(b) = baseline
{
let sigma = b.std();
let z = if sigma > f64::EPSILON {
(bytes as f64 - b.mean) / sigma
} else {
0.0
};
if z >= self.n_sigma && self.last_emitted.get(&src, now).is_none() {
self.last_emitted.insert(src, (), now);
Some(ExfilScore {
src,
bytes,
mean: b.mean,
sigma,
n,
})
} else {
None
}
} else {
None
};
self.baseline.record_at(src, bytes as f64, now);
self.counts.insert(src, n + 1, now);
score
}
pub fn evict_expired(&mut self, now: Timestamp) {
self.baseline
.evict_stale(now, Duration::from_secs(24 * 3600));
self.counts.evict_expired(now);
self.last_emitted.evict_expired(now);
}
pub fn tracked(&self) -> usize {
self.baseline.len()
}
}
impl Default for DataExfilDetector {
fn default() -> Self {
Self::new()
}
}
impl ExfilScore {
pub fn into_anomaly(self, ts: Timestamp) -> OwnedAnomaly {
OwnedAnomaly::new(DetectorKind::DataExfil, Severity::Warning, ts)
.with_key(&SrcHost(self.src))
.with_metric("bytes", self.bytes as f64)
.with_metric("mean", self.mean)
.with_metric("sigma", self.sigma)
.with_metric("n", self.n as f64)
}
}
impl crate::DetectorScore for ExfilScore {
fn kind(&self) -> DetectorKind {
DetectorKind::DataExfil
}
fn into_anomaly(self, ts: Timestamp) -> OwnedAnomaly {
self.into_anomaly(ts)
}
}
impl<K: KeyFields + Send> crate::detect::Detector<K> for DataExfilDetector {
fn kind(&self) -> DetectorKind {
DetectorKind::DataExfil
}
fn on_flow_end(
&mut self,
key: &K,
stats: &FlowStats,
_history: &HistoryString,
_l4: Option<L4Proto>,
ts: Timestamp,
out: &mut Vec<OwnedAnomaly>,
) {
let Some(src) = key.src_ip() else {
return;
};
if let Some(score) = self.observe(src, stats.bytes_initiator, ts) {
out.push(score.into_anomaly(ts));
}
}
fn tracked(&self) -> usize {
DataExfilDetector::tracked(self)
}
fn evict_expired(&mut self, now: Timestamp) {
DataExfilDetector::evict_expired(self, now);
}
}
#[cfg(test)]
mod tests {
use std::net::Ipv4Addr;
use super::*;
fn ip() -> IpAddr {
IpAddr::from(Ipv4Addr::new(10, 0, 0, 1))
}
fn t(secs: u32) -> Timestamp {
Timestamp::new(secs, 0)
}
#[test]
fn spike_after_stable_baseline_fires() {
let mut d = DataExfilDetector::new()
.with_min_samples(10)
.with_min_bytes(1_000_000);
for i in 0..15u32 {
let bytes = 2_000_000 + if i % 2 == 0 { 10_000 } else { 0 };
assert!(
d.observe(ip(), bytes, t(i)).is_none(),
"baseline flow {i} shouldn't fire"
);
}
let s = d.observe(ip(), 50_000_000, t(100)).expect("exfil fires");
assert_eq!(s.bytes, 50_000_000);
assert!(s.n >= 10);
}
#[test]
fn spike_below_min_bytes_does_not_fire() {
let mut d = DataExfilDetector::new()
.with_min_samples(5)
.with_min_bytes(1_000_000);
for i in 0..10u32 {
d.observe(ip(), 100 + (i % 2) as u64, t(i));
}
assert!(d.observe(ip(), 10_000, t(50)).is_none());
}
#[test]
fn warmup_suppresses_before_min_samples() {
let mut d = DataExfilDetector::new().with_min_samples(10);
assert!(d.observe(ip(), 100_000_000, t(0)).is_none());
}
#[test]
fn steady_high_volume_source_does_not_trip() {
let mut d = DataExfilDetector::new()
.with_min_samples(10)
.with_min_bytes(1_000_000);
for i in 0..40u32 {
let bytes = 10_000_000 + (i as u64 % 3) * 1000;
assert!(
d.observe(ip(), bytes, t(i)).is_none(),
"steady high volume shouldn't alarm at flow {i}"
);
}
}
#[test]
fn cooldown_suppresses_repeat_spikes() {
let mut d = DataExfilDetector::new()
.with_min_samples(5)
.with_min_bytes(1_000_000)
.with_cooldown(Duration::from_secs(300));
for i in 0..10u32 {
d.observe(ip(), 2_000_000, t(i));
}
let mut alerts = 0;
if d.observe(ip(), 50_000_000, t(100)).is_some() {
alerts += 1;
}
if d.observe(ip(), 50_000_000, t(101)).is_some() {
alerts += 1;
}
assert_eq!(alerts, 1, "cooldown suppresses the second spike");
}
}