bitflags::bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct FlowRisk: u64 {
const KNOWN_PROTO_NONSTD_PORT = 1 << 0;
const PORT_PROTO_MISMATCH = 1 << 1;
const TLS_SELF_SIGNED = 1 << 2;
const TLS_EXPIRED_CERT = 1 << 3;
const TLS_WEAK_CIPHER = 1 << 4;
const TLS_OBSOLETE_VERSION = 1 << 5;
const SNI_DNS_MISMATCH = 1 << 6;
const DGA_DOMAIN = 1 << 7;
const CLEARTEXT_CREDENTIALS = 1 << 8;
const OBSOLETE_PROTOCOL = 1 << 9;
const SUSPICIOUS_JA4 = 1 << 10;
const BINARY_TRANSFER = 1 << 11;
const PUNYCODE_IDN = 1 << 12;
const MALFORMED_PACKET = 1 << 13;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum RiskSeverity {
Low,
Medium,
High,
Severe,
}
impl RiskSeverity {
pub fn as_str(&self) -> &'static str {
match self {
RiskSeverity::Low => "low",
RiskSeverity::Medium => "medium",
RiskSeverity::High => "high",
RiskSeverity::Severe => "severe",
}
}
}
impl FlowRisk {
fn attrs(self) -> (&'static str, RiskSeverity, u16) {
use RiskSeverity::*;
match self {
FlowRisk::KNOWN_PROTO_NONSTD_PORT => ("known_proto_nonstd_port", Low, 10),
FlowRisk::PORT_PROTO_MISMATCH => ("port_proto_mismatch", Medium, 50),
FlowRisk::TLS_SELF_SIGNED => ("tls_self_signed", Medium, 50),
FlowRisk::TLS_EXPIRED_CERT => ("tls_expired_cert", Medium, 50),
FlowRisk::TLS_WEAK_CIPHER => ("tls_weak_cipher", Medium, 60),
FlowRisk::TLS_OBSOLETE_VERSION => ("tls_obsolete_version", Medium, 50),
FlowRisk::SNI_DNS_MISMATCH => ("sni_dns_mismatch", Medium, 50),
FlowRisk::DGA_DOMAIN => ("dga_domain", High, 100),
FlowRisk::CLEARTEXT_CREDENTIALS => ("cleartext_credentials", High, 100),
FlowRisk::OBSOLETE_PROTOCOL => ("obsolete_protocol", Medium, 50),
FlowRisk::SUSPICIOUS_JA4 => ("suspicious_ja4", High, 100),
FlowRisk::BINARY_TRANSFER => ("binary_transfer", Medium, 60),
FlowRisk::PUNYCODE_IDN => ("punycode_idn", Low, 10),
FlowRisk::MALFORMED_PACKET => ("malformed_packet", Low, 10),
_ => ("unknown", Low, 0),
}
}
pub fn slug(self) -> &'static str {
self.attrs().0
}
pub fn severity(self) -> RiskSeverity {
self.attrs().1
}
pub fn score(self) -> u16 {
self.iter()
.map(|f| f.attrs().2)
.fold(0u16, u16::saturating_add)
}
pub fn max_severity(self) -> Option<RiskSeverity> {
self.iter().map(|f| f.attrs().1).max()
}
pub fn as_slugs(self) -> impl Iterator<Item = &'static str> {
self.iter().map(|f| f.attrs().0)
}
pub fn count(self) -> u32 {
self.bits().count_ones()
}
}
impl FlowRisk {
#[cfg(feature = "tls")]
pub fn from_tls(hs: &crate::tls::TlsHandshake) -> FlowRisk {
use crate::tls::TlsVersion;
let mut r = FlowRisk::empty();
if let Some(v) = hs.version {
let obsolete = matches!(
v,
TlsVersion::Ssl3_0 | TlsVersion::Tls1_0 | TlsVersion::Tls1_1
) || matches!(v, TlsVersion::Other(raw) if raw < 0x0303);
if obsolete {
r |= FlowRisk::TLS_OBSOLETE_VERSION;
}
}
if let Some(cs) = hs.cipher_suite
&& is_weak_cipher(cs)
{
r |= FlowRisk::TLS_WEAK_CIPHER;
}
r
}
pub fn from_dns(qname: &str, scorer: &crate::detect::patterns::DgaScorer) -> FlowRisk {
let mut r = FlowRisk::empty();
let trimmed = qname.trim().trim_end_matches('.');
if trimmed.is_empty() {
return r;
}
let lower = trimmed.to_ascii_lowercase();
if lower.split('.').any(|l| l.starts_with("xn--")) {
r |= FlowRisk::PUNYCODE_IDN;
}
let labels: Vec<&str> = lower.split('.').collect();
let sld = match labels.len() {
0 => return r,
1 => labels[0],
n => labels[n - 2],
};
if !sld.is_empty() && scorer.is_dga(sld) {
r |= FlowRisk::DGA_DOMAIN;
}
r
}
#[cfg(feature = "extractors")]
pub fn from_port_proto(key: &crate::extract::FiveTupleKey, detected: &str) -> FlowRisk {
let detected = detected.trim();
if detected.is_empty() {
return FlowRisk::empty();
}
match key.protocol_label() {
Some(label) if label_matches(label, detected) => FlowRisk::empty(),
Some(_) => FlowRisk::PORT_PROTO_MISMATCH,
None => FlowRisk::KNOWN_PROTO_NONSTD_PORT,
}
}
}
#[cfg(any(feature = "extractors", feature = "analysis"))]
pub(crate) fn label_matches(label: &str, detected: &str) -> bool {
label
.split('/')
.any(|tok| tok.eq_ignore_ascii_case(detected))
}
pub fn is_weak_cipher(suite: u16) -> bool {
matches!(
suite,
0x0000 | 0x0001 | 0x0002 | 0x003B
| 0x0004 | 0x0005 | 0x0018 | 0xC002 | 0xC007 | 0xC00C | 0xC011 | 0xC016
| 0x000A | 0x0013 | 0x0016 | 0x001A | 0x001B | 0xC003 | 0xC008 | 0xC012
| 0x0003 | 0x0006 | 0x0008 | 0x0014 | 0x0017 | 0x0019
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_has_no_severity_and_zero_score() {
let r = FlowRisk::empty();
assert_eq!(r.score(), 0);
assert_eq!(r.max_severity(), None);
assert_eq!(r.count(), 0);
assert_eq!(r.as_slugs().count(), 0);
}
#[test]
fn score_sums_and_severity_maxes() {
let r =
FlowRisk::TLS_SELF_SIGNED | FlowRisk::DGA_DOMAIN | FlowRisk::KNOWN_PROTO_NONSTD_PORT;
assert_eq!(r.score(), 50 + 100 + 10);
assert_eq!(r.max_severity(), Some(RiskSeverity::High));
assert_eq!(r.count(), 3);
}
#[test]
fn severity_ordering_is_low_to_severe() {
assert!(RiskSeverity::Low < RiskSeverity::Medium);
assert!(RiskSeverity::Medium < RiskSeverity::High);
assert!(RiskSeverity::High < RiskSeverity::Severe);
}
#[test]
fn single_flag_slug_and_severity() {
assert_eq!(FlowRisk::DGA_DOMAIN.slug(), "dga_domain");
assert_eq!(FlowRisk::DGA_DOMAIN.severity(), RiskSeverity::High);
assert_eq!(
FlowRisk::CLEARTEXT_CREDENTIALS.slug(),
"cleartext_credentials"
);
}
#[test]
fn as_slugs_enumerates_set_flags() {
let r = FlowRisk::TLS_WEAK_CIPHER | FlowRisk::CLEARTEXT_CREDENTIALS;
let slugs: Vec<_> = r.as_slugs().collect();
assert!(slugs.contains(&"tls_weak_cipher"));
assert!(slugs.contains(&"cleartext_credentials"));
assert_eq!(slugs.len(), 2);
}
#[test]
fn score_saturates() {
let r = FlowRisk::all();
let _ = r.score(); assert!(r.score() > 0);
}
#[test]
fn weak_cipher_table() {
assert!(is_weak_cipher(0x0005)); assert!(is_weak_cipher(0x000A)); assert!(is_weak_cipher(0x0003)); assert!(is_weak_cipher(0x0000)); assert!(is_weak_cipher(0xC011)); assert!(!is_weak_cipher(0x1301)); assert!(!is_weak_cipher(0xC02F)); }
#[cfg(feature = "tls")]
#[test]
fn from_tls_flags_obsolete_version_and_weak_cipher() {
use crate::tls::{TlsHandshake, TlsVersion};
let hs = TlsHandshake {
version: Some(TlsVersion::Tls1_0),
cipher_suite: Some(0x0005), ..Default::default()
};
let r = FlowRisk::from_tls(&hs);
assert!(r.contains(FlowRisk::TLS_OBSOLETE_VERSION));
assert!(r.contains(FlowRisk::TLS_WEAK_CIPHER));
let modern = TlsHandshake {
version: Some(TlsVersion::Tls1_3),
cipher_suite: Some(0x1301), ..Default::default()
};
assert!(FlowRisk::from_tls(&modern).is_empty());
}
#[test]
fn from_dns_flags_dga_and_punycode() {
let scorer = crate::detect::patterns::DgaScorer::new();
let dga = FlowRisk::from_dns("kq3v9z7xj2wq.com", &scorer);
assert!(dga.contains(FlowRisk::DGA_DOMAIN));
let benign = FlowRisk::from_dns("www.google.com", &scorer);
assert!(!benign.contains(FlowRisk::DGA_DOMAIN));
let idn = FlowRisk::from_dns("xn--80ak6aa92e.com", &scorer);
assert!(idn.contains(FlowRisk::PUNYCODE_IDN));
assert!(FlowRisk::from_dns("", &scorer).is_empty());
assert!(FlowRisk::from_dns(".", &scorer).is_empty());
}
#[cfg(feature = "extractors")]
#[test]
fn from_port_proto_mismatch_and_nonstd() {
use crate::extract::FiveTupleKey;
use std::net::SocketAddr;
let mk = |dport: u16| FiveTupleKey {
proto: crate::L4Proto::Tcp,
a: "10.0.0.1:54321".parse::<SocketAddr>().unwrap(),
b: format!("10.0.0.2:{dport}").parse::<SocketAddr>().unwrap(),
};
let mismatch = FlowRisk::from_port_proto(&mk(443), "ssh");
assert!(mismatch.contains(FlowRisk::PORT_PROTO_MISMATCH));
assert!(FlowRisk::from_port_proto(&mk(80), "http").is_empty());
assert!(FlowRisk::from_port_proto(&mk(443), "tls").is_empty());
let nonstd = FlowRisk::from_port_proto(&mk(51000), "ssh");
assert!(nonstd.contains(FlowRisk::KNOWN_PROTO_NONSTD_PORT));
}
}