flowscope 0.22.0

Passive flow & session tracking for packet capture (runtime-free, cross-platform)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
//! Unified [`Detector`] trait + [`DetectorRegistry`] pipeline
//! (issue #131).
//!
//! Before 0.21 every detector had its own observe surface —
//! `(key, ts, bytes)` for beacons, `(key, success)` for the
//! port-scan TRW, `&str` for DGA — and consumers hand-wired a
//! `Vec` of detectors, each with bespoke feed/drain plumbing.
//! The registry keeps the heterogeneous inputs (they're
//! genuinely different) but unifies the **event-to-detector
//! dispatch**: register detectors once, then drive them all
//! from one `observe(&event, &mut out)` call per flow event and
//! one `observe_dns` call per DNS query.
//!
//! ```text
//! tracker/driver events ─▶ registry.observe(&evt, &mut out) ─▶ Vec<OwnedAnomaly> ─▶ EVE/NDJSON
//! DNS slot drain ────────▶ registry.observe_dns(key, qname, ts, &mut out) ──┘
//! ```
//!
//! # Design notes
//!
//! - **Hook-based, not input-enum-based.** [`Detector`] exposes
//!   one defaulted no-op hook per feed (`on_flow_start`,
//!   `on_flow_end`, `on_dns_query`, …). The registry matches
//!   each event **once** and fans the right hook to N
//!   detectors; a detector implements only the feeds it
//!   consumes. New feeds arrive as new defaulted hooks —
//!   non-breaking.
//! - **DNS names are not flow events.** Query names reach
//!   consumers through DNS parser drains (`SlotHandle` /
//!   `DnsMessage`), so the registry has a dedicated
//!   [`observe_dns`](DetectorRegistry::observe_dns) entry point
//!   — the same split `analysis::FlowAnalyzer` uses. Names are
//!   pre-extracted `&str`, keeping `detect` independent of the
//!   `dns` Cargo feature.
//! - **Caller-buffer output.** Hooks append to a caller-owned
//!   `Vec<OwnedAnomaly>` (the `track_into` idiom): zero
//!   per-event allocation in steady state, no unbounded
//!   internal queues.
//! - **Actionable-only emission.** The shipped impls gate on
//!   per-detector thresholds + cooldowns
//!   (`with_anomaly_threshold` / `with_cooldown` /
//!   suppression), so a registry drain is alert-shaped, not
//!   score-shaped. Use the detectors' raw `observe` methods
//!   directly when you want every score.

use std::{net::IpAddr, time::Duration};

use lru::LruCache;

use crate::{
    DetectorKind, OwnedAnomaly, Timestamp,
    anomaly_fields::KeyFields,
    detect::patterns::{BeaconDetector, DgaScorer, PortScanDetector, RitaBeaconDetector},
    event::{FlowEvent, FlowStats},
    extractor::L4Proto,
    history::HistoryString,
};

/// One detection algorithm, driven by a [`DetectorRegistry`].
///
/// Every hook defaults to a no-op — implement only the feeds
/// the detector consumes. Hooks append emission-worthy
/// anomalies to `out` (caller-owned buffer, `track_into`
/// idiom).
///
/// `Send` so a registry can ride a driver task across worker
/// threads (all shipped detectors are trivially `Send`).
pub trait Detector<K>: Send {
    /// Typed identity — lands on every anomaly this detector
    /// emits and drives metric labels / ATT&CK tagging.
    fn kind(&self) -> DetectorKind;

    /// New flow observed ([`FlowEvent::Started`]).
    fn on_flow_start(&mut self, _key: &K, _ts: Timestamp, _out: &mut Vec<OwnedAnomaly>) {}

    /// TCP 3-way handshake completed ([`FlowEvent::Established`]).
    fn on_flow_established(&mut self, _key: &K, _ts: Timestamp, _out: &mut Vec<OwnedAnomaly>) {}

    /// Flow ended ([`FlowEvent::Ended`]) — the natural
    /// per-connection observation (RITA's unit of analysis).
    ///
    /// `ts` is the flow's last activity: the tracker-level
    /// `Ended` carries no timestamp of its own, so the registry
    /// passes `stats.last_seen` (the driver-level adapter uses
    /// `Event::Ended.ts`, which equals it).
    fn on_flow_end(
        &mut self,
        _key: &K,
        _stats: &FlowStats,
        _history: &HistoryString,
        _l4: Option<L4Proto>,
        _ts: Timestamp,
        _out: &mut Vec<OwnedAnomaly>,
    ) {
    }

    /// Periodic live-flow snapshot ([`FlowEvent::Tick`] — only
    /// fires when `FlowTrackerConfig::flow_tick_interval` is
    /// set). Note tick stats are **cumulative**; delta-track
    /// inside the detector if you need per-interval values.
    fn on_flow_tick(
        &mut self,
        _key: &K,
        _stats: &FlowStats,
        _ts: Timestamp,
        _out: &mut Vec<OwnedAnomaly>,
    ) {
    }

    /// DNS query name observed on flow `key`. Pre-extracted
    /// `&str` (e.g. from a `DnsMessage::Query`'s first question)
    /// — feed it via [`DetectorRegistry::observe_dns`] from your
    /// DNS slot drain, resolver log, or any other name source.
    fn on_dns_query(
        &mut self,
        _key: &K,
        _qname: &str,
        _ts: Timestamp,
        _out: &mut Vec<OwnedAnomaly>,
    ) {
    }

    /// Keys currently tracked (0 for stateless detectors).
    fn tracked(&self) -> usize {
        0
    }

    /// Drop per-key state stale relative to `now` — bounds
    /// memory across key churn.
    fn evict_expired(&mut self, _now: Timestamp) {}
}

/// Heterogeneous detector set driven from one event stream.
///
/// See the [module docs](self) for the shape. Registered
/// detectors are boxed trait objects; drive them with
/// [`observe`](Self::observe) (tracker events),
/// [`observe_event`](Self::observe_event) (typed-driver
/// events), and [`observe_dns`](Self::observe_dns) (DNS query
/// names), then route the accumulated [`OwnedAnomaly`] buffer
/// to your sink.
///
/// ```
/// use flowscope::detect::{Detector, DetectorRegistry, HostPair, SrcHost};
/// use flowscope::detect::patterns::{BeaconDetector, PortScanDetector};
/// use flowscope::extract::FiveTupleKey;
///
/// let mut registry: DetectorRegistry<FiveTupleKey> = DetectorRegistry::new();
/// registry
///     .register(BeaconDetector::<HostPair>::new())
///     .register(PortScanDetector::<SrcHost>::new());
/// assert_eq!(registry.len(), 2);
///
/// // per event: registry.observe(&flow_event, &mut anomalies);
/// // per DNS query: registry.observe_dns(&key, qname, ts, &mut anomalies);
/// ```
#[must_use = "a registry does nothing until driven via observe/observe_dns"]
pub struct DetectorRegistry<K> {
    detectors: Vec<Box<dyn Detector<K>>>,
}

impl<K: KeyFields> DetectorRegistry<K> {
    /// Empty registry.
    pub fn new() -> Self {
        Self {
            detectors: Vec::new(),
        }
    }

    /// Register a detector. Chainable.
    pub fn register(&mut self, detector: impl Detector<K> + 'static) -> &mut Self {
        self.detectors.push(Box::new(detector));
        self
    }

    /// Drive every registered detector from one tracker-level
    /// event. Anomalies append to `out` — reuse the buffer
    /// across calls (`out.clear()` per drain).
    pub fn observe(&mut self, event: &FlowEvent<K>, out: &mut Vec<OwnedAnomaly>) {
        match event {
            FlowEvent::Started { key, ts, .. } => {
                for d in &mut self.detectors {
                    d.on_flow_start(key, *ts, out);
                }
            }
            FlowEvent::Established { key, ts, .. } => {
                for d in &mut self.detectors {
                    d.on_flow_established(key, *ts, out);
                }
            }
            FlowEvent::Ended {
                key,
                stats,
                history,
                l4,
                ..
            } => {
                let ts = stats.last_seen;
                for d in &mut self.detectors {
                    d.on_flow_end(key, stats, history, *l4, ts, out);
                }
            }
            FlowEvent::Tick { key, stats, ts } => {
                for d in &mut self.detectors {
                    d.on_flow_tick(key, stats, *ts, out);
                }
            }
            // Packet / StateChange / FlowAnomaly / TrackerAnomaly —
            // no detector feed (and both enums are #[non_exhaustive]).
            _ => {}
        }
    }

    /// Typed-driver adapter — same fan-out from a
    /// [`crate::driver::Event`]. Two-line integration for
    /// `Driver::track_into` pipelines.
    #[cfg(all(feature = "extractors", feature = "reassembler", feature = "session"))]
    pub fn observe_event(&mut self, event: &crate::driver::Event<K>, out: &mut Vec<OwnedAnomaly>) {
        use crate::driver::Event;
        match event {
            Event::Started { key, ts, .. } => {
                for d in &mut self.detectors {
                    d.on_flow_start(key, *ts, out);
                }
            }
            Event::Established { key, ts, .. } => {
                for d in &mut self.detectors {
                    d.on_flow_established(key, *ts, out);
                }
            }
            Event::Ended {
                key,
                stats,
                history,
                l4,
                ts,
                ..
            } => {
                for d in &mut self.detectors {
                    d.on_flow_end(key, stats, history, *l4, *ts, out);
                }
            }
            Event::Tick { key, stats, ts } => {
                for d in &mut self.detectors {
                    d.on_flow_tick(key, stats, *ts, out);
                }
            }
            _ => {}
        }
    }

    /// Feed one DNS query name (from a DNS slot drain / resolver
    /// log) to every registered detector.
    pub fn observe_dns(
        &mut self,
        key: &K,
        qname: &str,
        ts: Timestamp,
        out: &mut Vec<OwnedAnomaly>,
    ) {
        for d in &mut self.detectors {
            d.on_dns_query(key, qname, ts, out);
        }
    }

    /// Fan [`Detector::evict_expired`] out to every detector.
    pub fn evict_expired(&mut self, now: Timestamp) {
        for d in &mut self.detectors {
            d.evict_expired(now);
        }
    }

    /// Sum of every detector's tracked-key count.
    pub fn tracked(&self) -> usize {
        self.detectors.iter().map(|d| d.tracked()).sum()
    }

    /// The registered detectors' kinds, in registration order.
    pub fn kinds(&self) -> impl Iterator<Item = DetectorKind> + '_ {
        self.detectors.iter().map(|d| d.kind())
    }

    /// Number of registered detectors.
    pub fn len(&self) -> usize {
        self.detectors.len()
    }

    pub fn is_empty(&self) -> bool {
        self.detectors.is_empty()
    }
}

impl<K: KeyFields> Default for DetectorRegistry<K> {
    fn default() -> Self {
        Self::new()
    }
}

// ─── derived aggregation keys ────────────────────────────────────

/// Source-host aggregation key — pivots per-flow events to a
/// per-source-IP detector axis (port scans, floods, exfil).
///
/// Replaces the `SrcIpKey` newtype every consumer re-declared.
/// Build from any flow key via [`Self::from_key`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SrcHost(pub IpAddr);

impl SrcHost {
    /// Derive from any [`KeyFields`] flow key; `None` when the
    /// key exposes no source IP (custom keys).
    pub fn from_key<K: KeyFields + ?Sized>(key: &K) -> Option<Self> {
        key.src_ip().map(SrcHost)
    }
}

impl KeyFields for SrcHost {
    fn src_ip(&self) -> Option<IpAddr> {
        Some(self.0)
    }
}

/// `(src, dst, dst_port)` aggregation key — the beaconing axis:
/// each beacon ping is its own 5-tuple (ephemeral source port),
/// so beacon detectors must aggregate across flows on the
/// host-pair, not the flow key.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct HostPair {
    pub src: IpAddr,
    pub dst: IpAddr,
    /// Destination port, when the flow key exposes one. Kept in
    /// the key so beacons to distinct services score separately.
    pub dst_port: Option<u16>,
}

impl HostPair {
    /// Derive from any [`KeyFields`] flow key; `None` unless the
    /// key exposes both IPs.
    pub fn from_key<K: KeyFields + ?Sized>(key: &K) -> Option<Self> {
        Some(Self {
            src: key.src_ip()?,
            dst: key.dest_ip()?,
            dst_port: key.dest_port(),
        })
    }
}

impl KeyFields for HostPair {
    fn src_ip(&self) -> Option<IpAddr> {
        Some(self.src)
    }
    fn dest_ip(&self) -> Option<IpAddr> {
        Some(self.dst)
    }
    fn dest_port(&self) -> Option<u16> {
        self.dst_port
    }
}

// ─── shipped Detector impls ──────────────────────────────────────

/// Derive the port-scan "connection succeeded" signal from a
/// finished flow. TCP: the responder answered the handshake
/// (`'s'` — responder SYN[-ACK] — in the Zeek-style history);
/// an unanswered SYN or a RST refusal both read as failure.
/// Non-TCP: any responder packet counts as success.
fn flow_succeeded(history: &HistoryString, stats: &FlowStats, l4: Option<L4Proto>) -> bool {
    match l4 {
        Some(L4Proto::Tcp) => history.contains('s'),
        _ => stats.packets_responder > 0,
    }
}

impl<K: KeyFields + Send> Detector<K> for BeaconDetector<HostPair> {
    fn kind(&self) -> DetectorKind {
        DetectorKind::BeaconCv
    }

    /// One observation per finished flow, keyed by
    /// [`HostPair`]: `(ts = last activity, bytes =
    /// initiator-sent bytes)`. Emits when the gated observe
    /// (threshold + cooldown) fires. No-op for keys without
    /// both IPs.
    fn on_flow_end(
        &mut self,
        key: &K,
        stats: &FlowStats,
        _history: &HistoryString,
        _l4: Option<L4Proto>,
        ts: Timestamp,
        out: &mut Vec<OwnedAnomaly>,
    ) {
        let Some(pair) = HostPair::from_key(key) else {
            return;
        };
        if let Some(score) = self.observe_gated(pair, ts, stats.bytes_initiator) {
            out.push(score.into_anomaly(ts));
        }
    }

    fn tracked(&self) -> usize {
        BeaconDetector::tracked(self)
    }

    fn evict_expired(&mut self, now: Timestamp) {
        // One day of silence retires a host-pair's window — far
        // beyond the 24 h max beacon interval the detector scores.
        self.evict_stale(now, Duration::from_secs(24 * 60 * 60));
    }
}

impl<K: KeyFields + Send> Detector<K> for RitaBeaconDetector<HostPair> {
    fn kind(&self) -> DetectorKind {
        DetectorKind::BeaconRita
    }

    /// Same feed as the CV impl — see
    /// [`BeaconDetector`]'s `on_flow_end` docs.
    fn on_flow_end(
        &mut self,
        key: &K,
        stats: &FlowStats,
        _history: &HistoryString,
        _l4: Option<L4Proto>,
        ts: Timestamp,
        out: &mut Vec<OwnedAnomaly>,
    ) {
        let Some(pair) = HostPair::from_key(key) else {
            return;
        };
        if let Some(score) = self.observe_gated(pair, ts, stats.bytes_initiator) {
            out.push(score.into_anomaly(ts));
        }
    }

    fn tracked(&self) -> usize {
        RitaBeaconDetector::tracked(self)
    }

    fn evict_expired(&mut self, now: Timestamp) {
        self.evict_stale(now, Duration::from_secs(24 * 60 * 60));
    }
}

impl<K: KeyFields + Send> Detector<K> for PortScanDetector<SrcHost> {
    fn kind(&self) -> DetectorKind {
        DetectorKind::PortScanTrw
    }

    /// One TRW observation per finished flow, keyed by
    /// [`SrcHost`]. Success derives statelessly from the flow
    /// record (see the module-private `flow_succeeded`). Emits
    /// only on a `Scanner` verdict — TRW resets the key's state
    /// at every terminal verdict, which is natural
    /// re-alert dedup. No-op for keys without a source IP.
    fn on_flow_end(
        &mut self,
        key: &K,
        stats: &FlowStats,
        history: &HistoryString,
        l4: Option<L4Proto>,
        ts: Timestamp,
        out: &mut Vec<OwnedAnomaly>,
    ) {
        let Some(src) = SrcHost::from_key(key) else {
            return;
        };
        let success = flow_succeeded(history, stats, l4);
        let score = self.observe(src, success);
        if matches!(score.verdict, crate::detect::patterns::ScanVerdict::Scanner) {
            out.push(score.into_anomaly(ts));
        }
    }

    fn tracked(&self) -> usize {
        PortScanDetector::tracked(self)
    }
}

/// Registry adapter for the stateless [`DgaScorer`] — adds the
/// qname plumbing and alert dedup the raw scorer deliberately
/// doesn't carry.
///
/// Feeds from [`Detector::on_dns_query`]: extracts the
/// second-to-last dot-label as the registrable-ish domain (the
/// same PSL-free heuristic as `FlowRisk::from_dns` — imperfect
/// for `co.uk`-style suffixes; pre-extract with the
/// `publicsuffix` crate when that matters), scores it, and
/// emits one anomaly per `(source IP, domain)` pair — repeat
/// queries for a flagged domain are suppressed through a
/// bounded LRU.
///
/// [`DgaScorer`] itself stays public and unchanged for direct
/// scoring (`FlowRisk::from_dns`, `analysis::FlowAnalyzer`).
pub struct DgaDetector {
    scorer: DgaScorer,
    threshold: f32,
    seen: LruCache<(Option<IpAddr>, String), ()>,
}

impl DgaDetector {
    /// Default threshold (−3.5, [`DgaScorer::is_dga`]'s) and a
    /// 4 096-entry suppression LRU.
    pub fn new() -> Self {
        Self {
            scorer: DgaScorer::new(),
            threshold: -3.5,
            seen: LruCache::new(std::num::NonZeroUsize::new(4096).unwrap()),
        }
    }

    /// Bigram log-likelihood verdict threshold (more negative =
    /// stricter; see [`DgaScorer::is_dga_with_threshold`]).
    pub fn with_threshold(mut self, threshold: f32) -> Self {
        self.threshold = threshold;
        self
    }

    /// Capacity of the `(source, domain)` alert-suppression LRU
    /// (clamped to ≥ 1).
    pub fn with_suppression_capacity(mut self, capacity: usize) -> Self {
        self.seen = LruCache::new(std::num::NonZeroUsize::new(capacity.max(1)).unwrap());
        self
    }

    /// PSL-free registrable-domain heuristic: second-to-last
    /// label (`sub.evil.com` → `evil`), single label kept as-is.
    fn sld(qname: &str) -> Option<String> {
        let trimmed = qname.trim().trim_end_matches('.');
        if trimmed.is_empty() {
            return None;
        }
        let lower = trimmed.to_ascii_lowercase();
        let labels: Vec<&str> = lower.split('.').collect();
        let sld = match labels.len() {
            0 => return None,
            1 => labels[0],
            n => labels[n - 2],
        };
        if sld.is_empty() {
            None
        } else {
            Some(sld.to_string())
        }
    }
}

impl Default for DgaDetector {
    fn default() -> Self {
        Self::new()
    }
}

impl<K: KeyFields + Send> Detector<K> for DgaDetector {
    fn kind(&self) -> DetectorKind {
        DetectorKind::Dga
    }

    fn on_dns_query(&mut self, key: &K, qname: &str, ts: Timestamp, out: &mut Vec<OwnedAnomaly>) {
        let Some(sld) = Self::sld(qname) else {
            return;
        };
        if !self.scorer.is_dga_with_threshold(&sld, self.threshold) {
            return;
        }
        let suppress_key = (key.src_ip(), sld.clone());
        if self.seen.contains(&suppress_key) {
            return;
        }
        self.seen.put(suppress_key, ());
        out.push(
            self.scorer
                .score(&sld)
                .into_anomaly(ts, Some(key as &dyn KeyFields))
                .with_observation("qname", qname.to_string())
                .with_observation("sld", sld),
        );
    }

    fn tracked(&self) -> usize {
        self.seen.len()
    }
}

#[cfg(test)]
mod tests {
    use std::net::Ipv4Addr;

    use super::*;

    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
    struct NoIpKey;
    impl KeyFields for NoIpKey {}

    fn key_a() -> SrcHost {
        SrcHost(IpAddr::from(Ipv4Addr::new(10, 0, 0, 1)))
    }

    #[test]
    fn derived_keys_from_keyfields() {
        let pair = HostPair {
            src: IpAddr::from(Ipv4Addr::new(10, 0, 0, 1)),
            dst: IpAddr::from(Ipv4Addr::new(10, 0, 0, 2)),
            dst_port: Some(443),
        };
        // A HostPair is itself a KeyFields impl — round-trips.
        assert_eq!(HostPair::from_key(&pair), Some(pair));
        assert_eq!(SrcHost::from_key(&pair), Some(SrcHost(pair.src)));
        // Keys without IPs derive nothing.
        assert_eq!(HostPair::from_key(&NoIpKey), None);
        assert_eq!(SrcHost::from_key(&NoIpKey), None);
    }

    #[test]
    fn dga_detector_flags_and_suppresses() {
        let mut d = DgaDetector::new();
        let mut out = Vec::new();
        let key = key_a();
        <DgaDetector as Detector<SrcHost>>::on_dns_query(
            &mut d,
            &key,
            "kq3v9z7xj2wq.com",
            Timestamp::new(0, 0),
            &mut out,
        );
        assert_eq!(out.len(), 1, "DGA-shaped SLD emits");
        assert_eq!(out[0].kind, DetectorKind::Dga);
        // Same (src, domain): suppressed.
        <DgaDetector as Detector<SrcHost>>::on_dns_query(
            &mut d,
            &key,
            "www.kq3v9z7xj2wq.com",
            Timestamp::new(1, 0),
            &mut out,
        );
        assert_eq!(out.len(), 1, "repeat domain suppressed");
        // Benign domain: no emission.
        <DgaDetector as Detector<SrcHost>>::on_dns_query(
            &mut d,
            &key,
            "www.google.com",
            Timestamp::new(2, 0),
            &mut out,
        );
        assert_eq!(out.len(), 1);
    }

    #[test]
    fn flow_succeeded_rules() {
        let mut history = HistoryString::new();
        let mut stats = FlowStats::default();
        // Unanswered TCP SYN: failure.
        history.push_str("S");
        assert!(!flow_succeeded(&history, &stats, Some(L4Proto::Tcp)));
        // RST-refused: failure.
        let mut refused = HistoryString::new();
        refused.push_str("Sr");
        assert!(!flow_succeeded(&refused, &stats, Some(L4Proto::Tcp)));
        // Responder SYN-ACK: success.
        let mut ok = HistoryString::new();
        ok.push_str("Ss");
        assert!(flow_succeeded(&ok, &stats, Some(L4Proto::Tcp)));
        // UDP: responder packets decide.
        assert!(!flow_succeeded(&history, &stats, Some(L4Proto::Udp)));
        stats.packets_responder = 1;
        assert!(flow_succeeded(&history, &stats, Some(L4Proto::Udp)));
    }
}