1use chrono::{DateTime, Utc};
4use serde::Serialize;
5
6use crate::timeline::{NormalizedVerdict, TimelineEvent};
7
8#[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 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 pub fn parse_list(s: &str) -> Vec<Self> {
32 s.split(',')
33 .filter_map(|part| Self::parse(part.trim()))
34 .collect()
35 }
36
37 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 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 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#[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 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#[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 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 pub fn matches(&self, event: &TimelineEvent) -> bool {
149 if !self.sources.is_empty() && !self.sources.contains(&event.source) {
151 return false;
152 }
153
154 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 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 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 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(); 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(); 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(); 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(); assert!(q.matches(&event)); }
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(); assert!(q.matches(&event)); }
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(); 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(); assert!(q.matches(&event)); }
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(); 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(); 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}