From 8aeadc7c64539fced1f950ff8835b777ea042334 Mon Sep 17 00:00:00 2001 From: DaLaw2 Date: Sat, 18 Apr 2026 16:43:13 +0800 Subject: [PATCH] fix(ml): floor ML min_packets fallback at 5 packets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An operator who sets `min_packets` to 0..=4 on their config currently applies that raw value to every bulk TCP / UDP flow that lacks an explicit low-packet override, feeding two-packet TLS handshakes and stray SYN-ACKs into inference where the features carry almost no signal. Introduce `ML_MIN_PACKETS_FLOOR = 5` and wrap every fall-through `=> global` branch in `effective_min_packets` with `.max(ML_MIN_PACKETS_FLOOR)`. The floor is a lower bound — a stricter global still wins. Low-packet overrides stay untouched (they are intentionally meaningful at very low counts): - ICMP protocol → 1 - UDP/53 (DNS) → 1 - UDP/123 (NTP) → 2 - UDP/3333, UDP/45700 → 2 (known C2 ports) - TCP/53 (DNS) → 2 - TCP/4444, 8443, 8080, 1337, 31337, 3333, 45700 → 2 (stealth / C2) Tests: 5 new (floor applies when global below floor, stricter global wins, ICMP override bypasses floor, all low-packet overrides preserved, boundary value 5 passes through). 207 pass total. clippy --package net-guardia -- -D warnings clean. Closes B-region Q-5. Co-Authored-By: Claude Opus 4.7 (1M context) --- net-guardia/src/core/ml/engine.rs | 94 +++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/net-guardia/src/core/ml/engine.rs b/net-guardia/src/core/ml/engine.rs index 4a916dc..9933e3a 100644 --- a/net-guardia/src/core/ml/engine.rs +++ b/net-guardia/src/core/ml/engine.rs @@ -30,6 +30,15 @@ use crate::model::monitoring::user_packet::UserPacket; /// across at least half the window's ticks before alerting. const DEFAULT_CONFIRMATION_WINDOW_FRACTION: u64 = 2; +/// Hard floor on the per-flow packet count that gates ML inference for any +/// protocol / port combination that lacks an explicit low-packet override. +/// Prevents a misconfigured `min_packets` (0..=4) from feeding two-packet +/// flows into the model where the features carry almost no signal and the +/// false-alarm rate dominates. Protocols and ports that are meaningful at +/// very low packet counts (ICMP scans, DNS tunneling, C2 beacons) bypass +/// this floor through explicit overrides in `effective_min_packets`. +const ML_MIN_PACKETS_FLOOR: usize = 5; + /// Per-queue tracker. With symmetric hash in eBPF, both directions of a flow /// land on the same queue, so per-queue trackers correctly see bidirectional flows. pub type ThreadTracker = Arc>; @@ -102,26 +111,30 @@ impl Engine { } /// Protocol/port-aware min_packets: some traffic patterns are meaningful - /// at very low packet counts and would be invisible to ML at the global threshold. + /// at very low packet counts and would be invisible to ML at the global + /// threshold. Paths that fall through to `global` are additionally + /// floored at `ML_MIN_PACKETS_FLOOR` so a misconfigured global setting + /// can't feed near-empty flows into inference. fn effective_min_packets(flow_key: &FlowKey, global: usize) -> usize { + let floored = global.max(ML_MIN_PACKETS_FLOOR); match flow_key.protocol { - // ICMP: single-packet SYN scans, ping sweeps + // ICMP: single-packet SYN scans, ping sweeps. 1 => 1, // UDP 17 => match flow_key.dst_port { 53 => 1, 123 => 2, 3333 | 45700 => 2, - _ => global, + _ => floored, }, // TCP 6 => match flow_key.dst_port { 53 => 2, 4444 | 8443 | 8080 | 1337 | 31337 => 2, 3333 | 45700 => 2, - _ => global, + _ => floored, }, - _ => global, + _ => floored, } } @@ -303,3 +316,74 @@ impl PacketSinkFactory for Engine { Some(Arc::new(QueueTrackerSink { tracker })) } } + +#[cfg(test)] +mod tests { + //! `effective_min_packets` coverage. Broader Engine behavior needs a + //! flow-tracker harness and lives in integration-style tests elsewhere. + + use super::*; + + fn flow_key(protocol: u8, dst_port: u16) -> FlowKey { + FlowKey { + src_ip: [0; 16], + dst_ip: [0; 16], + src_port: 12345, + dst_port, + protocol, + ip_version: 4, + } + } + + #[test] + fn floor_applies_when_global_below_five() { + // Bulk TCP / UDP with no low-packet override must not drop below 5 + // even if the operator sets a permissive global. + assert_eq!( + Engine::effective_min_packets(&flow_key(6, 443), 2), + ML_MIN_PACKETS_FLOOR + ); + assert_eq!( + Engine::effective_min_packets(&flow_key(17, 500), 0), + ML_MIN_PACKETS_FLOOR + ); + // Uncommon protocol (SCTP) also honors the floor. + assert_eq!( + Engine::effective_min_packets(&flow_key(132, 9), 1), + ML_MIN_PACKETS_FLOOR + ); + } + + #[test] + fn floor_respects_higher_global() { + // A stricter global wins — the floor is a lower bound, not a clamp. + assert_eq!(Engine::effective_min_packets(&flow_key(6, 443), 12), 12); + assert_eq!(Engine::effective_min_packets(&flow_key(17, 500), 8), 8); + } + + #[test] + fn icmp_override_bypasses_floor() { + // Single-packet ICMP scans must remain visible regardless of the floor. + assert_eq!(Engine::effective_min_packets(&flow_key(1, 0), 100), 1); + } + + #[test] + fn low_packet_overrides_preserved() { + // Every explicit low-packet override keeps its tuned value. + assert_eq!(Engine::effective_min_packets(&flow_key(17, 53), 100), 1); // UDP DNS + assert_eq!(Engine::effective_min_packets(&flow_key(17, 123), 100), 2); // UDP NTP + assert_eq!(Engine::effective_min_packets(&flow_key(17, 3333), 100), 2); // UDP C2 + assert_eq!(Engine::effective_min_packets(&flow_key(17, 45700), 100), 2); // UDP C2 + assert_eq!(Engine::effective_min_packets(&flow_key(6, 53), 100), 2); // TCP DNS + for port in [4444u16, 8443, 8080, 1337, 31337] { + assert_eq!(Engine::effective_min_packets(&flow_key(6, port), 100), 2); + } + assert_eq!(Engine::effective_min_packets(&flow_key(6, 3333), 100), 2); // TCP C2 + } + + #[test] + fn exact_floor_value_passes_through() { + // At the floor boundary, no bump applied. + assert_eq!(Engine::effective_min_packets(&flow_key(6, 443), 5), 5); + } +}