use std::net::IpAddr;
use crate::anomaly::Severity;
use crate::ctx::Ctx;
use crate::protocol::FlowKey;
pub use flowscope::detect::ioc::{IocKind, IocMatch, IocSet};
pub trait IocSetExt: Sized {
fn ip(self, ip: IpAddr) -> Self;
fn ips(self, it: impl IntoIterator<Item = IpAddr>) -> Self;
fn domain(self, d: impl AsRef<str>) -> Self;
fn domains<S: AsRef<str>>(self, it: impl IntoIterator<Item = S>) -> Self;
fn ja4(self, fp: impl AsRef<str>) -> Self;
fn ja3(self, fp: impl AsRef<str>) -> Self;
}
impl IocSetExt for IocSet {
fn ip(mut self, ip: IpAddr) -> Self {
let kind = match ip {
IpAddr::V4(_) => IocKind::Ipv4,
IpAddr::V6(_) => IocKind::Ipv6,
};
self.insert(kind, &ip.to_string(), None, None);
self
}
fn ips(mut self, it: impl IntoIterator<Item = IpAddr>) -> Self {
for ip in it {
self = self.ip(ip);
}
self
}
fn domain(mut self, d: impl AsRef<str>) -> Self {
self.insert(IocKind::Domain, d.as_ref(), None, None);
self
}
fn domains<S: AsRef<str>>(mut self, it: impl IntoIterator<Item = S>) -> Self {
for d in it {
self.insert(IocKind::Domain, d.as_ref(), None, None);
}
self
}
fn ja4(mut self, fp: impl AsRef<str>) -> Self {
self.insert(IocKind::Ja4, fp.as_ref(), None, None);
self
}
fn ja3(mut self, fp: impl AsRef<str>) -> Self {
self.insert(IocKind::Ja3, fp.as_ref(), None, None);
self
}
}
#[cfg(any(feature = "dns", feature = "tls", feature = "http"))]
fn emit_match(ctx: &mut Ctx<'_>, key: Option<&FlowKey>, m: &IocMatch, observed: Option<&str>) {
let mut w = ctx.emit("ioc_match", Severity::Critical);
if let Some(k) = key {
w = w.with_key(k);
}
w = w.with("ioc_kind", m.kind.as_str());
w = w.with("indicator", m.value.clone());
if let Some(obs) = observed
&& obs != m.value
{
w = w.with("observed", obs.to_string());
}
if let Some(src) = &m.source {
w = w.with("source", src.clone());
}
if let Some(rep) = m.reputation {
w = w.with_metric("reputation", rep as f64);
}
w.emit();
}
pub(crate) fn check_flow_ip(set: &IocSet, key: FlowKey, ctx: &mut Ctx<'_>) {
for (ip, side) in [(key.b.ip(), "dst"), (key.a.ip(), "src")] {
if let Some(m) = set.contains_ip(ip) {
let mut w = ctx
.emit("ioc_match", Severity::Critical)
.with_key(&key)
.with("ioc_kind", m.kind.as_str())
.with("side", side)
.with("indicator", m.value.clone());
if let Some(src) = &m.source {
w = w.with("source", src.clone());
}
if let Some(rep) = m.reputation {
w = w.with_metric("reputation", rep as f64);
}
w.emit();
}
}
}
#[cfg(feature = "dns")]
pub(crate) fn check_dns(set: &IocSet, msg: &flowscope::dns::DnsMessage, ctx: &mut Ctx<'_>) {
let flowscope::dns::DnsMessage::Query(q) = msg else {
return;
};
if let Some(name) = q.questions.first().map(|x| x.name.as_str())
&& let Some(m) = set.contains_domain(name)
{
emit_match(ctx, None, &m, Some(name));
}
}
#[cfg(feature = "tls")]
pub(crate) fn check_tls(set: &IocSet, hs: &flowscope::tls::TlsHandshake, ctx: &mut Ctx<'_>) {
let observed = hs.sni.as_deref();
for m in set.check_tls(hs) {
let obs = if m.kind == IocKind::Domain {
observed
} else {
None
};
emit_match(ctx, None, &m, obs);
}
}
#[cfg(feature = "http")]
pub(crate) fn check_http(set: &IocSet, msg: &flowscope::http::HttpMessage, ctx: &mut Ctx<'_>) {
if let flowscope::http::HttpMessage::Request(req) = msg
&& let Some(host) = req.host()
&& let Some(m) = set.contains_domain(host)
{
emit_match(ctx, None, &m, Some(host));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
#[test]
fn domain_match_is_subdomain_aware_and_case_insensitive() {
let set = IocSet::new().domain("Evil.Example");
assert!(set.contains_domain("evil.example").is_some());
assert!(set.contains_domain("login.EVIL.example.").is_some());
assert!(set.contains_domain("a.b.evil.example").is_some());
assert!(set.contains_domain("notevil.example").is_none());
assert!(set.contains_domain("example").is_none());
}
#[test]
fn ip_and_fp_membership() {
let ip: IpAddr = Ipv4Addr::new(203, 0, 113, 7).into();
let set = IocSet::new().ip(ip).ja4("abc").ja3("def");
assert!(set.contains_ip(ip).is_some());
assert!(set.contains_ip(Ipv4Addr::LOCALHOST.into()).is_none());
assert!(set.contains(IocKind::Ja4, "abc").is_some());
assert!(set.contains(IocKind::Ja3, "def").is_some());
assert!(set.contains(IocKind::Ja4, "nope").is_none());
}
#[test]
fn empty_set_matches_nothing() {
let set = IocSet::new();
assert!(set.is_empty());
assert!(set.contains_domain("evil.example").is_none());
assert!(set.contains_ip(Ipv4Addr::LOCALHOST.into()).is_none());
}
}