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    // The target may be referenced by another correlation, which suppresses
319    // its output by default.
320    let mut engine = CorrelationEngine::new(CorrelationConfig {
321        emit_detections: true,
322        ..CorrelationConfig::default()
323    });
324    for pipeline in pipelines {
325        engine.add_pipeline(pipeline.clone());
326    }
327    engine.add_collection(collection)?;
328    let owned: Vec<serde_json::Value> = events.iter().map(|e| yaml_to_json(&e.event)).collect();
329    let mut first_fire: Option<(usize, String)> = None;
330    for (index, (timed, json)) in events.iter().zip(owned.iter()).enumerate() {
331        let je = JsonEvent::borrow(json);
332        let ts = BASE_TIMESTAMP.saturating_add(timed.offset.seconds as i64);
333        let results = engine.process_event_at(&je, ts);
334        if first_fire.is_none() && results.iter().any(|r| identity.matches(r)) {
335            first_fire = Some((index, timed.offset.original.clone()));
336        }
337    }
338    let fired = first_fire.is_some();
339    let diagnostic = match (first_fire, exemplar.expect) {
340        (Some((index, offset)), Expect::NoMatch) => Some(format!(
341            "the correlation fired at event index {index} (offset {offset})"
342        )),
343        (None, Expect::Match) => Some(format!(
344            "the correlation never fired across {} events",
345            events.len()
346        )),
347        _ => None,
348    };
349    Ok(outcome(identity, exemplar, fired, diagnostic))
350}
351
352fn outcome(
353    identity: &TargetIdentity,
354    exemplar: &Exemplar,
355    fired: bool,
356    diagnostic: Option<String>,
357) -> ExemplarResult {
358    let actual = if fired {
359        Expect::Match
360    } else {
361        Expect::NoMatch
362    };
363    ExemplarResult {
364        rule_id: identity.id.clone(),
365        rule_title: identity.title.clone(),
366        rule_kind: identity.kind,
367        index: exemplar.index,
368        name: exemplar.name.clone(),
369        expect: exemplar.expect,
370        actual,
371        passed: actual == exemplar.expect,
372        diagnostic,
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use rsigma_parser::parse_sigma_yaml;
380
381    fn collection(yaml: &str) -> SigmaCollection {
382        parse_sigma_yaml(yaml).unwrap()
383    }
384
385    fn run(yaml: &str) -> ExemplarReport {
386        run_exemplars(&collection(yaml), &[]).expect("run")
387    }
388
389    const DETECTION: &str = r#"
390title: Whoami
391id: 11111111-2222-3333-4444-555555555555
392logsource:
393    category: process_creation
394    product: windows
395detection:
396    selection:
397        CommandLine|contains: whoami
398    condition: selection
399custom_attributes:
400    rsigma.exemplars:
401        - name: whoami fires
402          expect: match
403          event:
404              CommandLine: whoami /all
405        - name: benign hostname
406          expect: no-match
407          event:
408              CommandLine: hostname
409"#;
410
411    #[test]
412    fn detection_match_and_no_match() {
413        let report = run(DETECTION);
414        assert!(report.all_passed());
415        assert_eq!(report.results.len(), 2);
416        assert_eq!(report.results[0].actual, Expect::Match);
417        assert_eq!(report.results[1].actual, Expect::NoMatch);
418        assert!(report.missing.is_empty());
419    }
420
421    #[test]
422    fn failed_assertion_is_not_a_run_error() {
423        let yaml = r#"
424title: Whoami
425id: 11111111-2222-3333-4444-555555555555
426logsource:
427    category: process_creation
428detection:
429    selection:
430        CommandLine|contains: whoami
431    condition: selection
432custom_attributes:
433    rsigma.exemplars:
434        - expect: match
435          event:
436              CommandLine: hostname
437"#;
438        let report = run(yaml);
439        assert!(!report.all_passed());
440        assert_eq!(report.results[0].actual, Expect::NoMatch);
441        assert_eq!(
442            report.results[0].diagnostic.as_deref(),
443            Some("the rule did not match the event")
444        );
445    }
446
447    #[test]
448    fn passing_exemplars_carry_no_diagnostic() {
449        let report = run(DETECTION);
450        assert!(report.results.iter().all(|r| r.diagnostic.is_none()));
451    }
452
453    #[test]
454    fn failed_correlation_no_match_names_the_firing_event() {
455        let yaml = r#"
456title: Login
457id: login-rule
458logsource:
459    category: auth
460detection:
461    selection:
462        EventType: login
463    condition: selection
464---
465title: Many Logins
466correlation:
467    type: event_count
468    rules:
469        - login-rule
470    group-by:
471        - User
472    timespan: 60s
473    condition:
474        gte: 2
475custom_attributes:
476    rsigma.exemplars:
477        - expect: no-match
478          events:
479              - offset: 0s
480                event: { EventType: login, User: alice }
481              - offset: 30s
482                event: { EventType: login, User: alice }
483"#;
484        let report = run(yaml);
485        assert!(!report.all_passed());
486        assert_eq!(
487            report.results[0].diagnostic.as_deref(),
488            Some("the correlation fired at event index 1 (offset 30s)")
489        );
490    }
491
492    #[test]
493    fn suppression_and_reset_do_not_mask_the_first_fire() {
494        let yaml = r#"
495title: Login
496id: login-rule
497logsource:
498    category: auth
499detection:
500    selection:
501        EventType: login
502    condition: selection
503---
504title: Many Logins
505correlation:
506    type: event_count
507    rules:
508        - login-rule
509    group-by:
510        - User
511    timespan: 60s
512    condition:
513        gte: 2
514custom_attributes:
515    rsigma.suppress: 5m
516    rsigma.action: reset
517    rsigma.exemplars:
518        - name: fires despite suppression
519          expect: match
520          events:
521              - offset: 0s
522                event: { EventType: login, User: alice }
523              - offset: 1s
524                event: { EventType: login, User: alice }
525              - offset: 2s
526                event: { EventType: login, User: alice }
527              - offset: 3s
528                event: { EventType: login, User: alice }
529        - name: single event stays quiet
530          expect: no-match
531          events:
532              - offset: 0s
533                event: { EventType: login, User: bob }
534"#;
535        let report = run(yaml);
536        assert!(report.all_passed(), "{report:?}");
537        assert_eq!(report.results.len(), 2);
538    }
539
540    #[test]
541    fn filter_excludes_matching_event() {
542        let yaml = r#"
543title: Whoami
544id: 11111111-2222-3333-4444-555555555555
545logsource:
546    category: process_creation
547    product: windows
548detection:
549    selection:
550        CommandLine|contains: whoami
551    condition: selection
552custom_attributes:
553    rsigma.exemplars:
554        - name: alice fires
555          expect: match
556          event:
557              CommandLine: whoami /all
558              User: alice
559        - name: system filtered
560          expect: no-match
561          event:
562              CommandLine: whoami /all
563              User: SYSTEM
564---
565title: Exclude SYSTEM
566logsource:
567    category: process_creation
568    product: windows
569filter:
570    rules:
571        - 11111111-2222-3333-4444-555555555555
572    selection:
573        User: SYSTEM
574    condition: not selection
575"#;
576        let report = run(yaml);
577        assert!(report.all_passed(), "{report:?}");
578    }
579
580    #[test]
581    fn pipeline_rewrites_rule_fields() {
582        let yaml = r#"
583title: Whoami
584id: 11111111-2222-3333-4444-555555555555
585logsource:
586    category: process_creation
587detection:
588    selection:
589        CommandLine|contains: whoami
590    condition: selection
591custom_attributes:
592    rsigma.exemplars:
593        - expect: match
594          event:
595              process.command_line: whoami /all
596"#;
597        let pipeline = crate::parse_pipeline(
598            r#"
599name: map
600priority: 10
601transformations:
602  - type: field_name_mapping
603    mapping:
604        CommandLine: process.command_line
605"#,
606        )
607        .unwrap();
608        let report = run_exemplars(&collection(yaml), &[pipeline]).unwrap();
609        assert!(report.all_passed(), "{report:?}");
610    }
611
612    #[test]
613    fn event_count_correlation() {
614        let yaml = r#"
615title: Login
616id: login-rule
617logsource:
618    category: auth
619detection:
620    selection:
621        EventType: login
622    condition: selection
623---
624title: Many Logins
625id: many-logins
626correlation:
627    type: event_count
628    rules:
629        - login-rule
630    group-by:
631        - User
632    timespan: 60s
633    condition:
634        gte: 3
635custom_attributes:
636    rsigma.exemplars:
637        - name: burst
638          expect: match
639          events:
640              - offset: 0s
641                event: { EventType: login, User: alice }
642              - offset: 1s
643                event: { EventType: login, User: alice }
644              - offset: 2s
645                event: { EventType: login, User: alice }
646        - name: too few
647          expect: no-match
648          events:
649              - offset: 0s
650                event: { EventType: login, User: bob }
651              - offset: 1s
652                event: { EventType: login, User: bob }
653"#;
654        let report = run(yaml);
655        assert!(report.all_passed(), "{report:?}");
656        assert_eq!(report.missing.len(), 1);
657        assert_eq!(report.missing[0].rule_title, "Login");
658    }
659
660    #[test]
661    fn correlation_referenced_by_another_correlation() {
662        let yaml = r#"
663title: Login
664id: login-rule
665logsource:
666    category: auth
667detection:
668    selection:
669        EventType: login
670    condition: selection
671---
672title: Many Logins
673id: many-logins
674correlation:
675    type: event_count
676    rules:
677        - login-rule
678    group-by:
679        - User
680    timespan: 60s
681    condition:
682        gte: 2
683custom_attributes:
684    rsigma.exemplars:
685        - expect: match
686          events:
687              - offset: 0s
688                event: { EventType: login, User: alice }
689              - offset: 1s
690                event: { EventType: login, User: alice }
691---
692title: Repeated Bursts
693id: repeated-bursts
694correlation:
695    type: event_count
696    rules:
697        - many-logins
698    group-by:
699        - User
700    timespan: 5m
701    condition:
702        gte: 2
703"#;
704        let report = run(yaml);
705        assert!(report.all_passed(), "{report:?}");
706    }
707
708    #[test]
709    fn value_count_correlation() {
710        let yaml = r#"
711title: Login
712id: login-rule
713logsource:
714    category: auth
715detection:
716    selection:
717        EventType: login
718    condition: selection
719---
720title: Distinct Hosts
721correlation:
722    type: value_count
723    rules:
724        - login-rule
725    group-by:
726        - User
727    timespan: 60s
728    condition:
729        field: Host
730        gte: 2
731custom_attributes:
732    rsigma.exemplars:
733        - expect: match
734          events:
735              - offset: 0s
736                event: { EventType: login, User: alice, Host: a }
737              - offset: 1s
738                event: { EventType: login, User: alice, Host: b }
739"#;
740        let report = run(yaml);
741        assert!(report.all_passed(), "{report:?}");
742    }
743
744    #[test]
745    fn temporal_correlation() {
746        let yaml = r#"
747title: Failed
748id: failed-login
749logsource:
750    category: auth
751detection:
752    selection:
753        EventType: failed
754    condition: selection
755---
756title: Success
757id: success-login
758logsource:
759    category: auth
760detection:
761    selection:
762        EventType: success
763    condition: selection
764---
765title: Fail then success
766correlation:
767    type: temporal
768    rules:
769        - failed-login
770        - success-login
771    group-by:
772        - User
773    timespan: 60s
774    condition:
775        gte: 2
776custom_attributes:
777    rsigma.exemplars:
778        - expect: match
779          events:
780              - offset: 0s
781                event: { EventType: failed, User: alice }
782              - offset: 10s
783                event: { EventType: success, User: alice }
784        - expect: no-match
785          events:
786              - offset: 0s
787                event: { EventType: failed, User: bob }
788"#;
789        let report = run(yaml);
790        assert!(report.all_passed(), "{report:?}");
791    }
792
793    #[test]
794    fn exemplars_do_not_share_state() {
795        let yaml = r#"
796title: Login
797id: login-rule
798logsource:
799    category: auth
800detection:
801    selection:
802        EventType: login
803    condition: selection
804---
805title: Many Logins
806correlation:
807    type: event_count
808    rules:
809        - login-rule
810    group-by:
811        - User
812    timespan: 60s
813    condition:
814        gte: 3
815custom_attributes:
816    rsigma.exemplars:
817        - name: first burst
818          expect: match
819          events:
820              - offset: 0s
821                event: { EventType: login, User: alice }
822              - offset: 1s
823                event: { EventType: login, User: alice }
824              - offset: 2s
825                event: { EventType: login, User: alice }
826        - name: second burst
827          expect: match
828          events:
829              - offset: 0s
830                event: { EventType: login, User: alice }
831              - offset: 1s
832                event: { EventType: login, User: alice }
833              - offset: 2s
834                event: { EventType: login, User: alice }
835"#;
836        let report = run(yaml);
837        assert!(report.all_passed(), "{report:?}");
838        assert_eq!(report.results.len(), 2);
839    }
840
841    #[test]
842    fn unrelated_detection_matches_do_not_count() {
843        let yaml = r#"
844title: Login
845id: login-rule
846logsource:
847    category: auth
848detection:
849    selection:
850        EventType: login
851    condition: selection
852---
853title: Also login
854id: also-login
855logsource:
856    category: auth
857detection:
858    selection:
859        EventType: login
860    condition: selection
861---
862title: Many Logins
863id: many-logins
864correlation:
865    type: event_count
866    rules:
867        - login-rule
868    group-by:
869        - User
870    timespan: 60s
871    condition:
872        gte: 2
873custom_attributes:
874    rsigma.exemplars:
875        - expect: match
876          events:
877              - offset: 0s
878                event: { EventType: login, User: alice }
879              - offset: 1s
880                event: { EventType: login, User: alice }
881"#;
882        let report = run(yaml);
883        assert!(report.all_passed(), "{report:?}");
884        assert_eq!(report.results[0].rule_id.as_deref(), Some("many-logins"));
885    }
886
887    #[test]
888    fn missing_correlation_ref_is_config_error() {
889        let yaml = r#"
890title: Many Logins
891correlation:
892    type: event_count
893    rules:
894        - missing-rule
895    timespan: 60s
896    condition:
897        gte: 2
898custom_attributes:
899    rsigma.exemplars:
900        - expect: match
901          events:
902              - offset: 0s
903                event: { EventType: login }
904              - offset: 1s
905                event: { EventType: login }
906"#;
907        let err = run_exemplars(&collection(yaml), &[]).unwrap_err();
908        assert!(matches!(err, ExemplarRunError::Reference(_)), "{err}");
909    }
910
911    #[test]
912    fn duplicate_titles_without_id_are_rejected() {
913        let yaml = r#"
914title: Dup
915logsource:
916    category: test
917detection:
918    selection:
919        field: a
920    condition: selection
921custom_attributes:
922    rsigma.exemplars:
923        - expect: match
924          event:
925              field: a
926---
927title: Dup
928logsource:
929    category: test
930detection:
931    selection:
932        field: b
933    condition: selection
934"#;
935        let err = run_exemplars(&collection(yaml), &[]).unwrap_err();
936        assert!(matches!(err, ExemplarRunError::AmbiguousTitle(_)), "{err}");
937    }
938
939    #[test]
940    fn malformed_exemplars_are_rejected() {
941        let yaml = r#"
942title: Whoami
943logsource:
944    category: test
945detection:
946    selection:
947        field: value
948    condition: selection
949custom_attributes:
950    rsigma.exemplars:
951        - expect: banana
952          event:
953              field: value
954"#;
955        let err = run_exemplars(&collection(yaml), &[]).unwrap_err();
956        assert!(matches!(err, ExemplarRunError::Shape { .. }), "{err}");
957    }
958
959    #[test]
960    fn filter_exemplars_are_rejected() {
961        let yaml = r#"
962title: F
963logsource:
964    category: test
965filter:
966    selection:
967        User: SYSTEM
968    condition: selection
969custom_attributes:
970    rsigma.exemplars:
971        - expect: match
972          event:
973              User: SYSTEM
974"#;
975        let err = run_exemplars(&collection(yaml), &[]).unwrap_err();
976        assert!(matches!(err, ExemplarRunError::Shape { .. }), "{err}");
977    }
978}