Skip to main content

rsigma_parser/
exemplar.rs

1//! Embedded rule exemplars under `rsigma.exemplars`.
2//!
3//! Exemplars are machine-verifiable example events carried on a Sigma rule
4//! as a custom attribute. Detection rules use a single `event` mapping;
5//! correlation rules use a timestamped `events` sequence. The engine never
6//! interprets these values at match time; they are documentation plus a
7//! closed verification recipe for `rule test`.
8
9use 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
18/// The custom-attribute key that carries embedded exemplars.
19pub const EXEMPLARS_KEY: &str = "rsigma.exemplars";
20
21/// Allowed keys on one exemplar entry.
22const ENTRY_KEYS: &[&str] = &["name", "expect", "event", "events"];
23
24/// Allowed keys on one timed correlation event.
25const TIMED_KEYS: &[&str] = &["offset", "event"];
26
27/// Which kind of Sigma document an exemplar list is attached to.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ExemplarRuleKind {
31    /// A stateless detection rule (`event:` payload).
32    Detection,
33    /// A stateful correlation rule (`events:` payload).
34    Correlation,
35    /// A filter rule (must not carry exemplars).
36    Filter,
37}
38
39/// Whether the exemplar is expected to match the target rule.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum Expect {
43    /// The target rule should produce a result.
44    Match,
45    /// The target rule should not produce a result.
46    NoMatch,
47}
48
49impl Expect {
50    /// Parse `match` / `no-match`.
51    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    /// Stable wire name.
60    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/// One timed event in a correlation exemplar sequence.
75#[derive(Debug, Clone, PartialEq)]
76pub struct TimedEvent {
77    /// Offset from the deterministic base timestamp.
78    pub offset: Timespan,
79    /// Event body as a YAML mapping.
80    pub event: Value,
81}
82
83/// The payload of one exemplar.
84#[derive(Debug, Clone, PartialEq)]
85pub enum ExemplarPayload {
86    /// A single event for a detection rule.
87    Event(Value),
88    /// A timestamped sequence for a correlation rule.
89    Sequence(Vec<TimedEvent>),
90}
91
92/// One extracted exemplar.
93#[derive(Debug, Clone, PartialEq)]
94pub struct Exemplar {
95    /// Display name (explicit `name` or the 0-based index as a string).
96    pub name: String,
97    /// 0-based index in the source list.
98    pub index: usize,
99    /// Expected outcome.
100    pub expect: Expect,
101    /// Event or event sequence.
102    pub payload: ExemplarPayload,
103}
104
105/// Classification of an exemplar shape error.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
107#[serde(rename_all = "snake_case")]
108pub enum ExemplarErrorKind {
109    /// The list or an entry is structurally invalid.
110    Structural,
111    /// The payload does not match the host rule kind.
112    WrongRuleKind,
113}
114
115/// A structural problem in an exemplar list or entry.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
117pub struct ExemplarShapeError {
118    /// JSON-pointer path of the offending node.
119    pub path: String,
120    /// Human-readable description.
121    pub message: String,
122    /// Structural vs payload/kind mismatch.
123    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    /// True when the error is a payload/kind mismatch rather than a structural one.
134    pub fn is_wrong_rule_kind(&self) -> bool {
135        self.kind == ExemplarErrorKind::WrongRuleKind
136    }
137}
138
139/// Extract exemplars from a detection rule.
140pub fn exemplars(rule: &SigmaRule) -> Result<Vec<Exemplar>, Vec<ExemplarShapeError>> {
141    exemplars_from_attrs(&rule.custom_attributes, ExemplarRuleKind::Detection)
142}
143
144/// Extract exemplars from a correlation rule.
145pub fn correlation_exemplars(
146    rule: &CorrelationRule,
147) -> Result<Vec<Exemplar>, Vec<ExemplarShapeError>> {
148    exemplars_from_attrs(&rule.custom_attributes, ExemplarRuleKind::Correlation)
149}
150
151/// Extract exemplars from a filter rule (always empty or an error).
152pub fn filter_exemplars(rule: &FilterRule) -> Result<Vec<Exemplar>, Vec<ExemplarShapeError>> {
153    exemplars_from_attrs(&rule.custom_attributes, ExemplarRuleKind::Filter)
154}
155
156/// Extract exemplars from a winning `custom_attributes` map.
157pub 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
167/// Count structurally valid `expect: match` exemplars on a parsed attribute map.
168pub 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
175/// Count structurally valid `expect: match` exemplars on a JSON attribute map.
176pub 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
193/// Every raw `rsigma.exemplars` value on a YAML mapping, from both the nested
194/// `custom_attributes:` map and a top-level dotted key, with its JSON-pointer.
195pub 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
210/// The winning raw exemplar value, matching parser precedence (nested wins).
211pub 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
222/// Count structurally valid `expect: match` exemplars on the winning raw value.
223pub 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
237/// Parse a raw exemplar YAML value with kind-specific payload rules.
238pub 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}