#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct BeaconingThresholds {
pub min_occurrences: usize,
pub max_coefficient_of_variation: f64,
pub min_interval_seconds: f64,
}
pub const DEFAULT_BEACONING: BeaconingThresholds = BeaconingThresholds {
min_occurrences: 4,
max_coefficient_of_variation: 0.25,
min_interval_seconds: 30.0,
};
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct BeaconAssessment {
pub occurrences: usize,
pub mean_interval_seconds: f64,
pub coefficient_of_variation: f64,
}
#[must_use]
pub fn assess_periodicity(
sorted_timestamps_ns: &[i64],
thresholds: &BeaconingThresholds,
) -> Option<BeaconAssessment> {
let occurrences = sorted_timestamps_ns.len();
if occurrences < thresholds.min_occurrences {
return None;
}
let intervals: Vec<f64> = sorted_timestamps_ns
.windows(2)
.map(|w| (w[1] - w[0]) as f64 / 1_000_000_000.0)
.collect();
if intervals.len() < 2 {
return None;
}
let n = intervals.len() as f64;
let mean = intervals.iter().sum::<f64>() / n;
if mean < thresholds.min_interval_seconds {
return None;
}
let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
let coefficient_of_variation = variance.sqrt() / mean;
if coefficient_of_variation > thresholds.max_coefficient_of_variation {
return None;
}
Some(BeaconAssessment {
occurrences,
mean_interval_seconds: mean,
coefficient_of_variation,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn ts_from_intervals(start_s: i64, gaps_s: &[i64]) -> Vec<i64> {
let mut out = vec![start_s * 1_000_000_000];
let mut cur = start_s;
for g in gaps_s {
cur += g;
out.push(cur * 1_000_000_000);
}
out
}
#[test]
fn perfectly_regular_series_is_beaconing() {
let ts = ts_from_intervals(1_000_000, &[60, 60, 60, 60]);
let a = assess_periodicity(&ts, &DEFAULT_BEACONING).expect("should detect beacon");
assert_eq!(a.occurrences, 5);
assert!((a.mean_interval_seconds - 60.0).abs() < 1e-9);
assert!(a.coefficient_of_variation < 1e-9);
}
#[test]
fn low_jitter_series_within_tolerance_is_beaconing() {
let ts = ts_from_intervals(2_000_000, &[300, 305, 295, 302, 298]);
let a = assess_periodicity(&ts, &DEFAULT_BEACONING).expect("low jitter is a beacon");
assert!(a.coefficient_of_variation < 0.25);
assert_eq!(a.occurrences, 6);
}
#[test]
fn irregular_human_traffic_is_not_beaconing() {
let ts = ts_from_intervals(3_000_000, &[5, 3600, 40, 900, 7200]);
assert!(assess_periodicity(&ts, &DEFAULT_BEACONING).is_none());
}
#[test]
fn too_few_occurrences_is_not_beaconing() {
let ts = ts_from_intervals(4_000_000, &[60, 60]);
assert!(assess_periodicity(&ts, &DEFAULT_BEACONING).is_none());
}
#[test]
fn sub_min_interval_burst_is_not_beaconing() {
let ts = ts_from_intervals(5_000_000, &[5, 5, 5, 5, 5]);
assert!(assess_periodicity(&ts, &DEFAULT_BEACONING).is_none());
}
#[test]
fn empty_and_single_are_none_not_panic() {
assert!(assess_periodicity(&[], &DEFAULT_BEACONING).is_none());
assert!(assess_periodicity(&[42], &DEFAULT_BEACONING).is_none());
}
}