use std::collections::HashSet;
use std::net::Ipv4Addr;
use std::time::Duration;
use flowscope::correlate::{ArpTable, NeighborEvent};
use flowscope::{ArpMessage, MacAddr, Timestamp};
use crate::anomaly::Severity;
use crate::ctx::Ctx;
use crate::error::Result;
pub const DEFAULT_ARP_WARMUP: Duration = Duration::from_secs(5);
const ARP_TABLE_TTL: Duration = Duration::from_secs(20 * 60);
const ARP_TABLE_CAPACITY: usize = 8192;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ArpAnomalyKind {
SpoofSuspected,
BindingChanged,
Gratuitous,
NewBinding,
}
impl ArpAnomalyKind {
#[inline]
pub fn as_str(self) -> &'static str {
match self {
ArpAnomalyKind::SpoofSuspected => "arp_spoof_suspected",
ArpAnomalyKind::BindingChanged => "arp_binding_changed",
ArpAnomalyKind::Gratuitous => "arp_gratuitous",
ArpAnomalyKind::NewBinding => "arp_new_binding",
}
}
#[inline]
pub fn severity(self) -> Severity {
match self {
ArpAnomalyKind::SpoofSuspected | ArpAnomalyKind::BindingChanged => Severity::Warning,
ArpAnomalyKind::Gratuitous | ArpAnomalyKind::NewBinding => Severity::Info,
}
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct ArpAnomaly {
pub kind: ArpAnomalyKind,
pub msg: ArpMessage,
pub ts: Timestamp,
pub prior_mac: Option<MacAddr>,
}
impl ArpAnomaly {
#[inline]
pub fn ip(&self) -> Ipv4Addr {
self.msg.sender_ip
}
#[inline]
pub fn mac(&self) -> MacAddr {
self.msg.sender
}
}
#[derive(Clone)]
pub(crate) struct ArpConfig {
pub(crate) warmup: Duration,
pub(crate) allow: HashSet<(Ipv4Addr, MacAddr)>,
pub(crate) report_gratuitous: bool,
pub(crate) report_new_binding: bool,
}
impl Default for ArpConfig {
fn default() -> Self {
Self {
warmup: DEFAULT_ARP_WARMUP,
allow: HashSet::new(),
report_gratuitous: false,
report_new_binding: false,
}
}
}
pub(crate) type ArpMsgHandler = Box<dyn Fn(&ArpMessage, &mut Ctx<'_>) -> Result<()> + Send>;
pub(crate) type ArpAnomalyHandler = Box<dyn Fn(&ArpAnomaly, &mut Ctx<'_>) -> Result<()> + Send>;
pub(crate) struct ArpWatch {
pub(crate) table: ArpTable,
pub(crate) config: ArpConfig,
pub(crate) msg_handlers: Vec<ArpMsgHandler>,
pub(crate) anomaly_handlers: Vec<ArpAnomalyHandler>,
first_seen: Option<Timestamp>,
}
impl ArpWatch {
pub(crate) fn new(config: ArpConfig) -> Self {
let capacity = std::num::NonZeroUsize::new(ARP_TABLE_CAPACITY).expect("capacity > 0");
Self {
table: ArpTable::new(ARP_TABLE_TTL, capacity),
config,
msg_handlers: Vec::new(),
anomaly_handlers: Vec::new(),
first_seen: None,
}
}
pub(crate) fn observe(&mut self, msg: &ArpMessage, ts: Timestamp) -> Option<ArpAnomaly> {
let first = *self.first_seen.get_or_insert(ts);
let in_warmup = ts.saturating_sub(first) < self.config.warmup;
let allowed = self.config.allow.contains(&(msg.sender_ip, msg.sender));
if msg.is_likely_spoof() && !allowed {
self.table.observe(msg.sender_ip, msg.sender, ts);
return Some(ArpAnomaly {
kind: ArpAnomalyKind::SpoofSuspected,
msg: *msg,
ts,
prior_mac: None,
});
}
let event = self.table.observe(msg.sender_ip, msg.sender, ts);
if allowed {
return None;
}
match event {
NeighborEvent::Changed { prior, .. } if !in_warmup => Some(ArpAnomaly {
kind: ArpAnomalyKind::BindingChanged,
msg: *msg,
ts,
prior_mac: Some(prior),
}),
NeighborEvent::NewBinding { .. } if self.config.report_new_binding && !in_warmup => {
Some(ArpAnomaly {
kind: ArpAnomalyKind::NewBinding,
msg: *msg,
ts,
prior_mac: None,
})
}
_ if self.config.report_gratuitous
&& msg.is_gratuitous()
&& !matches!(event, NeighborEvent::Changed { .. }) =>
{
Some(ArpAnomaly {
kind: ArpAnomalyKind::Gratuitous,
msg: *msg,
ts,
prior_mac: None,
})
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use flowscope::ArpOp;
fn mac(b: u8) -> MacAddr {
MacAddr([b, b, b, b, b, b])
}
fn ip(d: u8) -> Ipv4Addr {
Ipv4Addr::new(10, 0, 0, d)
}
fn ts(secs: u64) -> Timestamp {
Timestamp::from_unix_f64(secs as f64)
}
fn reply(sender_ip: Ipv4Addr, sender: MacAddr, target_ip: Ipv4Addr) -> ArpMessage {
build_arp(ArpOp::Reply, sender, sender_ip, mac(0xff), target_ip)
}
fn build_arp(
oper: ArpOp,
sender: MacAddr,
sender_ip: Ipv4Addr,
target: MacAddr,
target_ip: Ipv4Addr,
) -> ArpMessage {
let op = match oper {
ArpOp::Request => 1u16,
ArpOp::Reply => 2,
ArpOp::RarpRequest => 3,
ArpOp::RarpReply => 4,
ArpOp::Other(v) => v,
_ => 0,
};
let mut p = Vec::with_capacity(28);
p.extend_from_slice(&[0, 1]); p.extend_from_slice(&[0x08, 0x00]); p.push(6); p.push(4); p.extend_from_slice(&op.to_be_bytes());
p.extend_from_slice(sender.as_bytes());
p.extend_from_slice(&sender_ip.octets());
p.extend_from_slice(target.as_bytes());
p.extend_from_slice(&target_ip.octets());
flowscope::arp::parse(&p).expect("valid ARP payload")
}
#[test]
fn spoof_reply_fires_even_during_warmup() {
let mut w = ArpWatch::new(ArpConfig::default());
let m = build_arp(ArpOp::Reply, mac(0xaa), ip(1), mac(0xbb), ip(1));
assert!(m.is_likely_spoof());
let a = w.observe(&m, ts(0)).expect("spoof anomaly");
assert_eq!(a.kind, ArpAnomalyKind::SpoofSuspected);
assert_eq!(a.ip(), ip(1));
}
#[test]
fn binding_change_suppressed_in_warmup_then_fires() {
let mut w = ArpWatch::new(ArpConfig::default());
assert!(w.observe(&reply(ip(2), mac(1), ip(9)), ts(0)).is_none());
assert!(w.observe(&reply(ip(2), mac(2), ip(9)), ts(2)).is_none());
let a = w
.observe(&reply(ip(2), mac(3), ip(9)), ts(10))
.expect("binding change");
assert_eq!(a.kind, ArpAnomalyKind::BindingChanged);
assert_eq!(a.prior_mac, Some(mac(2)));
}
#[test]
fn stable_binding_is_silent() {
let mut w = ArpWatch::new(ArpConfig::default());
assert!(w.observe(&reply(ip(3), mac(1), ip(9)), ts(0)).is_none());
assert!(w.observe(&reply(ip(3), mac(1), ip(9)), ts(100)).is_none());
}
#[test]
fn allowlist_silences_spoof_and_change() {
let mut cfg = ArpConfig::default();
cfg.allow.insert((ip(4), mac(0xaa)));
let mut w = ArpWatch::new(cfg);
let m = build_arp(ArpOp::Reply, mac(0xaa), ip(4), mac(0xbb), ip(4));
assert!(m.is_likely_spoof());
assert!(w.observe(&m, ts(0)).is_none());
}
#[test]
fn new_binding_opt_in_after_warmup() {
let cfg = ArpConfig {
report_new_binding: true,
..Default::default()
};
let mut w = ArpWatch::new(cfg);
assert!(w.observe(&reply(ip(5), mac(1), ip(9)), ts(0)).is_none());
let a = w
.observe(&reply(ip(6), mac(2), ip(9)), ts(10))
.expect("new binding");
assert_eq!(a.kind, ArpAnomalyKind::NewBinding);
}
}