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}