Skip to main content

flowscope/
anomaly_fields.rs

1//! [`KeyFields`] + [`AnomalyFields`] — structured field access
2//! for emit writers (EVE, NDJSON, CSV, Zeek, custom).
3//!
4//! Lets writers pull typed accessors (`IpAddr`, `u16`,
5//! `&'static str`) off a flow key + an [`crate::AnomalyKind`]
6//! without going through `Debug` formatting.
7//!
8//! Plan 126 introduced these as one trait; plan 130 (0.12.0)
9//! split them along their natural cleavage so the type system
10//! reflects what's a flow-key property vs an anomaly property:
11//!
12//! - [`KeyFields`] — 5-tuple / protocol accessors. Default
13//!   impls on [`crate::extract::FiveTupleKey`] and
14//!   [`crate::L4Proto`]. Implement for custom keys to make them
15//!   work with every [`crate::emit`] writer.
16//! - [`AnomalyFields`] — anomaly-classification accessors.
17//!   Implemented on [`crate::AnomalyKind`].
18
19use std::net::IpAddr;
20
21/// Structured access to flow-key fields for emit writers.
22///
23/// All methods default to `None` so implementors override only
24/// the fields they actually carry. Emit writers MUST tolerate
25/// `None` returns — they correspond to "field not applicable
26/// for this key type" (e.g. `src_port()` on an IP-only key).
27///
28/// # Implementing for custom keys
29///
30/// Custom [`crate::FlowExtractor::Key`] types should implement
31/// this trait if they want to flow through CSV / NDJSON / Zeek /
32/// EVE without fallback `Debug` formatting:
33///
34/// ```
35/// use std::net::IpAddr;
36/// use flowscope::KeyFields;
37///
38/// struct MyKey { src: IpAddr, dst: IpAddr }
39///
40/// impl KeyFields for MyKey {
41///     fn src_ip(&self) -> Option<IpAddr> { Some(self.src) }
42///     fn dest_ip(&self) -> Option<IpAddr> { Some(self.dst) }
43/// }
44/// ```
45pub trait KeyFields {
46    /// Source IP for the flow.
47    fn src_ip(&self) -> Option<IpAddr> {
48        None
49    }
50
51    /// Source port (TCP/UDP).
52    fn src_port(&self) -> Option<u16> {
53        None
54    }
55
56    /// Destination IP for the flow.
57    fn dest_ip(&self) -> Option<IpAddr> {
58        None
59    }
60
61    /// Destination port (TCP/UDP).
62    fn dest_port(&self) -> Option<u16> {
63        None
64    }
65
66    /// L4 protocol as a static EVE-compatible label
67    /// (`"TCP"` / `"UDP"` / `"ICMP"` / `"ICMPv6"` / `"SCTP"`).
68    fn proto_str(&self) -> Option<&'static str> {
69        None
70    }
71
72    /// L4 protocol number per IANA assigned numbers
73    /// (TCP=6, UDP=17, ICMP=1, ICMPv6=58, SCTP=132). Used by
74    /// IPFIX-IE-keyed exporters that need the numeric ID
75    /// alongside the [`Self::proto_str`] label. Default `None`
76    /// — override on keys that carry an L4 protocol.
77    ///
78    /// Issue #16 — needed by the
79    /// [`FlowRecord::from_key_fields`](crate::FlowRecord::from_key_fields)
80    /// generic constructor so emit writers can unify the
81    /// `write_event(Ended)` → `write_flow_record` code path.
82    fn protocol_identifier(&self) -> Option<u8> {
83        None
84    }
85
86    /// Application-layer protocol label, e.g. `"http"` /
87    /// `"dns"` / `"tls"`. Default `None` — emit writers
88    /// typically thread the `parser_kind` from
89    /// [`crate::driver::SlotMessage`] instead. Override only on
90    /// custom keys that carry app-layer hints natively.
91    fn app_proto_str(&self) -> Option<&'static str> {
92        None
93    }
94
95    /// Stable, **seed-fixed** 64-bit hash over the canonical
96    /// (direction-normalized) 5-tuple.
97    ///
98    /// Unlike a `#[derive(Hash)]` run through `RandomState` (a
99    /// per-process random seed), this is **reproducible across
100    /// threads and processes** — the property required for
101    /// sharding a merged flow table so both legs of a flow always
102    /// land on the same worker (see [`Self::shard_index`]). A→B
103    /// and B→A produce the same value.
104    ///
105    /// Returns `None` if any of (proto, src ip/port, dest ip/port)
106    /// is unknown. The algorithm is FNV-1a. This is a fast,
107    /// **non-portable** in-process identifier — it is *not* emitted by
108    /// the EVE / NDJSON writers (which lead with the portable
109    /// [`community_id`](Self::community_id) since 0.19, issue #88). Use
110    /// it for sharding / in-memory keying, not for cross-tool pivots.
111    ///
112    /// Issue #76 (folds #70).
113    fn stable_hash(&self) -> Option<u64> {
114        let src_ip = self.src_ip()?;
115        let src_port = self.src_port()?;
116        let dest_ip = self.dest_ip()?;
117        let dest_port = self.dest_port()?;
118        // Proto component: the string label when the key carries one
119        // (stable across direction for TCP/UDP/…), else the numeric
120        // protocol id.
121        let mut id_buf = [0u8; 1];
122        let proto: &[u8] = if let Some(s) = self.proto_str() {
123            s.as_bytes()
124        } else if let Some(id) = self.protocol_identifier() {
125            id_buf[0] = id;
126            &id_buf
127        } else {
128            return None;
129        };
130        Some(fnv1a_five_tuple(
131            proto, src_ip, src_port, dest_ip, dest_port,
132        ))
133    }
134
135    /// Deterministic shard index in `0..n` for sharded flow
136    /// tables. `None` if `n == 0` or the key lacks a full 5-tuple.
137    ///
138    /// Built on [`Self::stable_hash`], so both directions of a
139    /// flow map to the same shard across processes — the
140    /// correctness requirement for tap-merge sharding.
141    ///
142    /// Issue #76 (folds #70).
143    fn shard_index(&self, n: usize) -> Option<usize> {
144        if n == 0 {
145            return None;
146        }
147        Some((self.stable_hash()? % n as u64) as usize)
148    }
149
150    /// [Corelight Community ID](https://github.com/corelight/community-id-spec)
151    /// v1 with the universal default seed (0) — the cross-tool
152    /// flow id for pivoting flowscope output against Zeek /
153    /// Suricata / Security Onion.
154    ///
155    /// Returns `Some` only when the crate is built with the
156    /// `community-id` feature **and** the key carries a full
157    /// 5-tuple; otherwise `None`. TCP/UDP/SCTP are exact; ICMP is
158    /// stable but not spec-compatible (see [`crate::community_id`]).
159    ///
160    /// Issue #76.
161    fn community_id(&self) -> Option<String> {
162        self.community_id_seeded(0)
163    }
164
165    /// [`Self::community_id`] with an explicit sensor seed.
166    fn community_id_seeded(&self, seed: u16) -> Option<String> {
167        #[cfg(feature = "community-id")]
168        {
169            crate::community_id::community_id_for_key(self, seed)
170        }
171        #[cfg(not(feature = "community-id"))]
172        {
173            let _ = seed;
174            None
175        }
176    }
177}
178
179/// FNV-1a over the canonical 5-tuple: `proto.as_bytes() ‖ lo_ip
180/// ‖ lo_port_be ‖ hi_ip ‖ hi_port_be`, where `(lo_ip, lo_port)`
181/// is the lexicographically smaller endpoint. Direction- and
182/// process-stable. Backs [`KeyFields::stable_hash`] — a non-portable
183/// in-process identifier (the portable cross-tool id is
184/// [`KeyFields::community_id`]).
185pub(crate) fn fnv1a_five_tuple(
186    proto: &[u8],
187    src_ip: IpAddr,
188    src_port: u16,
189    dest_ip: IpAddr,
190    dest_port: u16,
191) -> u64 {
192    let (lo_ip, lo_port, hi_ip, hi_port) = if (src_ip, src_port) <= (dest_ip, dest_port) {
193        (src_ip, src_port, dest_ip, dest_port)
194    } else {
195        (dest_ip, dest_port, src_ip, src_port)
196    };
197
198    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
199    const FNV_PRIME: u64 = 0x100000001b3;
200    let mut h = FNV_OFFSET;
201    fn feed(b: u8, h: &mut u64) {
202        *h ^= b as u64;
203        *h = h.wrapping_mul(FNV_PRIME);
204    }
205    fn feed_ip(ip: IpAddr, h: &mut u64) {
206        match ip {
207            IpAddr::V4(v4) => {
208                for &b in &v4.octets() {
209                    feed(b, h);
210                }
211            }
212            IpAddr::V6(v6) => {
213                for &b in &v6.octets() {
214                    feed(b, h);
215                }
216            }
217        }
218    }
219    fn feed_port(p: u16, h: &mut u64) {
220        feed((p >> 8) as u8, h);
221        feed((p & 0xff) as u8, h);
222    }
223    for &b in proto {
224        feed(b, &mut h);
225    }
226    feed_ip(lo_ip, &mut h);
227    feed_port(lo_port, &mut h);
228    feed_ip(hi_ip, &mut h);
229    feed_port(hi_port, &mut h);
230    h
231}
232
233/// Anomaly classification accessor — implemented on
234/// [`crate::AnomalyKind`] in this crate. Consumers writing
235/// alert-shaped emit writers (EVE, future Suricata-shaped
236/// outputs) constrain on this trait separately from
237/// [`KeyFields`].
238pub trait AnomalyFields {
239    /// EVE `anomaly.type` classification.
240    ///
241    /// Suricata schema:
242    /// - `"stream"` — transport-layer state / reassembly anomalies
243    /// - `"decode"` — frame-integrity anomalies
244    /// - `"applayer"` — parser-driven application-layer anomalies
245    fn anomaly_type(&self) -> Option<&'static str> {
246        None
247    }
248
249    /// EVE `anomaly.event` — the stable slug, e.g.
250    /// `"ooo_segment"` or `"buffer_overflow"`.
251    /// [`crate::AnomalyKind`] implements via `short_kind()`.
252    fn anomaly_event(&self) -> Option<&'static str> {
253        None
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    struct CustomKey {
262        src: IpAddr,
263        dst: IpAddr,
264    }
265
266    impl KeyFields for CustomKey {
267        fn src_ip(&self) -> Option<IpAddr> {
268            Some(self.src)
269        }
270        fn dest_ip(&self) -> Option<IpAddr> {
271            Some(self.dst)
272        }
273    }
274
275    #[test]
276    fn key_fields_default_impls_return_none() {
277        struct Empty;
278        impl KeyFields for Empty {}
279        let e = Empty;
280        assert!(e.src_ip().is_none());
281        assert!(e.src_port().is_none());
282        assert!(e.dest_ip().is_none());
283        assert!(e.dest_port().is_none());
284        assert!(e.proto_str().is_none());
285        assert!(e.app_proto_str().is_none());
286    }
287
288    #[test]
289    fn anomaly_fields_default_impls_return_none() {
290        struct Empty;
291        impl AnomalyFields for Empty {}
292        let e = Empty;
293        assert!(e.anomaly_type().is_none());
294        assert!(e.anomaly_event().is_none());
295    }
296
297    #[test]
298    fn custom_key_overrides_only_chosen_fields() {
299        let k = CustomKey {
300            src: "10.0.0.1".parse().unwrap(),
301            dst: "10.0.0.2".parse().unwrap(),
302        };
303        assert_eq!(k.src_ip(), Some("10.0.0.1".parse().unwrap()));
304        assert_eq!(k.dest_ip(), Some("10.0.0.2".parse().unwrap()));
305        // Not overridden — defaults to None.
306        assert!(k.src_port().is_none());
307        assert!(k.proto_str().is_none());
308    }
309}