use std::collections::HashSet;
use std::net::IpAddr;
use crate::anomaly::Severity;
use crate::ctx::Ctx;
use crate::protocol::FlowKey;
#[derive(Debug, Clone, Default)]
pub struct IocSet {
ips: HashSet<IpAddr>,
domains: HashSet<String>,
ja4: HashSet<String>,
ja3: HashSet<String>,
}
fn norm_domain(d: &str) -> String {
d.trim_end_matches('.').to_ascii_lowercase()
}
impl IocSet {
pub fn new() -> Self {
Self::default()
}
pub fn ip(mut self, ip: IpAddr) -> Self {
self.ips.insert(ip);
self
}
pub fn ips(mut self, it: impl IntoIterator<Item = IpAddr>) -> Self {
self.ips.extend(it);
self
}
pub fn domain(mut self, d: impl AsRef<str>) -> Self {
self.domains.insert(norm_domain(d.as_ref()));
self
}
pub fn domains<S: AsRef<str>>(mut self, it: impl IntoIterator<Item = S>) -> Self {
self.domains
.extend(it.into_iter().map(|d| norm_domain(d.as_ref())));
self
}
pub fn ja4(mut self, fp: impl Into<String>) -> Self {
self.ja4.insert(fp.into());
self
}
pub fn ja3(mut self, fp: impl Into<String>) -> Self {
self.ja3.insert(fp.into());
self
}
pub fn is_empty(&self) -> bool {
self.ips.is_empty() && self.domains.is_empty() && self.ja4.is_empty() && self.ja3.is_empty()
}
pub fn matches_ip(&self, ip: &IpAddr) -> bool {
self.ips.contains(ip)
}
pub fn matches_domain(&self, name: &str) -> Option<&str> {
if self.domains.is_empty() {
return None;
}
let n = norm_domain(name);
let mut candidate: &str = &n;
loop {
if let Some(hit) = self.domains.get(candidate) {
return Some(hit.as_str());
}
match candidate.find('.') {
Some(pos) => candidate = &candidate[pos + 1..],
None => return None,
}
}
}
pub fn matches_ja4(&self, fp: &str) -> bool {
self.ja4.contains(fp)
}
pub fn matches_ja3(&self, fp: &str) -> bool {
self.ja3.contains(fp)
}
}
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 set.matches_ip(&ip) {
ctx.emit("ioc_match", Severity::Critical)
.with_key(&key)
.with("ioc_kind", "ip")
.with("side", side)
.with("indicator", ip.to_string())
.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(indicator) = set.matches_domain(name)
{
ctx.emit("ioc_match", Severity::Critical)
.with("ioc_kind", "dns")
.with("indicator", indicator.to_string())
.with("observed", name.to_string())
.emit();
}
}
#[cfg(feature = "tls")]
pub(crate) fn check_tls(set: &IocSet, hs: &flowscope::tls::TlsHandshake, ctx: &mut Ctx<'_>) {
if let Some(sni) = hs.sni.as_deref()
&& let Some(indicator) = set.matches_domain(sni)
{
ctx.emit("ioc_match", Severity::Critical)
.with("ioc_kind", "sni")
.with("indicator", indicator.to_string())
.with("observed", sni.to_string())
.emit();
}
if let Some(ja4) = hs.ja4.as_deref()
&& set.matches_ja4(ja4)
{
ctx.emit("ioc_match", Severity::Critical)
.with("ioc_kind", "ja4")
.with("indicator", ja4.to_string())
.emit();
}
if let Some(ja3) = hs.ja3.as_deref()
&& set.matches_ja3(ja3)
{
ctx.emit("ioc_match", Severity::Critical)
.with("ioc_kind", "ja3")
.with("indicator", ja3.to_string())
.emit();
}
}
#[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(indicator) = set.matches_domain(host)
{
ctx.emit("ioc_match", Severity::Critical)
.with("ioc_kind", "http_host")
.with("indicator", indicator.to_string())
.with("observed", host.to_string())
.emit();
}
}
#[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_eq!(set.matches_domain("evil.example"), Some("evil.example"));
assert_eq!(
set.matches_domain("login.EVIL.example."),
Some("evil.example")
);
assert_eq!(set.matches_domain("a.b.evil.example"), Some("evil.example"));
assert!(set.matches_domain("notevil.example").is_none());
assert!(set.matches_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.matches_ip(&ip));
assert!(!set.matches_ip(&Ipv4Addr::LOCALHOST.into()));
assert!(set.matches_ja4("abc"));
assert!(set.matches_ja3("def"));
assert!(!set.matches_ja4("nope"));
}
#[test]
fn empty_set_matches_nothing() {
let set = IocSet::new();
assert!(set.is_empty());
assert!(set.matches_domain("evil.example").is_none());
assert!(!set.matches_ip(&Ipv4Addr::LOCALHOST.into()));
}
}