use std::collections::HashSet;
use std::net::Ipv6Addr;
use std::time::Duration;
use flowscope::correlate::{NeighborEvent, NeighborTable};
use flowscope::{MacAddr, NdpMessage, Timestamp};
use crate::anomaly::Severity;
use crate::ctx::Ctx;
use crate::error::Result;
type NdpTable = NeighborTable<Ipv6Addr, MacAddr>;
pub const DEFAULT_NDP_WARMUP: Duration = Duration::from_secs(5);
const NDP_TABLE_TTL: Duration = Duration::from_secs(20 * 60);
const NDP_TABLE_CAPACITY: usize = 8192;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NdpAnomalyKind {
SpoofSuspected,
BindingChanged,
Unsolicited,
NewBinding,
}
impl NdpAnomalyKind {
#[inline]
pub fn as_str(self) -> &'static str {
match self {
NdpAnomalyKind::SpoofSuspected => "ndp_spoof_suspected",
NdpAnomalyKind::BindingChanged => "ndp_binding_changed",
NdpAnomalyKind::Unsolicited => "ndp_unsolicited",
NdpAnomalyKind::NewBinding => "ndp_new_binding",
}
}
#[inline]
pub fn severity(self) -> Severity {
match self {
NdpAnomalyKind::SpoofSuspected | NdpAnomalyKind::BindingChanged => Severity::Warning,
NdpAnomalyKind::Unsolicited | NdpAnomalyKind::NewBinding => Severity::Info,
}
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct NdpAnomaly {
pub kind: NdpAnomalyKind,
pub msg: NdpMessage,
pub ts: Timestamp,
pub prior_mac: Option<MacAddr>,
}
impl NdpAnomaly {
#[inline]
pub fn ip(&self) -> Ipv6Addr {
self.msg.target
}
#[inline]
pub fn mac(&self) -> Option<MacAddr> {
self.msg.lladdr
}
}
#[derive(Clone)]
pub(crate) struct NdpConfig {
pub(crate) warmup: Duration,
pub(crate) allow: HashSet<(Ipv6Addr, MacAddr)>,
pub(crate) report_unsolicited: bool,
pub(crate) report_new_binding: bool,
}
impl Default for NdpConfig {
fn default() -> Self {
Self {
warmup: DEFAULT_NDP_WARMUP,
allow: HashSet::new(),
report_unsolicited: false,
report_new_binding: false,
}
}
}
pub(crate) type NdpMsgHandler = Box<dyn Fn(&NdpMessage, &mut Ctx<'_>) -> Result<()> + Send>;
pub(crate) type NdpAnomalyHandler = Box<dyn Fn(&NdpAnomaly, &mut Ctx<'_>) -> Result<()> + Send>;
pub(crate) struct NdpWatch {
pub(crate) table: NdpTable,
pub(crate) config: NdpConfig,
pub(crate) msg_handlers: Vec<NdpMsgHandler>,
pub(crate) anomaly_handlers: Vec<NdpAnomalyHandler>,
first_seen: Option<Timestamp>,
}
impl NdpWatch {
pub(crate) fn new(config: NdpConfig) -> Self {
let capacity = std::num::NonZeroUsize::new(NDP_TABLE_CAPACITY).expect("capacity > 0");
Self {
table: NdpTable::new(NDP_TABLE_TTL, capacity),
config,
msg_handlers: Vec::new(),
anomaly_handlers: Vec::new(),
first_seen: None,
}
}
pub(crate) fn observe(&mut self, msg: &NdpMessage, ts: Timestamp) -> Option<NdpAnomaly> {
let first = *self.first_seen.get_or_insert(ts);
let in_warmup = ts.saturating_sub(first) < self.config.warmup;
let mac = msg.lladdr?;
let allowed = self.config.allow.contains(&(msg.target, mac));
if msg.is_likely_spoof() && !allowed {
self.table.observe(msg.target, mac, ts);
return Some(NdpAnomaly {
kind: NdpAnomalyKind::SpoofSuspected,
msg: *msg,
ts,
prior_mac: None,
});
}
let event = self.table.observe(msg.target, mac, ts);
if allowed {
return None;
}
match event {
NeighborEvent::Changed { prior, .. } if !in_warmup => Some(NdpAnomaly {
kind: NdpAnomalyKind::BindingChanged,
msg: *msg,
ts,
prior_mac: Some(prior),
}),
NeighborEvent::NewBinding { .. } if self.config.report_new_binding && !in_warmup => {
Some(NdpAnomaly {
kind: NdpAnomalyKind::NewBinding,
msg: *msg,
ts,
prior_mac: None,
})
}
_ if self.config.report_unsolicited
&& msg.is_unsolicited_override()
&& !matches!(event, NeighborEvent::Changed { .. }) =>
{
Some(NdpAnomaly {
kind: NdpAnomalyKind::Unsolicited,
msg: *msg,
ts,
prior_mac: None,
})
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use flowscope::NdpKind;
fn mac(b: u8) -> MacAddr {
MacAddr([b, b, b, b, b, b])
}
fn ip(d: u16) -> Ipv6Addr {
Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, d)
}
fn ts(secs: u64) -> Timestamp {
Timestamp::from_unix_f64(secs as f64)
}
fn na(target: Ipv6Addr, lladdr: MacAddr, solicited: bool, override_: bool) -> NdpMessage {
let mut m = Vec::with_capacity(8 + 16 + 8);
m.push(136);
m.push(0);
m.extend_from_slice(&[0, 0]); let mut flags = 0u8;
if override_ {
flags |= 0x20; }
if solicited {
flags |= 0x40; }
m.push(flags);
m.extend_from_slice(&[0, 0, 0]); m.extend_from_slice(&target.octets());
m.extend_from_slice(&[2, 1]); m.extend_from_slice(lladdr.as_bytes());
flowscope::ndp::parse_icmpv6(&m).expect("valid NA")
}
#[test]
fn unsolicited_override_na_fires_spoof_even_during_warmup() {
let mut w = NdpWatch::new(NdpConfig::default());
let m = na(ip(1), mac(0xbb), false, true); assert_eq!(m.kind, NdpKind::Advertisement);
assert!(m.is_likely_spoof());
let a = w.observe(&m, ts(0)).expect("spoof anomaly");
assert_eq!(a.kind, NdpAnomalyKind::SpoofSuspected);
assert_eq!(a.ip(), ip(1));
assert_eq!(a.mac(), Some(mac(0xbb)));
}
#[test]
fn binding_change_suppressed_in_warmup_then_fires() {
let mut w = NdpWatch::new(NdpConfig::default());
assert!(w.observe(&na(ip(2), mac(1), true, false), ts(0)).is_none());
assert!(w.observe(&na(ip(2), mac(2), true, false), ts(2)).is_none());
let a = w
.observe(&na(ip(2), mac(3), true, false), ts(10))
.expect("binding change");
assert_eq!(a.kind, NdpAnomalyKind::BindingChanged);
assert_eq!(a.prior_mac, Some(mac(2)));
}
#[test]
fn stable_binding_is_silent() {
let mut w = NdpWatch::new(NdpConfig::default());
assert!(w.observe(&na(ip(3), mac(1), true, false), ts(0)).is_none());
assert!(
w.observe(&na(ip(3), mac(1), true, false), ts(100))
.is_none()
);
}
#[test]
fn allowlist_silences_spoof() {
let mut cfg = NdpConfig::default();
cfg.allow.insert((ip(4), mac(0xbb)));
let mut w = NdpWatch::new(cfg);
let m = na(ip(4), mac(0xbb), false, true);
assert!(m.is_likely_spoof());
assert!(w.observe(&m, ts(0)).is_none());
}
}