1use std::collections::HashMap;
10use std::fmt;
11
12use serde::Serialize;
13use yaml_serde::Value;
14
15use crate::ast::{CorrelationRule, FilterRule, SigmaRule};
16use crate::value::Timespan;
17
18pub const EXEMPLARS_KEY: &str = "rsigma.exemplars";
20
21const ENTRY_KEYS: &[&str] = &["name", "expect", "event", "events"];
23
24const TIMED_KEYS: &[&str] = &["offset", "event"];
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ExemplarRuleKind {
31 Detection,
33 Correlation,
35 Filter,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum Expect {
43 Match,
45 NoMatch,
47}
48
49impl Expect {
50 pub fn parse(s: &str) -> Option<Self> {
52 match s {
53 "match" => Some(Self::Match),
54 "no-match" => Some(Self::NoMatch),
55 _ => None,
56 }
57 }
58
59 pub fn as_str(self) -> &'static str {
61 match self {
62 Self::Match => "match",
63 Self::NoMatch => "no-match",
64 }
65 }
66}
67
68impl fmt::Display for Expect {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 f.write_str(self.as_str())
71 }
72}
73
74#[derive(Debug, Clone, PartialEq)]
76pub struct TimedEvent {
77 pub offset: Timespan,
79 pub event: Value,
81}
82
83#[derive(Debug, Clone, PartialEq)]
85pub enum ExemplarPayload {
86 Event(Value),
88 Sequence(Vec<TimedEvent>),
90}
91
92#[derive(Debug, Clone, PartialEq)]
94pub struct Exemplar {
95 pub name: String,
97 pub index: usize,
99 pub expect: Expect,
101 pub payload: ExemplarPayload,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
107#[serde(rename_all = "snake_case")]
108pub enum ExemplarErrorKind {
109 Structural,
111 WrongRuleKind,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
117pub struct ExemplarShapeError {
118 pub path: String,
120 pub message: String,
122 pub kind: ExemplarErrorKind,
124}
125
126impl fmt::Display for ExemplarShapeError {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 write!(f, "{}: {}", self.path, self.message)
129 }
130}
131
132impl ExemplarShapeError {
133 pub fn is_wrong_rule_kind(&self) -> bool {
135 self.kind == ExemplarErrorKind::WrongRuleKind
136 }
137}
138
139pub fn exemplars(rule: &SigmaRule) -> Result<Vec<Exemplar>, Vec<ExemplarShapeError>> {
141 exemplars_from_attrs(&rule.custom_attributes, ExemplarRuleKind::Detection)
142}
143
144pub fn correlation_exemplars(
146 rule: &CorrelationRule,
147) -> Result<Vec<Exemplar>, Vec<ExemplarShapeError>> {
148 exemplars_from_attrs(&rule.custom_attributes, ExemplarRuleKind::Correlation)
149}
150
151pub fn filter_exemplars(rule: &FilterRule) -> Result<Vec<Exemplar>, Vec<ExemplarShapeError>> {
153 exemplars_from_attrs(&rule.custom_attributes, ExemplarRuleKind::Filter)
154}
155
156pub fn exemplars_from_attrs(
158 attrs: &HashMap<String, Value>,
159 kind: ExemplarRuleKind,
160) -> Result<Vec<Exemplar>, Vec<ExemplarShapeError>> {
161 let Some(value) = attrs.get(EXEMPLARS_KEY) else {
162 return Ok(Vec::new());
163 };
164 parse_exemplars(value, kind, format!("/custom_attributes/{EXEMPLARS_KEY}"))
165}
166
167pub fn match_exemplar_count(attrs: &HashMap<String, Value>) -> usize {
169 match exemplars_from_attrs(attrs, ExemplarRuleKind::Detection) {
170 Ok(list) => list.iter().filter(|e| e.expect == Expect::Match).count(),
171 Err(_) => 0,
172 }
173}
174
175pub fn match_exemplar_count_json(attrs: &HashMap<String, serde_json::Value>) -> usize {
177 let Some(value) = attrs.get(EXEMPLARS_KEY) else {
178 return 0;
179 };
180 let Ok(yaml) = yaml_serde::to_value(value) else {
181 return 0;
182 };
183 match parse_exemplars(
184 &yaml,
185 ExemplarRuleKind::Detection,
186 EXEMPLARS_KEY.to_string(),
187 ) {
188 Ok(list) => list.iter().filter(|e| e.expect == Expect::Match).count(),
189 Err(_) => 0,
190 }
191}
192
193pub fn raw_exemplar_values(m: &yaml_serde::Mapping) -> Vec<(String, &Value)> {
196 let mut out = Vec::new();
197 if let Some(ca) = m
198 .get(val_key("custom_attributes"))
199 .and_then(|v| v.as_mapping())
200 && let Some(v) = mapping_get(ca, EXEMPLARS_KEY)
201 {
202 out.push((format!("/custom_attributes/{EXEMPLARS_KEY}"), v));
203 }
204 if let Some(v) = mapping_get(m, EXEMPLARS_KEY) {
205 out.push((format!("/{EXEMPLARS_KEY}"), v));
206 }
207 out
208}
209
210pub fn raw_winning_exemplars(m: &yaml_serde::Mapping) -> Option<&Value> {
212 if let Some(ca) = m
213 .get(val_key("custom_attributes"))
214 .and_then(|v| v.as_mapping())
215 && let Some(v) = mapping_get(ca, EXEMPLARS_KEY)
216 {
217 return Some(v);
218 }
219 mapping_get(m, EXEMPLARS_KEY)
220}
221
222pub fn raw_match_exemplar_count(m: &yaml_serde::Mapping) -> usize {
224 let Some(value) = raw_winning_exemplars(m) else {
225 return 0;
226 };
227 match parse_exemplars(
228 value,
229 ExemplarRuleKind::Detection,
230 EXEMPLARS_KEY.to_string(),
231 ) {
232 Ok(list) => list.iter().filter(|e| e.expect == Expect::Match).count(),
233 Err(_) => 0,
234 }
235}
236
237pub fn parse_exemplars(
239 value: &Value,
240 kind: ExemplarRuleKind,
241 path: String,
242) -> Result<Vec<Exemplar>, Vec<ExemplarShapeError>> {
243 let mut errors = Vec::new();
244 let Some(seq) = value.as_sequence() else {
245 return Err(vec![err(
246 path,
247 "rsigma.exemplars must be a sequence of mappings",
248 )]);
249 };
250 if seq.is_empty() {
251 return Err(vec![err(path, "rsigma.exemplars must not be empty")]);
252 }
253 if kind == ExemplarRuleKind::Filter {
254 errors.push(wrong_kind(
255 path.clone(),
256 "filter rules must not carry rsigma.exemplars",
257 ));
258 return Err(errors);
259 }
260
261 let mut seen_names: Vec<String> = Vec::new();
262 let mut exemplars = Vec::new();
263 for (index, item) in seq.iter().enumerate() {
264 let item_path = format!("{path}/{index}");
265 match parse_entry(item, kind, index, &item_path, &mut seen_names) {
266 Ok(exemplar) => exemplars.push(exemplar),
267 Err(mut item_errors) => errors.append(&mut item_errors),
268 }
269 }
270 if errors.is_empty() {
271 Ok(exemplars)
272 } else {
273 Err(errors)
274 }
275}
276
277fn parse_entry(
278 item: &Value,
279 kind: ExemplarRuleKind,
280 index: usize,
281 path: &str,
282 seen_names: &mut Vec<String>,
283) -> Result<Exemplar, Vec<ExemplarShapeError>> {
284 let mut errors = Vec::new();
285 let Some(map) = item.as_mapping() else {
286 return Err(vec![err(
287 path.to_string(),
288 "each exemplar must be a mapping",
289 )]);
290 };
291
292 for key in map.keys() {
293 let Some(ks) = key.as_str() else { continue };
294 if !ENTRY_KEYS.contains(&ks) {
295 errors.push(err(
296 format!("{path}/{ks}"),
297 format!("unknown exemplar key '{ks}'"),
298 ));
299 }
300 }
301
302 let name = match mapping_get(map, "name") {
303 None => index.to_string(),
304 Some(Value::String(s)) => {
305 let trimmed = s.trim();
306 if trimmed.is_empty() {
307 errors.push(err(
308 format!("{path}/name"),
309 "exemplar name must not be blank",
310 ));
311 index.to_string()
312 } else {
313 if seen_names.iter().any(|n| n == trimmed) {
314 errors.push(err(
315 format!("{path}/name"),
316 format!("duplicate exemplar name '{trimmed}'"),
317 ));
318 } else {
319 seen_names.push(trimmed.to_string());
320 }
321 trimmed.to_string()
322 }
323 }
324 Some(_) => {
325 errors.push(err(
326 format!("{path}/name"),
327 "exemplar name must be a string",
328 ));
329 index.to_string()
330 }
331 };
332
333 let expect = match mapping_get(map, "expect") {
334 None => {
335 errors.push(err(format!("{path}/expect"), "missing 'expect'"));
336 Expect::Match
337 }
338 Some(Value::String(s)) => match Expect::parse(s) {
339 Some(e) => e,
340 None => {
341 errors.push(err(
342 format!("{path}/expect"),
343 format!("invalid expect '{s}'; expected match or no-match"),
344 ));
345 Expect::Match
346 }
347 },
348 Some(_) => {
349 errors.push(err(
350 format!("{path}/expect"),
351 "expect must be 'match' or 'no-match'",
352 ));
353 Expect::Match
354 }
355 };
356
357 let has_event = mapping_get(map, "event").is_some();
358 let has_events = mapping_get(map, "events").is_some();
359 let payload = match (has_event, has_events, kind) {
360 (true, true, _) => {
361 errors.push(err(
362 path.to_string(),
363 "exemplar must have exactly one of 'event' or 'events'",
364 ));
365 ExemplarPayload::Event(Value::Null)
366 }
367 (false, false, _) => {
368 errors.push(err(
369 path.to_string(),
370 "exemplar must have exactly one of 'event' or 'events'",
371 ));
372 ExemplarPayload::Event(Value::Null)
373 }
374 (true, false, ExemplarRuleKind::Correlation) => {
375 errors.push(wrong_kind(
376 format!("{path}/event"),
377 "correlation exemplars must use 'events'",
378 ));
379 ExemplarPayload::Event(Value::Null)
380 }
381 (false, true, ExemplarRuleKind::Detection) => {
382 errors.push(wrong_kind(
383 format!("{path}/events"),
384 "detection exemplars must use 'event'",
385 ));
386 ExemplarPayload::Event(Value::Null)
387 }
388 (true, false, ExemplarRuleKind::Detection) => {
389 match parse_event_mapping(mapping_get(map, "event").expect("event present"), path) {
390 Ok(event) => ExemplarPayload::Event(event),
391 Err(e) => {
392 errors.push(e);
393 ExemplarPayload::Event(Value::Null)
394 }
395 }
396 }
397 (false, true, ExemplarRuleKind::Correlation) => {
398 match parse_sequence(mapping_get(map, "events").expect("events present"), path) {
399 Ok(seq) => ExemplarPayload::Sequence(seq),
400 Err(mut seq_errors) => {
401 errors.append(&mut seq_errors);
402 ExemplarPayload::Sequence(Vec::new())
403 }
404 }
405 }
406 (_, _, ExemplarRuleKind::Filter) => ExemplarPayload::Event(Value::Null),
407 };
408
409 if errors.is_empty() {
410 Ok(Exemplar {
411 name,
412 index,
413 expect,
414 payload,
415 })
416 } else {
417 Err(errors)
418 }
419}
420
421fn parse_event_mapping(value: &Value, path: &str) -> Result<Value, ExemplarShapeError> {
422 match value {
423 Value::Mapping(_) => Ok(value.clone()),
424 _ => Err(err(format!("{path}/event"), "event must be a mapping")),
425 }
426}
427
428fn parse_sequence(value: &Value, path: &str) -> Result<Vec<TimedEvent>, Vec<ExemplarShapeError>> {
429 let Some(seq) = value.as_sequence() else {
430 return Err(vec![err(
431 format!("{path}/events"),
432 "events must be a sequence of mappings",
433 )]);
434 };
435 if seq.is_empty() {
436 return Err(vec![err(
437 format!("{path}/events"),
438 "events must not be empty",
439 )]);
440 }
441
442 let mut errors = Vec::new();
443 let mut events = Vec::new();
444 let mut prev_secs: Option<u64> = None;
445 for (index, item) in seq.iter().enumerate() {
446 let item_path = format!("{path}/events/{index}");
447 match parse_timed_event(item, &item_path) {
448 Ok(event) => {
449 if let Some(prev) = prev_secs
450 && event.offset.seconds < prev
451 {
452 errors.push(err(
453 format!("{item_path}/offset"),
454 "offsets must be non-decreasing",
455 ));
456 }
457 prev_secs = Some(event.offset.seconds);
458 events.push(event);
459 }
460 Err(mut item_errors) => errors.append(&mut item_errors),
461 }
462 }
463 if errors.is_empty() {
464 Ok(events)
465 } else {
466 Err(errors)
467 }
468}
469
470fn parse_timed_event(item: &Value, path: &str) -> Result<TimedEvent, Vec<ExemplarShapeError>> {
471 let mut errors = Vec::new();
472 let Some(map) = item.as_mapping() else {
473 return Err(vec![err(
474 path.to_string(),
475 "each timed event must be a mapping",
476 )]);
477 };
478 for key in map.keys() {
479 let Some(ks) = key.as_str() else { continue };
480 if !TIMED_KEYS.contains(&ks) {
481 errors.push(err(
482 format!("{path}/{ks}"),
483 format!("unknown timed-event key '{ks}'"),
484 ));
485 }
486 }
487
488 let offset = match mapping_get(map, "offset") {
489 None => {
490 errors.push(err(format!("{path}/offset"), "missing 'offset'"));
491 None
492 }
493 Some(Value::String(s)) => match Timespan::parse(s) {
494 Ok(ts) => Some(ts),
495 Err(_) => {
496 errors.push(err(
497 format!("{path}/offset"),
498 format!("invalid timespan '{s}'"),
499 ));
500 None
501 }
502 },
503 Some(_) => {
504 errors.push(err(
505 format!("{path}/offset"),
506 "offset must be a duration string such as 0s or 30s",
507 ));
508 None
509 }
510 };
511
512 let event = match mapping_get(map, "event") {
513 None => {
514 errors.push(err(format!("{path}/event"), "missing 'event'"));
515 None
516 }
517 Some(Value::Mapping(_)) => Some(mapping_get(map, "event").expect("event").clone()),
518 Some(_) => {
519 errors.push(err(format!("{path}/event"), "event must be a mapping"));
520 None
521 }
522 };
523
524 match (offset, event, errors.is_empty()) {
525 (Some(offset), Some(event), true) => Ok(TimedEvent { offset, event }),
526 _ => Err(errors),
527 }
528}
529
530fn err(path: impl Into<String>, message: impl Into<String>) -> ExemplarShapeError {
531 ExemplarShapeError {
532 path: path.into(),
533 message: message.into(),
534 kind: ExemplarErrorKind::Structural,
535 }
536}
537
538fn wrong_kind(path: impl Into<String>, message: impl Into<String>) -> ExemplarShapeError {
539 ExemplarShapeError {
540 path: path.into(),
541 message: message.into(),
542 kind: ExemplarErrorKind::WrongRuleKind,
543 }
544}
545
546fn val_key(s: &str) -> Value {
547 Value::String(s.to_string())
548}
549
550fn mapping_get<'a>(m: &'a yaml_serde::Mapping, key: &str) -> Option<&'a Value> {
551 m.get(val_key(key)).or_else(|| {
552 m.iter()
553 .find_map(|(k, v)| (k.as_str() == Some(key)).then_some(v))
554 })
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use crate::parse_sigma_yaml;
561
562 fn rule(yaml: &str) -> SigmaRule {
563 parse_sigma_yaml(yaml).unwrap().rules.pop().unwrap()
564 }
565
566 fn corr(yaml: &str) -> CorrelationRule {
567 parse_sigma_yaml(yaml).unwrap().correlations.pop().unwrap()
568 }
569
570 const DETECTION: &str = r#"
571title: Whoami
572id: 11111111-2222-3333-4444-555555555555
573logsource:
574 category: process_creation
575 product: windows
576detection:
577 selection:
578 CommandLine|contains: whoami
579 condition: selection
580custom_attributes:
581 rsigma.exemplars:
582 - name: whoami fires
583 expect: match
584 event:
585 CommandLine: whoami /all
586 - name: benign hostname
587 expect: no-match
588 event:
589 CommandLine: hostname
590"#;
591
592 const CORRELATION: &str = r#"
593title: Login
594id: login-rule
595logsource:
596 category: auth
597detection:
598 selection:
599 EventType: login
600 condition: selection
601---
602title: Burst
603correlation:
604 type: event_count
605 rules: [login-rule]
606 group-by: [User]
607 timespan: 1m
608 condition: { gte: 2 }
609custom_attributes:
610 rsigma.exemplars:
611 - name: burst of failures
612 expect: match
613 events:
614 - offset: 0s
615 event: { EventType: login, User: alice }
616 - offset: 30s
617 event: { EventType: login, User: alice }
618"#;
619
620 #[test]
621 fn extracts_detection_exemplars() {
622 let list = exemplars(&rule(DETECTION)).unwrap();
623 assert_eq!(list.len(), 2);
624 assert_eq!(list[0].name, "whoami fires");
625 assert_eq!(list[0].expect, Expect::Match);
626 assert!(matches!(list[0].payload, ExemplarPayload::Event(_)));
627 assert_eq!(list[1].expect, Expect::NoMatch);
628 assert_eq!(match_exemplar_count(&rule(DETECTION).custom_attributes), 1);
629 }
630
631 #[test]
632 fn extracts_correlation_exemplars() {
633 let list = correlation_exemplars(&corr(CORRELATION)).unwrap();
634 assert_eq!(list.len(), 1);
635 match &list[0].payload {
636 ExemplarPayload::Sequence(events) => {
637 assert_eq!(events.len(), 2);
638 assert_eq!(events[0].offset.original, "0s");
639 assert_eq!(events[1].offset.seconds, 30);
640 }
641 other => panic!("expected sequence, got {other:?}"),
642 }
643 }
644
645 #[test]
646 fn nested_custom_attributes_win_over_top_level() {
647 let parsed = rule(
648 r#"
649title: Precedence
650logsource:
651 category: test
652detection:
653 selection:
654 field: value
655 condition: selection
656rsigma.exemplars:
657 - expect: match
658 event: { field: top }
659custom_attributes:
660 rsigma.exemplars:
661 - name: nested
662 expect: no-match
663 event: { field: nested }
664"#,
665 );
666 let list = exemplars(&parsed).unwrap();
667 assert_eq!(list.len(), 1);
668 assert_eq!(list[0].name, "nested");
669 assert_eq!(list[0].expect, Expect::NoMatch);
670 }
671
672 #[test]
673 fn global_action_inherits_exemplars() {
674 let collection = parse_sigma_yaml(
675 r#"
676action: global
677custom_attributes:
678 rsigma.exemplars:
679 - expect: match
680 event: { field: value }
681---
682title: Inherited
683logsource:
684 category: test
685detection:
686 selection:
687 field: value
688 condition: selection
689"#,
690 )
691 .unwrap();
692 let list = exemplars(&collection.rules[0]).unwrap();
693 assert_eq!(list.len(), 1);
694 assert_eq!(list[0].expect, Expect::Match);
695 }
696
697 #[test]
698 fn rejects_empty_list() {
699 let parsed = rule(
700 r#"
701title: Empty
702logsource:
703 category: test
704detection:
705 selection:
706 field: value
707 condition: selection
708custom_attributes:
709 rsigma.exemplars: []
710"#,
711 );
712 let err = exemplars(&parsed).unwrap_err();
713 assert!(err.iter().any(|e| e.message.contains("must not be empty")));
714 }
715
716 #[test]
717 fn rejects_detection_events_payload() {
718 let parsed = rule(
719 r#"
720title: Wrong kind
721logsource:
722 category: test
723detection:
724 selection:
725 field: value
726 condition: selection
727custom_attributes:
728 rsigma.exemplars:
729 - expect: match
730 events:
731 - offset: 0s
732 event: { field: value }
733"#,
734 );
735 let err = exemplars(&parsed).unwrap_err();
736 assert!(err.iter().any(|e| e.message.contains("must use 'event'")));
737 }
738
739 #[test]
740 fn rejects_decreasing_offsets() {
741 let err = correlation_exemplars(&corr(
742 r#"
743title: Login
744id: login-rule
745logsource:
746 category: auth
747detection:
748 selection:
749 EventType: login
750 condition: selection
751---
752title: Burst
753correlation:
754 type: event_count
755 rules: [login-rule]
756 group-by: [User]
757 timespan: 1m
758 condition: { gte: 2 }
759custom_attributes:
760 rsigma.exemplars:
761 - expect: match
762 events:
763 - offset: 30s
764 event: { EventType: login }
765 - offset: 10s
766 event: { EventType: login }
767"#,
768 ))
769 .unwrap_err();
770 assert!(err.iter().any(|e| e.message.contains("non-decreasing")));
771 }
772
773 #[test]
774 fn rejects_bare_numeric_offsets() {
775 let err = correlation_exemplars(&corr(
776 r#"
777title: Login
778id: login-rule
779logsource:
780 category: auth
781detection:
782 selection:
783 EventType: login
784 condition: selection
785---
786title: Burst
787correlation:
788 type: event_count
789 rules: [login-rule]
790 group-by: [User]
791 timespan: 1m
792 condition: { gte: 2 }
793custom_attributes:
794 rsigma.exemplars:
795 - expect: match
796 events:
797 - offset: 0
798 event: { EventType: login }
799 - offset: 30s
800 event: { EventType: login }
801"#,
802 ))
803 .unwrap_err();
804 assert!(
805 err.iter()
806 .any(|e| e.message.contains("duration string") && !e.is_wrong_rule_kind())
807 );
808 }
809
810 #[test]
811 fn filter_exemplars_are_rejected() {
812 let filter = parse_sigma_yaml(
813 r#"
814title: Exclude
815logsource:
816 category: test
817filter:
818 rules: []
819 selection:
820 field: skip
821 condition: selection
822custom_attributes:
823 rsigma.exemplars:
824 - expect: match
825 event: { field: skip }
826"#,
827 )
828 .unwrap()
829 .filters
830 .pop()
831 .unwrap();
832 let err = filter_exemplars(&filter).unwrap_err();
833 assert!(
834 err.iter()
835 .any(|e| e.message.contains("filter rules must not"))
836 );
837 }
838
839 #[test]
840 fn raw_helper_sees_both_placements() {
841 let value: Value = yaml_serde::from_str(
842 r#"
843title: Both
844rsigma.exemplars:
845 - expect: match
846 event: { a: 1 }
847custom_attributes:
848 rsigma.exemplars:
849 - expect: no-match
850 event: { a: 2 }
851"#,
852 )
853 .unwrap();
854 let m = value.as_mapping().unwrap();
855 assert_eq!(raw_exemplar_values(m).len(), 2);
856 let winning = raw_winning_exemplars(m).unwrap();
857 assert!(
858 winning.as_sequence().unwrap()[0]
859 .as_mapping()
860 .unwrap()
861 .get(val_key("expect"))
862 .unwrap()
863 .as_str()
864 .unwrap()
865 .contains("no-match")
866 );
867 }
868
869 #[test]
870 fn malformed_exemplars_do_not_count_as_ads_validation() {
871 let parsed = rule(
872 r#"
873title: Bad
874logsource:
875 category: test
876detection:
877 selection:
878 field: value
879 condition: selection
880custom_attributes:
881 rsigma.exemplars: not-a-list
882"#,
883 );
884 assert_eq!(match_exemplar_count(&parsed.custom_attributes), 0);
885 }
886}