use crate::error::Result; use serde::{Deserialize, Serialize}; pub const WEIGHT_P1: f64 = 0.75; pub const WEIGHT_P2: f64 = 0.75; pub const WEIGHT_P3: f64 = 0.75; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct FusionWeights { pub p1: f64, pub p2: f64, pub p3: f64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct FusionTerm { pub pillar: u8, pub alert_id: Option, pub m: f64, pub w: f64, pub weighted: f64, pub complement: f64, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct FusionInput { pub m1: f64, pub m2: f64, pub m3: f64, #[serde(default)] pub alert_id_p1: Option, #[serde(default)] pub alert_id_p2: Option, #[serde(default)] pub alert_id_p3: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct HostScore { pub orig_h: String, pub score: f64, pub terms: Vec, pub product: f64, pub method: &'static str, pub weights: FusionWeights, pub order: [u8; 3], pub formula_version: &'static str, } pub fn score(orig_h: &str, input: &FusionInput) -> Result { let t1_weighted = WEIGHT_P1 * input.m1; let t1_comp = 1.0 - t1_weighted; let term1 = FusionTerm { pillar: 1, alert_id: input.alert_id_p1.clone(), m: input.m1, w: WEIGHT_P1, weighted: t1_weighted, complement: t1_comp, }; let t2_weighted = WEIGHT_P2 * input.m2; let t2_comp = 1.0 - t2_weighted; let term2 = FusionTerm { pillar: 2, alert_id: input.alert_id_p2.clone(), m: input.m2, w: WEIGHT_P2, weighted: t2_weighted, complement: t2_comp, }; let t3_weighted = WEIGHT_P3 * input.m3; let t3_comp = 1.0 - t3_weighted; let term3 = FusionTerm { pillar: 3, alert_id: input.alert_id_p3.clone(), m: input.m3, w: WEIGHT_P3, weighted: t3_weighted, complement: t3_comp, }; // Product computed in fixed order 1, 2, 3 let product = t1_comp * t2_comp * t3_comp; let s = (1.0 - product).clamp(0.0, 1.0); Ok(HostScore { orig_h: orig_h.to_string(), score: s, terms: vec![term1, term2, term3], product, method: "weighted-noisy-or", weights: FusionWeights { p1: WEIGHT_P1, p2: WEIGHT_P2, p3: WEIGHT_P3, }, order: [1, 2, 3], formula_version: "1.0", }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_1_noisy_or_properties() { // Zero input -> score 0 let zero = score( "10.0.0.1", &FusionInput { m1: 0.0, m2: 0.0, m3: 0.0, ..Default::default() }, ) .unwrap(); assert_eq!(zero.score, 0.0); // Exact ceilings let one_pillar = score( "10.0.0.1", &FusionInput { m1: 1.0, m2: 0.0, m3: 0.0, ..Default::default() }, ) .unwrap(); assert_eq!(one_pillar.score, 0.75); let two_pillars = score( "10.0.0.1", &FusionInput { m1: 1.0, m2: 1.0, m3: 0.0, ..Default::default() }, ) .unwrap(); assert_eq!(two_pillars.score, 0.9375); let three_pillars = score( "10.0.0.1", &FusionInput { m1: 1.0, m2: 1.0, m3: 1.0, ..Default::default() }, ) .unwrap(); assert_eq!(three_pillars.score, 0.984375); // Monotonicity over 11x11x11 grid for i in 0..=10 { let m1 = i as f64 / 10.0; for j in 0..=10 { let m2 = j as f64 / 10.0; for k in 0..=10 { let m3 = k as f64 / 10.0; let base = score( "10.0.0.1", &FusionInput { m1, m2, m3, ..Default::default() }, ) .unwrap() .score; if i < 10 { let step = score( "10.0.0.1", &FusionInput { m1: m1 + 0.1, m2, m3, ..Default::default() }, ) .unwrap() .score; assert!(step >= base); } if j < 10 { let step = score( "10.0.0.1", &FusionInput { m1, m2: m2 + 0.1, m3, ..Default::default() }, ) .unwrap() .score; assert!(step >= base); } if k < 10 { let step = score( "10.0.0.1", &FusionInput { m1, m2, m3: m3 + 0.1, ..Default::default() }, ) .unwrap() .score; assert!(step >= base); } } } } } #[test] fn test_2_validation_table_vectors() { let cases = [ ("192.168.1.132", 1.000, 0.971, 0.0, 0.932), // Philips HUE benign ("192.168.100.113", 0.829, 0.0, 0.0, 0.622), // Hakai C&C ("192.168.1.195", 0.0, 0.821, 0.0, 0.616), // Mirai C&C ("192.168.100.103", 0.0, 0.790, 0.0, 0.593), // Torii C&C ("10.0.2.15", 0.0, 0.582, 0.0, 0.436), // CTU-Normal-26 ("192.168.1.114", 0.471, 0.0, 0.0, 0.353), // CTU-Normal-18 ("192.168.1.197", 0.0, 0.0, 0.0, 0.0), // 42-1 Trojan ("10.0.2.15-20", 0.0, 0.0, 0.0, 0.0), // CTU-Normal-20 ]; for (host, m1, m2, m3, expected_s) in cases { let res = score( host, &FusionInput { m1, m2, m3, ..Default::default() }, ) .unwrap(); assert!( (res.score - expected_s).abs() < 1e-3, "host {host} score {} != expected {}", res.score, expected_s ); } } #[test] fn test_3_determinism() { let input = FusionInput { m1: 0.829, m2: 0.790, m3: 0.450, alert_id_p1: Some("p1_id".to_string()), alert_id_p2: Some("p2_id".to_string()), alert_id_p3: Some("p3_id".to_string()), }; let s1 = score("10.0.0.1", &input).unwrap(); let s2 = score("10.0.0.1", &input).unwrap(); let j1 = serde_json::to_vec(&s1).unwrap(); let j2 = serde_json::to_vec(&s2).unwrap(); assert_eq!(j1, j2); } }