Skip to main content

rsigma_eval/
exemplar.rs

1//! Replay embedded `rsigma.exemplars` against their host rules.
2
3use 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
20/// Deterministic base timestamp for correlation exemplar offsets.
21const BASE_TIMESTAMP: i64 = 1_700_000_000;
22
23/// One asserted exemplar outcome.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct ExemplarResult {
26    /// Rule `id`, when present.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub rule_id: Option<String>,
29    /// Rule title.
30    pub rule_title: String,
31    /// Detection or correlation.
32    pub rule_kind: ExemplarRuleKind,
33    /// 0-based index in the source list.
34    pub index: usize,
35    /// Display name.
36    pub name: String,
37    /// Expected outcome.
38    pub expect: Expect,
39    /// Observed outcome.
40    pub actual: Expect,
41    /// Whether `actual` matches `expect`.
42    pub passed: bool,
43    /// Why the assertion failed. Absent on passing exemplars.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub diagnostic: Option<String>,
46}
47
48/// A detection or correlation rule that carried no exemplars.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50pub struct MissingExemplars {
51    /// Rule `id`, when present.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub rule_id: Option<String>,
54    /// Rule title.
55    pub rule_title: String,
56    /// Detection or correlation.
57    pub rule_kind: ExemplarRuleKind,
58}
59
60/// Report of every asserted exemplar plus rules with none.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
62pub struct ExemplarReport {
63    /// Caller-supplied source label (path or `inline`).
64    #[serde(skip_serializing_if = "String::is_empty")]
65    pub source: String,
66    /// Per-exemplar outcomes, in collection then list order.
67    pub results: Vec<ExemplarResult>,
68    /// Detection and correlation rules that carried no exemplars.
69    #[serde(skip_serializing_if = "Vec::is_empty")]
70    pub missing: Vec<MissingExemplars>,
71}
72
73impl ExemplarReport {
74    /// True when every asserted exemplar passed.
75    pub fn all_passed(&self) -> bool {
76        self.results.iter().all(|r| r.passed)
77    }
78
79    /// Failed assertions.
80    pub fn failures(&self) -> impl Iterator<Item = &ExemplarResult> {
81        self.results.iter().filter(|r| !r.passed)
82    }
83}
84
85/// Configuration or compilation failure that aborts the run.
86#[derive(Debug)]
87pub enum ExemplarRunError {
88    /// Structurally invalid exemplars on a named rule.
89    Shape {
90        /// Rule title or id used in the diagnostic.
91        rule: String,
92        /// Parser shape errors.
93        errors: Vec<ExemplarShapeError>,
94    },
95    /// Title fallback is not unique in the collection.
96    AmbiguousTitle(String),
97    /// A correlation referenced a rule that is not in the collection.
98    Reference(String),
99    /// A rule or pipeline failed to compile.
100    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
134/// Replay every embedded exemplar in `collection` with fresh engine state.
135pub 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}