Skip to main content

hunt_query/
query.rs

1//! Structured query predicates for hunt envelope filtering.
2
3use chrono::{DateTime, Utc};
4use serde::Serialize;
5
6use crate::timeline::{NormalizedVerdict, TimelineEvent};
7
8/// Source system for events.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum EventSource {
12    Tetragon,
13    Hubble,
14    Receipt,
15    Scan,
16}
17
18impl EventSource {
19    /// Parse a source string (case-insensitive).
20    pub fn parse(s: &str) -> Option<Self> {
21        match s.trim().to_lowercase().as_str() {
22            "tetragon" => Some(Self::Tetragon),
23            "hubble" => Some(Self::Hubble),
24            "receipt" | "receipts" => Some(Self::Receipt),
25            "scan" | "scans" => Some(Self::Scan),
26            _ => None,
27        }
28    }
29
30    /// Parse a comma-separated list of sources.
31    pub fn parse_list(s: &str) -> Vec<Self> {
32        s.split(',')
33            .filter_map(|part| Self::parse(part.trim()))
34            .collect()
35    }
36
37    /// JetStream stream name.
38    pub fn stream_name(&self) -> &'static str {
39        match self {
40            Self::Tetragon => "CLAWDSTRIKE_TETRAGON",
41            Self::Hubble => "CLAWDSTRIKE_HUBBLE",
42            Self::Receipt => "CLAWDSTRIKE_RECEIPTS",
43            Self::Scan => "CLAWDSTRIKE_SCANS",
44        }
45    }
46
47    /// NATS subject filter pattern.
48    pub fn subject_filter(&self) -> &'static str {
49        match self {
50            Self::Tetragon => "clawdstrike.sdr.fact.tetragon_event.>",
51            Self::Hubble => "clawdstrike.sdr.fact.hubble_flow.>",
52            Self::Receipt => "clawdstrike.sdr.fact.receipt.>",
53            Self::Scan => "clawdstrike.sdr.fact.scan.>",
54        }
55    }
56
57    /// All known sources.
58    pub fn all() -> Vec<Self> {
59        vec![Self::Tetragon, Self::Hubble, Self::Receipt, Self::Scan]
60    }
61}
62
63impl std::fmt::Display for EventSource {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            Self::Tetragon => write!(f, "tetragon"),
67            Self::Hubble => write!(f, "hubble"),
68            Self::Receipt => write!(f, "receipt"),
69            Self::Scan => write!(f, "scan"),
70        }
71    }
72}
73
74/// Verdict filter for queries.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "snake_case")]
77pub enum QueryVerdict {
78    Allow,
79    Deny,
80    Warn,
81    Forwarded,
82    Dropped,
83}
84
85impl QueryVerdict {
86    /// Parse a verdict string (case-insensitive, supports aliases).
87    pub fn parse(s: &str) -> Option<Self> {
88        match s.trim().to_lowercase().as_str() {
89            "allow" | "allowed" | "pass" | "passed" => Some(Self::Allow),
90            "deny" | "denied" | "block" | "blocked" => Some(Self::Deny),
91            "warn" | "warned" | "warning" => Some(Self::Warn),
92            "forwarded" | "forward" => Some(Self::Forwarded),
93            "dropped" | "drop" => Some(Self::Dropped),
94            _ => None,
95        }
96    }
97}
98
99/// Structured query over historical events.
100#[derive(Debug, Clone)]
101pub struct HuntQuery {
102    pub sources: Vec<EventSource>,
103    pub verdict: Option<QueryVerdict>,
104    pub start: Option<DateTime<Utc>>,
105    pub end: Option<DateTime<Utc>>,
106    pub action_type: Option<String>,
107    pub process: Option<String>,
108    pub namespace: Option<String>,
109    pub pod: Option<String>,
110    pub limit: usize,
111    pub entity: Option<String>,
112}
113
114impl Default for HuntQuery {
115    fn default() -> Self {
116        Self {
117            sources: Vec::new(),
118            verdict: None,
119            start: None,
120            end: None,
121            action_type: None,
122            process: None,
123            namespace: None,
124            pod: None,
125            limit: 100,
126            entity: None,
127        }
128    }
129}
130
131impl HuntQuery {
132    /// Returns the effective sources: the configured list, or all sources if empty.
133    pub fn effective_sources(&self) -> Vec<EventSource> {
134        if self.sources.is_empty() {
135            EventSource::all()
136        } else {
137            let mut deduped = Vec::with_capacity(self.sources.len());
138            for source in &self.sources {
139                if !deduped.contains(source) {
140                    deduped.push(*source);
141                }
142            }
143            deduped
144        }
145    }
146
147    /// Returns true if the event matches ALL active predicates.
148    pub fn matches(&self, event: &TimelineEvent) -> bool {
149        // Check source filter
150        if !self.sources.is_empty() && !self.sources.contains(&event.source) {
151            return false;
152        }
153
154        // Check verdict filter
155        if let Some(ref v) = self.verdict {
156            let expected = match v {
157                QueryVerdict::Allow => NormalizedVerdict::Allow,
158                QueryVerdict::Deny => NormalizedVerdict::Deny,
159                QueryVerdict::Warn => NormalizedVerdict::Warn,
160                QueryVerdict::Forwarded => NormalizedVerdict::Forwarded,
161                QueryVerdict::Dropped => NormalizedVerdict::Dropped,
162            };
163            if event.verdict != expected {
164                return false;
165            }
166        }
167
168        // Check time range
169        if let Some(ref start) = self.start {
170            if event.timestamp < *start {
171                return false;
172            }
173        }
174        if let Some(ref end) = self.end {
175            if event.timestamp > *end {
176                return false;
177            }
178        }
179
180        // Check optional string fields (case-insensitive)
181        if let Some(ref at) = self.action_type {
182            if !event
183                .action_type
184                .as_ref()
185                .is_some_and(|ea| ea.eq_ignore_ascii_case(at))
186            {
187                return false;
188            }
189        }
190
191        if let Some(ref p) = self.process {
192            if !event
193                .process
194                .as_ref()
195                .is_some_and(|ep| ep.to_lowercase().contains(&p.to_lowercase()))
196            {
197                return false;
198            }
199        }
200
201        if let Some(ref ns) = self.namespace {
202            if !event
203                .namespace
204                .as_ref()
205                .is_some_and(|en| en.eq_ignore_ascii_case(ns))
206            {
207                return false;
208            }
209        }
210
211        if let Some(ref pod_filter) = self.pod {
212            if !event
213                .pod
214                .as_ref()
215                .is_some_and(|ep| ep.to_lowercase().contains(&pod_filter.to_lowercase()))
216            {
217                return false;
218            }
219        }
220
221        // Entity: matches against pod name or namespace (case-insensitive substring)
222        if let Some(ref entity) = self.entity {
223            let entity_lower = entity.to_lowercase();
224            let pod_match = event
225                .pod
226                .as_ref()
227                .is_some_and(|p| p.to_lowercase().contains(&entity_lower));
228            let ns_match = event
229                .namespace
230                .as_ref()
231                .is_some_and(|n| n.to_lowercase().contains(&entity_lower));
232            if !pod_match && !ns_match {
233                return false;
234            }
235        }
236
237        true
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::timeline::{NormalizedVerdict, TimelineEvent, TimelineEventKind};
245    use chrono::TimeZone;
246
247    fn make_event() -> TimelineEvent {
248        TimelineEvent {
249            timestamp: Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap(),
250            source: EventSource::Tetragon,
251            kind: TimelineEventKind::ProcessExec,
252            verdict: NormalizedVerdict::Allow,
253            severity: None,
254            summary: "process_exec /usr/bin/curl".to_string(),
255            process: Some("/usr/bin/curl".to_string()),
256            namespace: Some("default".to_string()),
257            pod: Some("agent-pod-abc123".to_string()),
258            action_type: Some("process".to_string()),
259            signature_valid: None,
260            raw: None,
261        }
262    }
263
264    #[test]
265    fn event_source_parse() {
266        assert_eq!(EventSource::parse("tetragon"), Some(EventSource::Tetragon));
267        assert_eq!(EventSource::parse("HUBBLE"), Some(EventSource::Hubble));
268        assert_eq!(EventSource::parse("Receipt"), Some(EventSource::Receipt));
269        assert_eq!(EventSource::parse("receipts"), Some(EventSource::Receipt));
270        assert_eq!(EventSource::parse("scan"), Some(EventSource::Scan));
271        assert_eq!(EventSource::parse("scans"), Some(EventSource::Scan));
272        assert_eq!(EventSource::parse("unknown"), None);
273    }
274
275    #[test]
276    fn event_source_parse_list() {
277        let sources = EventSource::parse_list("tetragon, hubble");
278        assert_eq!(sources, vec![EventSource::Tetragon, EventSource::Hubble]);
279
280        let sources = EventSource::parse_list("SCAN");
281        assert_eq!(sources, vec![EventSource::Scan]);
282
283        let empty = EventSource::parse_list("");
284        assert!(empty.is_empty());
285    }
286
287    #[test]
288    fn event_source_stream_names() {
289        assert_eq!(EventSource::Tetragon.stream_name(), "CLAWDSTRIKE_TETRAGON");
290        assert_eq!(EventSource::Hubble.stream_name(), "CLAWDSTRIKE_HUBBLE");
291        assert_eq!(EventSource::Receipt.stream_name(), "CLAWDSTRIKE_RECEIPTS");
292        assert_eq!(EventSource::Scan.stream_name(), "CLAWDSTRIKE_SCANS");
293    }
294
295    #[test]
296    fn event_source_subject_filters() {
297        assert_eq!(
298            EventSource::Tetragon.subject_filter(),
299            "clawdstrike.sdr.fact.tetragon_event.>"
300        );
301        assert_eq!(
302            EventSource::Hubble.subject_filter(),
303            "clawdstrike.sdr.fact.hubble_flow.>"
304        );
305    }
306
307    #[test]
308    fn event_source_display() {
309        assert_eq!(EventSource::Tetragon.to_string(), "tetragon");
310        assert_eq!(EventSource::Hubble.to_string(), "hubble");
311        assert_eq!(EventSource::Receipt.to_string(), "receipt");
312        assert_eq!(EventSource::Scan.to_string(), "scan");
313    }
314
315    #[test]
316    fn event_source_all() {
317        let all = EventSource::all();
318        assert_eq!(all.len(), 4);
319        assert!(all.contains(&EventSource::Tetragon));
320        assert!(all.contains(&EventSource::Hubble));
321        assert!(all.contains(&EventSource::Receipt));
322        assert!(all.contains(&EventSource::Scan));
323    }
324
325    #[test]
326    fn query_verdict_parse() {
327        assert_eq!(QueryVerdict::parse("allow"), Some(QueryVerdict::Allow));
328        assert_eq!(QueryVerdict::parse("ALLOWED"), Some(QueryVerdict::Allow));
329        assert_eq!(QueryVerdict::parse("pass"), Some(QueryVerdict::Allow));
330        assert_eq!(QueryVerdict::parse("passed"), Some(QueryVerdict::Allow));
331        assert_eq!(QueryVerdict::parse("deny"), Some(QueryVerdict::Deny));
332        assert_eq!(QueryVerdict::parse("DENIED"), Some(QueryVerdict::Deny));
333        assert_eq!(QueryVerdict::parse("block"), Some(QueryVerdict::Deny));
334        assert_eq!(QueryVerdict::parse("blocked"), Some(QueryVerdict::Deny));
335        assert_eq!(QueryVerdict::parse("warn"), Some(QueryVerdict::Warn));
336        assert_eq!(QueryVerdict::parse("warned"), Some(QueryVerdict::Warn));
337        assert_eq!(QueryVerdict::parse("warning"), Some(QueryVerdict::Warn));
338        assert_eq!(
339            QueryVerdict::parse("forwarded"),
340            Some(QueryVerdict::Forwarded)
341        );
342        assert_eq!(
343            QueryVerdict::parse("forward"),
344            Some(QueryVerdict::Forwarded)
345        );
346        assert_eq!(QueryVerdict::parse("dropped"), Some(QueryVerdict::Dropped));
347        assert_eq!(QueryVerdict::parse("drop"), Some(QueryVerdict::Dropped));
348        assert_eq!(QueryVerdict::parse("unknown"), None);
349    }
350
351    #[test]
352    fn hunt_query_matches_forwarded_verdict() {
353        let mut event = make_event();
354        event.verdict = NormalizedVerdict::Forwarded;
355
356        let q = HuntQuery {
357            verdict: Some(QueryVerdict::Forwarded),
358            ..Default::default()
359        };
360        assert!(q.matches(&event));
361
362        let q2 = HuntQuery {
363            verdict: Some(QueryVerdict::Allow),
364            ..Default::default()
365        };
366        assert!(!q2.matches(&event));
367    }
368
369    #[test]
370    fn hunt_query_matches_dropped_verdict() {
371        let mut event = make_event();
372        event.verdict = NormalizedVerdict::Dropped;
373
374        let q = HuntQuery {
375            verdict: Some(QueryVerdict::Dropped),
376            ..Default::default()
377        };
378        assert!(q.matches(&event));
379
380        let q2 = HuntQuery {
381            verdict: Some(QueryVerdict::Deny),
382            ..Default::default()
383        };
384        assert!(!q2.matches(&event));
385    }
386
387    #[test]
388    fn hunt_query_default() {
389        let q = HuntQuery::default();
390        assert!(q.sources.is_empty());
391        assert!(q.verdict.is_none());
392        assert!(q.start.is_none());
393        assert!(q.end.is_none());
394        assert_eq!(q.limit, 100);
395    }
396
397    #[test]
398    fn hunt_query_effective_sources_empty() {
399        let q = HuntQuery::default();
400        assert_eq!(q.effective_sources(), EventSource::all());
401    }
402
403    #[test]
404    fn hunt_query_effective_sources_specified() {
405        let q = HuntQuery {
406            sources: vec![EventSource::Tetragon],
407            ..Default::default()
408        };
409        assert_eq!(q.effective_sources(), vec![EventSource::Tetragon]);
410    }
411
412    #[test]
413    fn hunt_query_effective_sources_deduplicates_preserving_order() {
414        let q = HuntQuery {
415            sources: vec![
416                EventSource::Receipt,
417                EventSource::Receipt,
418                EventSource::Hubble,
419                EventSource::Receipt,
420                EventSource::Hubble,
421            ],
422            ..Default::default()
423        };
424        assert_eq!(
425            q.effective_sources(),
426            vec![EventSource::Receipt, EventSource::Hubble]
427        );
428    }
429
430    #[test]
431    fn hunt_query_matches_all_default() {
432        let q = HuntQuery::default();
433        let event = make_event();
434        assert!(q.matches(&event));
435    }
436
437    #[test]
438    fn hunt_query_matches_source_filter() {
439        let q = HuntQuery {
440            sources: vec![EventSource::Hubble],
441            ..Default::default()
442        };
443        let event = make_event(); // source is Tetragon
444        assert!(!q.matches(&event));
445
446        let q2 = HuntQuery {
447            sources: vec![EventSource::Tetragon],
448            ..Default::default()
449        };
450        assert!(q2.matches(&event));
451    }
452
453    #[test]
454    fn hunt_query_matches_verdict_filter() {
455        let q = HuntQuery {
456            verdict: Some(QueryVerdict::Deny),
457            ..Default::default()
458        };
459        let event = make_event(); // verdict is Allow
460        assert!(!q.matches(&event));
461
462        let q2 = HuntQuery {
463            verdict: Some(QueryVerdict::Allow),
464            ..Default::default()
465        };
466        assert!(q2.matches(&event));
467    }
468
469    #[test]
470    fn hunt_query_matches_time_range() {
471        let event = make_event(); // 2025-06-15 12:00:00
472
473        let q = HuntQuery {
474            start: Some(Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap()),
475            ..Default::default()
476        };
477        assert!(!q.matches(&event));
478
479        let q2 = HuntQuery {
480            end: Some(Utc.with_ymd_and_hms(2025, 6, 15, 11, 0, 0).unwrap()),
481            ..Default::default()
482        };
483        assert!(!q2.matches(&event));
484
485        let q3 = HuntQuery {
486            start: Some(Utc.with_ymd_and_hms(2025, 6, 15, 11, 0, 0).unwrap()),
487            end: Some(Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap()),
488            ..Default::default()
489        };
490        assert!(q3.matches(&event));
491    }
492
493    #[test]
494    fn hunt_query_matches_action_type() {
495        let q = HuntQuery {
496            action_type: Some("PROCESS".to_string()),
497            ..Default::default()
498        };
499        let event = make_event(); // action_type is "process"
500        assert!(q.matches(&event)); // case-insensitive
501    }
502
503    #[test]
504    fn hunt_query_matches_process_contains() {
505        let q = HuntQuery {
506            process: Some("curl".to_string()),
507            ..Default::default()
508        };
509        let event = make_event(); // process is "/usr/bin/curl"
510        assert!(q.matches(&event)); // contains match
511    }
512
513    #[test]
514    fn hunt_query_matches_namespace() {
515        let q = HuntQuery {
516            namespace: Some("kube-system".to_string()),
517            ..Default::default()
518        };
519        let event = make_event(); // namespace is "default"
520        assert!(!q.matches(&event));
521    }
522
523    #[test]
524    fn hunt_query_matches_pod_contains() {
525        let q = HuntQuery {
526            pod: Some("agent-pod".to_string()),
527            ..Default::default()
528        };
529        let event = make_event(); // pod is "agent-pod-abc123"
530        assert!(q.matches(&event)); // contains match
531    }
532
533    #[test]
534    fn hunt_query_matches_combined_predicates() {
535        let q = HuntQuery {
536            sources: vec![EventSource::Tetragon],
537            verdict: Some(QueryVerdict::Allow),
538            process: Some("curl".to_string()),
539            namespace: Some("default".to_string()),
540            ..Default::default()
541        };
542        let event = make_event();
543        assert!(q.matches(&event));
544    }
545
546    #[test]
547    fn hunt_query_no_match_missing_optional_field() {
548        let mut event = make_event();
549        event.process = None;
550
551        let q = HuntQuery {
552            process: Some("curl".to_string()),
553            ..Default::default()
554        };
555        assert!(!q.matches(&event));
556    }
557
558    #[test]
559    fn hunt_query_entity_matches_pod() {
560        let q = HuntQuery {
561            entity: Some("agent-pod".to_string()),
562            ..Default::default()
563        };
564        let event = make_event(); // pod is "agent-pod-abc123"
565        assert!(q.matches(&event));
566    }
567
568    #[test]
569    fn hunt_query_entity_matches_namespace() {
570        let q = HuntQuery {
571            entity: Some("default".to_string()),
572            ..Default::default()
573        };
574        let event = make_event(); // namespace is "default"
575        assert!(q.matches(&event));
576    }
577
578    #[test]
579    fn hunt_query_entity_no_match() {
580        let q = HuntQuery {
581            entity: Some("nonexistent".to_string()),
582            ..Default::default()
583        };
584        let event = make_event();
585        assert!(!q.matches(&event));
586    }
587}