1use std::collections::HashMap;
4use std::fmt;
5
6use rsigma_parser::{
7 Exemplar, ExemplarErrorKind, ExemplarPayload, ExemplarRuleKind, ExemplarShapeError, Expect,
8 SigmaCollection, SigmaRule, correlation_exemplars, exemplars, filter_exemplars,
9};
10use serde::Serialize;
11
12use crate::compiler::yaml_to_json;
13use crate::correlation_engine::{CorrelationConfig, CorrelationEngine};
14use crate::engine::Engine;
15use crate::error::EvalError;
16use crate::event::JsonEvent;
17use crate::pipeline::Pipeline;
18use crate::result::EvaluationResult;
19
20const BASE_TIMESTAMP: i64 = 1_700_000_000;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct ExemplarResult {
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub rule_id: Option<String>,
29 pub rule_title: String,
31 pub rule_kind: ExemplarRuleKind,
33 pub index: usize,
35 pub name: String,
37 pub expect: Expect,
39 pub actual: Expect,
41 pub passed: bool,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub diagnostic: Option<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50pub struct MissingExemplars {
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub rule_id: Option<String>,
54 pub rule_title: String,
56 pub rule_kind: ExemplarRuleKind,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
62pub struct ExemplarReport {
63 #[serde(skip_serializing_if = "String::is_empty")]
65 pub source: String,
66 pub results: Vec<ExemplarResult>,
68 #[serde(skip_serializing_if = "Vec::is_empty")]
70 pub missing: Vec<MissingExemplars>,
71}
72
73impl ExemplarReport {
74 pub fn all_passed(&self) -> bool {
76 self.results.iter().all(|r| r.passed)
77 }
78
79 pub fn failures(&self) -> impl Iterator<Item = &ExemplarResult> {
81 self.results.iter().filter(|r| !r.passed)
82 }
83}
84
85#[derive(Debug)]
87pub enum ExemplarRunError {
88 Shape {
90 rule: String,
92 errors: Vec<ExemplarShapeError>,
94 },
95 AmbiguousTitle(String),
97 Reference(String),
99 Compile(EvalError),
101}
102
103impl fmt::Display for ExemplarRunError {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 Self::Shape { rule, errors } => {
107 write!(f, "invalid exemplars on '{rule}': ")?;
108 let messages: Vec<String> = errors.iter().map(ToString::to_string).collect();
109 f.write_str(&messages.join("; "))
110 }
111 Self::AmbiguousTitle(title) => {
112 write!(
113 f,
114 "ambiguous rule title '{title}': add an id or make the title unique"
115 )
116 }
117 Self::Reference(msg) => f.write_str(msg),
118 Self::Compile(err) => write!(f, "{err}"),
119 }
120 }
121}
122
123impl std::error::Error for ExemplarRunError {}
124
125impl From<EvalError> for ExemplarRunError {
126 fn from(err: EvalError) -> Self {
127 match err {
128 EvalError::UnknownRuleRef(r) => Self::Reference(format!("unknown rule reference: {r}")),
129 other => Self::Compile(other),
130 }
131 }
132}
133
134pub fn run_exemplars(
136 collection: &SigmaCollection,
137 pipelines: &[Pipeline],
138) -> Result<ExemplarReport, ExemplarRunError> {
139 let titles = title_counts(collection);
140 let mut results = Vec::new();
141 let mut missing = Vec::new();
142
143 for rule in &collection.rules {
144 let identity = TargetIdentity {
145 id: rule.id.clone(),
146 title: rule.title.clone(),
147 kind: ExemplarRuleKind::Detection,
148 };
149 let list = exemplars(rule).map_err(|errors| ExemplarRunError::Shape {
150 rule: identity.label(),
151 errors,
152 })?;
153 if list.is_empty() {
154 missing.push(identity.to_missing());
155 continue;
156 }
157 identity.require_unique(&titles)?;
158 for exemplar in list {
159 results.push(run_detection(
160 collection, rule, &identity, &exemplar, pipelines,
161 )?);
162 }
163 }
164
165 for rule in &collection.correlations {
166 let identity = TargetIdentity {
167 id: rule.id.clone(),
168 title: rule.title.clone(),
169 kind: ExemplarRuleKind::Correlation,
170 };
171 let list = correlation_exemplars(rule).map_err(|errors| ExemplarRunError::Shape {
172 rule: identity.label(),
173 errors,
174 })?;
175 if list.is_empty() {
176 missing.push(identity.to_missing());
177 continue;
178 }
179 identity.require_unique(&titles)?;
180 for exemplar in list {
181 results.push(run_correlation(
182 collection, &identity, &exemplar, pipelines,
183 )?);
184 }
185 }
186
187 for rule in &collection.filters {
188 if let Err(errors) = filter_exemplars(rule) {
189 return Err(ExemplarRunError::Shape {
190 rule: rule.id.clone().unwrap_or_else(|| rule.title.clone()),
191 errors,
192 });
193 }
194 }
195
196 Ok(ExemplarReport {
197 source: String::new(),
198 results,
199 missing,
200 })
201}
202
203struct TargetIdentity {
204 id: Option<String>,
205 title: String,
206 kind: ExemplarRuleKind,
207}
208
209impl TargetIdentity {
210 fn label(&self) -> String {
211 self.id.clone().unwrap_or_else(|| self.title.clone())
212 }
213
214 fn require_unique(&self, titles: &HashMap<String, usize>) -> Result<(), ExemplarRunError> {
215 if self.id.is_some() {
216 return Ok(());
217 }
218 if titles.get(&self.title).copied().unwrap_or(0) > 1 {
219 return Err(ExemplarRunError::AmbiguousTitle(self.title.clone()));
220 }
221 Ok(())
222 }
223
224 fn to_missing(&self) -> MissingExemplars {
225 MissingExemplars {
226 rule_id: self.id.clone(),
227 rule_title: self.title.clone(),
228 rule_kind: self.kind,
229 }
230 }
231
232 fn matches(&self, result: &EvaluationResult) -> bool {
233 let kind_ok = match self.kind {
234 ExemplarRuleKind::Detection => result.is_detection(),
235 ExemplarRuleKind::Correlation => result.is_correlation(),
236 ExemplarRuleKind::Filter => false,
237 };
238 if !kind_ok {
239 return false;
240 }
241 match &self.id {
242 Some(id) => result.header.rule_id.as_deref() == Some(id.as_str()),
243 None => result.header.rule_title == self.title,
244 }
245 }
246}
247
248fn title_counts(collection: &SigmaCollection) -> HashMap<String, usize> {
249 let mut counts = HashMap::new();
250 for title in collection
251 .rules
252 .iter()
253 .map(|r| r.title.as_str())
254 .chain(collection.correlations.iter().map(|r| r.title.as_str()))
255 {
256 *counts.entry(title.to_string()).or_insert(0) += 1;
257 }
258 counts
259}
260
261fn run_detection(
262 collection: &SigmaCollection,
263 rule: &SigmaRule,
264 identity: &TargetIdentity,
265 exemplar: &Exemplar,
266 pipelines: &[Pipeline],
267) -> Result<ExemplarResult, ExemplarRunError> {
268 let ExemplarPayload::Event(event) = &exemplar.payload else {
269 return Err(ExemplarRunError::Shape {
270 rule: identity.label(),
271 errors: vec![ExemplarShapeError {
272 path: format!("/custom_attributes/rsigma.exemplars/{}", exemplar.index),
273 message: "detection exemplars must use 'event'".to_string(),
274 kind: ExemplarErrorKind::WrongRuleKind,
275 }],
276 });
277 };
278 let synthetic = SigmaCollection {
279 rules: vec![rule.clone()],
280 correlations: Vec::new(),
281 filters: collection.filters.clone(),
282 errors: Vec::new(),
283 };
284 let mut engine = Engine::new();
285 if pipelines.is_empty() {
286 engine.add_collection(&synthetic)?;
287 } else {
288 engine.add_collection_with_pipelines(&synthetic, pipelines)?;
289 }
290 let json = yaml_to_json(event);
291 let je = JsonEvent::borrow(&json);
292 let matches = engine.evaluate(&je);
293 let fired = matches.iter().any(|r| identity.matches(r));
294 let diagnostic = match (fired, exemplar.expect) {
295 (true, Expect::NoMatch) => Some("the rule matched the event".to_string()),
296 (false, Expect::Match) => Some("the rule did not match the event".to_string()),
297 _ => None,
298 };
299 Ok(outcome(identity, exemplar, fired, diagnostic))
300}
301
302fn run_correlation(
303 collection: &SigmaCollection,
304 identity: &TargetIdentity,
305 exemplar: &Exemplar,
306 pipelines: &[Pipeline],
307) -> Result<ExemplarResult, ExemplarRunError> {
308 let ExemplarPayload::Sequence(events) = &exemplar.payload else {
309 return Err(ExemplarRunError::Shape {
310 rule: identity.label(),
311 errors: vec![ExemplarShapeError {
312 path: format!("/custom_attributes/rsigma.exemplars/{}", exemplar.index),
313 message: "correlation exemplars must use 'events'".to_string(),
314 kind: ExemplarErrorKind::WrongRuleKind,
315 }],
316 });
317 };
318 let mut engine = CorrelationEngine::new(CorrelationConfig::default());
319 for pipeline in pipelines {
320 engine.add_pipeline(pipeline.clone());
321 }
322 engine.add_collection(collection)?;
323 let owned: Vec<serde_json::Value> = events.iter().map(|e| yaml_to_json(&e.event)).collect();
324 let mut first_fire: Option<(usize, String)> = None;
325 for (index, (timed, json)) in events.iter().zip(owned.iter()).enumerate() {
326 let je = JsonEvent::borrow(json);
327 let ts = BASE_TIMESTAMP.saturating_add(timed.offset.seconds as i64);
328 let results = engine.process_event_at(&je, ts);
329 if first_fire.is_none() && results.iter().any(|r| identity.matches(r)) {
330 first_fire = Some((index, timed.offset.original.clone()));
331 }
332 }
333 let fired = first_fire.is_some();
334 let diagnostic = match (first_fire, exemplar.expect) {
335 (Some((index, offset)), Expect::NoMatch) => Some(format!(
336 "the correlation fired at event index {index} (offset {offset})"
337 )),
338 (None, Expect::Match) => Some(format!(
339 "the correlation never fired across {} events",
340 events.len()
341 )),
342 _ => None,
343 };
344 Ok(outcome(identity, exemplar, fired, diagnostic))
345}
346
347fn outcome(
348 identity: &TargetIdentity,
349 exemplar: &Exemplar,
350 fired: bool,
351 diagnostic: Option<String>,
352) -> ExemplarResult {
353 let actual = if fired {
354 Expect::Match
355 } else {
356 Expect::NoMatch
357 };
358 ExemplarResult {
359 rule_id: identity.id.clone(),
360 rule_title: identity.title.clone(),
361 rule_kind: identity.kind,
362 index: exemplar.index,
363 name: exemplar.name.clone(),
364 expect: exemplar.expect,
365 actual,
366 passed: actual == exemplar.expect,
367 diagnostic,
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use rsigma_parser::parse_sigma_yaml;
375
376 fn collection(yaml: &str) -> SigmaCollection {
377 parse_sigma_yaml(yaml).unwrap()
378 }
379
380 fn run(yaml: &str) -> ExemplarReport {
381 run_exemplars(&collection(yaml), &[]).expect("run")
382 }
383
384 const DETECTION: &str = r#"
385title: Whoami
386id: 11111111-2222-3333-4444-555555555555
387logsource:
388 category: process_creation
389 product: windows
390detection:
391 selection:
392 CommandLine|contains: whoami
393 condition: selection
394custom_attributes:
395 rsigma.exemplars:
396 - name: whoami fires
397 expect: match
398 event:
399 CommandLine: whoami /all
400 - name: benign hostname
401 expect: no-match
402 event:
403 CommandLine: hostname
404"#;
405
406 #[test]
407 fn detection_match_and_no_match() {
408 let report = run(DETECTION);
409 assert!(report.all_passed());
410 assert_eq!(report.results.len(), 2);
411 assert_eq!(report.results[0].actual, Expect::Match);
412 assert_eq!(report.results[1].actual, Expect::NoMatch);
413 assert!(report.missing.is_empty());
414 }
415
416 #[test]
417 fn failed_assertion_is_not_a_run_error() {
418 let yaml = r#"
419title: Whoami
420id: 11111111-2222-3333-4444-555555555555
421logsource:
422 category: process_creation
423detection:
424 selection:
425 CommandLine|contains: whoami
426 condition: selection
427custom_attributes:
428 rsigma.exemplars:
429 - expect: match
430 event:
431 CommandLine: hostname
432"#;
433 let report = run(yaml);
434 assert!(!report.all_passed());
435 assert_eq!(report.results[0].actual, Expect::NoMatch);
436 assert_eq!(
437 report.results[0].diagnostic.as_deref(),
438 Some("the rule did not match the event")
439 );
440 }
441
442 #[test]
443 fn passing_exemplars_carry_no_diagnostic() {
444 let report = run(DETECTION);
445 assert!(report.results.iter().all(|r| r.diagnostic.is_none()));
446 }
447
448 #[test]
449 fn failed_correlation_no_match_names_the_firing_event() {
450 let yaml = r#"
451title: Login
452id: login-rule
453logsource:
454 category: auth
455detection:
456 selection:
457 EventType: login
458 condition: selection
459---
460title: Many Logins
461correlation:
462 type: event_count
463 rules:
464 - login-rule
465 group-by:
466 - User
467 timespan: 60s
468 condition:
469 gte: 2
470custom_attributes:
471 rsigma.exemplars:
472 - expect: no-match
473 events:
474 - offset: 0s
475 event: { EventType: login, User: alice }
476 - offset: 30s
477 event: { EventType: login, User: alice }
478"#;
479 let report = run(yaml);
480 assert!(!report.all_passed());
481 assert_eq!(
482 report.results[0].diagnostic.as_deref(),
483 Some("the correlation fired at event index 1 (offset 30s)")
484 );
485 }
486
487 #[test]
488 fn suppression_and_reset_do_not_mask_the_first_fire() {
489 let yaml = r#"
490title: Login
491id: login-rule
492logsource:
493 category: auth
494detection:
495 selection:
496 EventType: login
497 condition: selection
498---
499title: Many Logins
500correlation:
501 type: event_count
502 rules:
503 - login-rule
504 group-by:
505 - User
506 timespan: 60s
507 condition:
508 gte: 2
509custom_attributes:
510 rsigma.suppress: 5m
511 rsigma.action: reset
512 rsigma.exemplars:
513 - name: fires despite suppression
514 expect: match
515 events:
516 - offset: 0s
517 event: { EventType: login, User: alice }
518 - offset: 1s
519 event: { EventType: login, User: alice }
520 - offset: 2s
521 event: { EventType: login, User: alice }
522 - offset: 3s
523 event: { EventType: login, User: alice }
524 - name: single event stays quiet
525 expect: no-match
526 events:
527 - offset: 0s
528 event: { EventType: login, User: bob }
529"#;
530 let report = run(yaml);
531 assert!(report.all_passed(), "{report:?}");
532 assert_eq!(report.results.len(), 2);
533 }
534
535 #[test]
536 fn filter_excludes_matching_event() {
537 let yaml = r#"
538title: Whoami
539id: 11111111-2222-3333-4444-555555555555
540logsource:
541 category: process_creation
542 product: windows
543detection:
544 selection:
545 CommandLine|contains: whoami
546 condition: selection
547custom_attributes:
548 rsigma.exemplars:
549 - name: alice fires
550 expect: match
551 event:
552 CommandLine: whoami /all
553 User: alice
554 - name: system filtered
555 expect: no-match
556 event:
557 CommandLine: whoami /all
558 User: SYSTEM
559---
560title: Exclude SYSTEM
561logsource:
562 category: process_creation
563 product: windows
564filter:
565 rules:
566 - 11111111-2222-3333-4444-555555555555
567 selection:
568 User: SYSTEM
569 condition: not selection
570"#;
571 let report = run(yaml);
572 assert!(report.all_passed(), "{report:?}");
573 }
574
575 #[test]
576 fn pipeline_rewrites_rule_fields() {
577 let yaml = r#"
578title: Whoami
579id: 11111111-2222-3333-4444-555555555555
580logsource:
581 category: process_creation
582detection:
583 selection:
584 CommandLine|contains: whoami
585 condition: selection
586custom_attributes:
587 rsigma.exemplars:
588 - expect: match
589 event:
590 process.command_line: whoami /all
591"#;
592 let pipeline = crate::parse_pipeline(
593 r#"
594name: map
595priority: 10
596transformations:
597 - type: field_name_mapping
598 mapping:
599 CommandLine: process.command_line
600"#,
601 )
602 .unwrap();
603 let report = run_exemplars(&collection(yaml), &[pipeline]).unwrap();
604 assert!(report.all_passed(), "{report:?}");
605 }
606
607 #[test]
608 fn event_count_correlation() {
609 let yaml = r#"
610title: Login
611id: login-rule
612logsource:
613 category: auth
614detection:
615 selection:
616 EventType: login
617 condition: selection
618---
619title: Many Logins
620id: many-logins
621correlation:
622 type: event_count
623 rules:
624 - login-rule
625 group-by:
626 - User
627 timespan: 60s
628 condition:
629 gte: 3
630custom_attributes:
631 rsigma.exemplars:
632 - name: burst
633 expect: match
634 events:
635 - offset: 0s
636 event: { EventType: login, User: alice }
637 - offset: 1s
638 event: { EventType: login, User: alice }
639 - offset: 2s
640 event: { EventType: login, User: alice }
641 - name: too few
642 expect: no-match
643 events:
644 - offset: 0s
645 event: { EventType: login, User: bob }
646 - offset: 1s
647 event: { EventType: login, User: bob }
648"#;
649 let report = run(yaml);
650 assert!(report.all_passed(), "{report:?}");
651 assert_eq!(report.missing.len(), 1);
652 assert_eq!(report.missing[0].rule_title, "Login");
653 }
654
655 #[test]
656 fn value_count_correlation() {
657 let yaml = r#"
658title: Login
659id: login-rule
660logsource:
661 category: auth
662detection:
663 selection:
664 EventType: login
665 condition: selection
666---
667title: Distinct Hosts
668correlation:
669 type: value_count
670 rules:
671 - login-rule
672 group-by:
673 - User
674 timespan: 60s
675 condition:
676 field: Host
677 gte: 2
678custom_attributes:
679 rsigma.exemplars:
680 - expect: match
681 events:
682 - offset: 0s
683 event: { EventType: login, User: alice, Host: a }
684 - offset: 1s
685 event: { EventType: login, User: alice, Host: b }
686"#;
687 let report = run(yaml);
688 assert!(report.all_passed(), "{report:?}");
689 }
690
691 #[test]
692 fn temporal_correlation() {
693 let yaml = r#"
694title: Failed
695id: failed-login
696logsource:
697 category: auth
698detection:
699 selection:
700 EventType: failed
701 condition: selection
702---
703title: Success
704id: success-login
705logsource:
706 category: auth
707detection:
708 selection:
709 EventType: success
710 condition: selection
711---
712title: Fail then success
713correlation:
714 type: temporal
715 rules:
716 - failed-login
717 - success-login
718 group-by:
719 - User
720 timespan: 60s
721 condition:
722 gte: 2
723custom_attributes:
724 rsigma.exemplars:
725 - expect: match
726 events:
727 - offset: 0s
728 event: { EventType: failed, User: alice }
729 - offset: 10s
730 event: { EventType: success, User: alice }
731 - expect: no-match
732 events:
733 - offset: 0s
734 event: { EventType: failed, User: bob }
735"#;
736 let report = run(yaml);
737 assert!(report.all_passed(), "{report:?}");
738 }
739
740 #[test]
741 fn exemplars_do_not_share_state() {
742 let yaml = r#"
743title: Login
744id: login-rule
745logsource:
746 category: auth
747detection:
748 selection:
749 EventType: login
750 condition: selection
751---
752title: Many Logins
753correlation:
754 type: event_count
755 rules:
756 - login-rule
757 group-by:
758 - User
759 timespan: 60s
760 condition:
761 gte: 3
762custom_attributes:
763 rsigma.exemplars:
764 - name: first burst
765 expect: match
766 events:
767 - offset: 0s
768 event: { EventType: login, User: alice }
769 - offset: 1s
770 event: { EventType: login, User: alice }
771 - offset: 2s
772 event: { EventType: login, User: alice }
773 - name: second burst
774 expect: match
775 events:
776 - offset: 0s
777 event: { EventType: login, User: alice }
778 - offset: 1s
779 event: { EventType: login, User: alice }
780 - offset: 2s
781 event: { EventType: login, User: alice }
782"#;
783 let report = run(yaml);
784 assert!(report.all_passed(), "{report:?}");
785 assert_eq!(report.results.len(), 2);
786 }
787
788 #[test]
789 fn unrelated_detection_matches_do_not_count() {
790 let yaml = r#"
791title: Login
792id: login-rule
793logsource:
794 category: auth
795detection:
796 selection:
797 EventType: login
798 condition: selection
799---
800title: Also login
801id: also-login
802logsource:
803 category: auth
804detection:
805 selection:
806 EventType: login
807 condition: selection
808---
809title: Many Logins
810id: many-logins
811correlation:
812 type: event_count
813 rules:
814 - login-rule
815 group-by:
816 - User
817 timespan: 60s
818 condition:
819 gte: 2
820custom_attributes:
821 rsigma.exemplars:
822 - expect: match
823 events:
824 - offset: 0s
825 event: { EventType: login, User: alice }
826 - offset: 1s
827 event: { EventType: login, User: alice }
828"#;
829 let report = run(yaml);
830 assert!(report.all_passed(), "{report:?}");
831 assert_eq!(report.results[0].rule_id.as_deref(), Some("many-logins"));
832 }
833
834 #[test]
835 fn missing_correlation_ref_is_config_error() {
836 let yaml = r#"
837title: Many Logins
838correlation:
839 type: event_count
840 rules:
841 - missing-rule
842 timespan: 60s
843 condition:
844 gte: 2
845custom_attributes:
846 rsigma.exemplars:
847 - expect: match
848 events:
849 - offset: 0s
850 event: { EventType: login }
851 - offset: 1s
852 event: { EventType: login }
853"#;
854 let err = run_exemplars(&collection(yaml), &[]).unwrap_err();
855 assert!(matches!(err, ExemplarRunError::Reference(_)), "{err}");
856 }
857
858 #[test]
859 fn duplicate_titles_without_id_are_rejected() {
860 let yaml = r#"
861title: Dup
862logsource:
863 category: test
864detection:
865 selection:
866 field: a
867 condition: selection
868custom_attributes:
869 rsigma.exemplars:
870 - expect: match
871 event:
872 field: a
873---
874title: Dup
875logsource:
876 category: test
877detection:
878 selection:
879 field: b
880 condition: selection
881"#;
882 let err = run_exemplars(&collection(yaml), &[]).unwrap_err();
883 assert!(matches!(err, ExemplarRunError::AmbiguousTitle(_)), "{err}");
884 }
885
886 #[test]
887 fn malformed_exemplars_are_rejected() {
888 let yaml = r#"
889title: Whoami
890logsource:
891 category: test
892detection:
893 selection:
894 field: value
895 condition: selection
896custom_attributes:
897 rsigma.exemplars:
898 - expect: banana
899 event:
900 field: value
901"#;
902 let err = run_exemplars(&collection(yaml), &[]).unwrap_err();
903 assert!(matches!(err, ExemplarRunError::Shape { .. }), "{err}");
904 }
905
906 #[test]
907 fn filter_exemplars_are_rejected() {
908 let yaml = r#"
909title: F
910logsource:
911 category: test
912filter:
913 selection:
914 User: SYSTEM
915 condition: selection
916custom_attributes:
917 rsigma.exemplars:
918 - expect: match
919 event:
920 User: SYSTEM
921"#;
922 let err = run_exemplars(&collection(yaml), &[]).unwrap_err();
923 assert!(matches!(err, ExemplarRunError::Shape { .. }), "{err}");
924 }
925}