use std::{net::IpAddr, time::Duration};
use crate::{
DetectorKind, OwnedAnomaly, Timestamp, anomaly_fields::KeyFields, correlate::FirstSeen,
detect::registry::SrcHost, event::Severity,
};
pub struct NewlyObservedDomainDetector {
seen: FirstSeen<String>,
warmup: Duration,
started_at: Option<Timestamp>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct NodScore {
pub src: Option<IpAddr>,
pub domain: String,
}
impl NewlyObservedDomainDetector {
pub fn new() -> Self {
Self {
seen: FirstSeen::new(Duration::from_secs(7 * 24 * 60 * 60), 100_000),
warmup: Duration::from_secs(600),
started_at: None,
}
}
pub fn with_capacity(mut self, ttl: Duration, capacity: usize) -> Self {
self.seen = FirstSeen::new(ttl, capacity);
self
}
pub fn with_warmup(mut self, warmup: Duration) -> Self {
self.warmup = warmup;
self
}
fn registered_domain(qname: &str) -> Option<String> {
let trimmed = qname.trim().trim_end_matches('.');
if trimmed.is_empty() {
return None;
}
let labels: Vec<&str> = trimmed.split('.').collect();
Some(match labels.len() {
0 => return None,
1 | 2 => trimmed.to_ascii_lowercase(),
n => format!(
"{}.{}",
labels[n - 2].to_ascii_lowercase(),
labels[n - 1].to_ascii_lowercase()
),
})
}
pub fn observe(
&mut self,
src: Option<IpAddr>,
qname: &str,
now: Timestamp,
) -> Option<NodScore> {
let started = *self.started_at.get_or_insert(now);
let domain = Self::registered_domain(qname)?;
let is_new = self.seen.observe(domain.clone(), now);
if !is_new || now.saturating_sub(started) < self.warmup {
return None;
}
Some(NodScore { src, domain })
}
pub fn evict_expired(&mut self, now: Timestamp) {
self.seen.evict_expired(now);
}
pub fn tracked(&self) -> usize {
self.seen.len()
}
}
impl Default for NewlyObservedDomainDetector {
fn default() -> Self {
Self::new()
}
}
impl NodScore {
pub fn into_anomaly(self, ts: Timestamp) -> OwnedAnomaly {
let mut a = OwnedAnomaly::new(DetectorKind::NewlyObservedDomain, Severity::Info, ts);
if let Some(src) = self.src {
a = a.with_key(&SrcHost(src));
}
a.with_observation("domain", self.domain)
}
}
impl crate::DetectorScore for NodScore {
fn kind(&self) -> DetectorKind {
DetectorKind::NewlyObservedDomain
}
fn into_anomaly(self, ts: Timestamp) -> OwnedAnomaly {
self.into_anomaly(ts)
}
}
impl<K: KeyFields + Send> crate::detect::Detector<K> for NewlyObservedDomainDetector {
fn kind(&self) -> DetectorKind {
DetectorKind::NewlyObservedDomain
}
fn on_dns_query(&mut self, key: &K, qname: &str, ts: Timestamp, out: &mut Vec<OwnedAnomaly>) {
if let Some(score) = self.observe(key.src_ip(), qname, ts) {
out.push(score.into_anomaly(ts));
}
}
fn tracked(&self) -> usize {
NewlyObservedDomainDetector::tracked(self)
}
fn evict_expired(&mut self, now: Timestamp) {
NewlyObservedDomainDetector::evict_expired(self, now);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn t(secs: u32) -> Timestamp {
Timestamp::new(secs, 0)
}
#[test]
fn first_sight_after_warmup_fires_once() {
let mut d = NewlyObservedDomainDetector::new().with_warmup(Duration::from_secs(600));
assert!(d.observe(None, "warmup.example", t(0)).is_none());
let s = d.observe(None, "evil.com", t(700)).expect("NOD hit");
assert_eq!(s.domain, "evil.com");
assert!(d.observe(None, "sub.evil.com", t(701)).is_none());
}
#[test]
fn warmup_suppresses_baseline_but_records_it() {
let mut d = NewlyObservedDomainDetector::new().with_warmup(Duration::from_secs(600));
assert!(d.observe(None, "google.com", t(0)).is_none());
assert!(d.observe(None, "google.com", t(700)).is_none());
}
#[test]
fn ttl_expiry_makes_domain_new_again() {
let mut d = NewlyObservedDomainDetector::new()
.with_capacity(Duration::from_secs(10), 1024)
.with_warmup(Duration::from_secs(0));
assert!(d.observe(None, "evil.com", t(0)).is_some());
assert!(d.observe(None, "evil.com", t(5)).is_none());
assert!(d.observe(None, "evil.com", t(30)).is_some());
}
#[test]
fn registered_domain_rollup() {
let mut d = NewlyObservedDomainDetector::new().with_warmup(Duration::from_secs(0));
assert!(d.observe(None, "a.b.evil.com", t(0)).is_some());
assert!(d.observe(None, "x.y.evil.com", t(1)).is_none());
}
}