Skip to main content

rsigma_eval/
rule_metadata.rs

1//! Rule documentation recovered from a rule key.
2//!
3//! Downstream aggregation (incident grouping, risk accumulation) keeps only a
4//! rule *key* per contributing result: the rule id, or the title when the rule
5//! has no id. That is enough to count firings but not enough to explain them.
6//! [`RuleBundleMetadata`] closes the gap by carrying every field an
7//! [ADS](rsigma_parser::ads) document is built from, and the lookup methods on
8//! [`Engine`](crate::Engine), [`CorrelationEngine`](crate::CorrelationEngine),
9//! and [`SchemaRouter`](crate::SchemaRouter) resolve a key back to it.
10//!
11//! Two rules can share a key, and a routed rule set compiles the same rule once
12//! per pipeline-set, so a lookup answers with [`RuleMetadataLookup`] rather than
13//! a bare `Option`: variants that are byte-identical collapse to
14//! [`Unique`](RuleMetadataLookup::Unique), and genuinely differing ones stay
15//! visible as [`Ambiguous`](RuleMetadataLookup::Ambiguous) instead of silently
16//! resolving to whichever was compiled first.
17
18use std::collections::HashMap;
19use std::sync::Arc;
20
21use rsigma_parser::Level;
22use rsigma_parser::ads::{AdsCarriers, AdsContent};
23use serde::Serialize;
24
25use crate::compiler::CompiledRule;
26use crate::correlation::CompiledCorrelation;
27
28/// Which kind of rule a metadata entry describes.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum RuleKind {
32    /// A stateless detection rule.
33    Detection,
34    /// A stateful correlation rule.
35    Correlation,
36}
37
38/// A rule's identity: its kind plus the id and title it is known by.
39///
40/// Kept structured rather than flattened to a single string so a detection rule
41/// and a correlation rule that happen to share a key stay distinguishable.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct RuleIdentity {
44    /// Whether this is a detection or a correlation rule.
45    pub kind: RuleKind,
46    /// The rule's `id`, when it declares one.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub id: Option<String>,
49    /// The rule's `title`.
50    pub title: String,
51}
52
53impl RuleIdentity {
54    /// The key downstream aggregation groups this rule under: the id, falling
55    /// back to the title. Mirrors how a result header is reduced to a rule key.
56    pub fn key(&self) -> &str {
57        self.id.as_deref().unwrap_or(&self.title)
58    }
59}
60
61/// Everything needed to document one rule away from the engine that compiled
62/// it: its identity, its severity and tags, and the three standard fields plus
63/// `rsigma.ads.*` attributes an ADS document is assembled from.
64#[derive(Debug, Clone, PartialEq, Serialize)]
65pub struct RuleBundleMetadata {
66    /// The rule's kind, id, and title.
67    pub identity: RuleIdentity,
68    /// The rule's `level`.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub level: Option<Level>,
71    /// The rule's `tags` (the ADS categorization carrier).
72    #[serde(skip_serializing_if = "Vec::is_empty")]
73    pub tags: Vec<String>,
74    /// The rule's `description` (the ADS goal carrier).
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub description: Option<String>,
77    /// The rule's `falsepositives` (the ADS false-positives carrier).
78    #[serde(skip_serializing_if = "Vec::is_empty")]
79    pub falsepositives: Vec<String>,
80    /// The rule's custom attributes, post-pipeline. Carries the six
81    /// `rsigma.ads.*` sections that have no standard Sigma field.
82    #[serde(skip_serializing_if = "HashMap::is_empty")]
83    pub custom_attributes: Arc<HashMap<String, serde_json::Value>>,
84}
85
86impl AdsCarriers for RuleBundleMetadata {
87    fn ads_description(&self) -> Option<&str> {
88        self.description.as_deref()
89    }
90
91    fn ads_tags(&self) -> &[String] {
92        &self.tags
93    }
94
95    fn ads_falsepositives(&self) -> &[String] {
96        &self.falsepositives
97    }
98
99    fn ads_custom_attribute(&self, key: &str) -> Option<AdsContent> {
100        self.custom_attributes
101            .get(key)
102            .and_then(AdsContent::from_json)
103    }
104
105    fn ads_match_exemplar_count(&self) -> usize {
106        rsigma_parser::match_exemplar_count_json(&self.custom_attributes)
107    }
108}
109
110/// The outcome of resolving a rule key to metadata.
111#[derive(Debug, Clone, PartialEq)]
112pub enum RuleMetadataLookup {
113    /// No loaded rule carries the key. Expected when an incident outlives the
114    /// rule that opened it across a reload.
115    Missing,
116    /// Exactly one distinct metadata document carries the key.
117    Unique(Box<RuleBundleMetadata>),
118    /// Several rules carry the key with differing metadata, in the order the
119    /// engines were built. Callers must decide how to present the conflict
120    /// rather than being handed an arbitrary one.
121    Ambiguous(Vec<RuleBundleMetadata>),
122}
123
124impl RuleMetadataLookup {
125    /// Collapse candidate variants into a lookup outcome, treating identical
126    /// documents as one. Routed rule sets compile the same rule once per
127    /// pipeline-set, so most keys yield several byte-identical candidates.
128    pub fn from_variants(variants: Vec<RuleBundleMetadata>) -> Self {
129        let mut distinct: Vec<RuleBundleMetadata> = Vec::new();
130        for variant in variants {
131            if !distinct.contains(&variant) {
132                distinct.push(variant);
133            }
134        }
135        match distinct.len() {
136            0 => RuleMetadataLookup::Missing,
137            1 => RuleMetadataLookup::Unique(Box::new(distinct.remove(0))),
138            _ => RuleMetadataLookup::Ambiguous(distinct),
139        }
140    }
141
142    /// Every candidate document, empty when the key is unknown.
143    pub fn variants(&self) -> &[RuleBundleMetadata] {
144        match self {
145            RuleMetadataLookup::Missing => &[],
146            RuleMetadataLookup::Unique(one) => std::slice::from_ref(one),
147            RuleMetadataLookup::Ambiguous(many) => many,
148        }
149    }
150}
151
152impl CompiledRule {
153    /// This rule's identity as a detection rule.
154    pub fn identity(&self) -> RuleIdentity {
155        RuleIdentity {
156            kind: RuleKind::Detection,
157            id: self.id.clone(),
158            title: self.title.clone(),
159        }
160    }
161
162    /// The documentation fields a downstream consumer needs to explain a match.
163    pub fn bundle_metadata(&self) -> RuleBundleMetadata {
164        RuleBundleMetadata {
165            identity: self.identity(),
166            level: self.level,
167            tags: self.tags.clone(),
168            description: self.description.clone(),
169            falsepositives: self.falsepositives.clone(),
170            custom_attributes: Arc::clone(&self.custom_attributes),
171        }
172    }
173}
174
175impl CompiledCorrelation {
176    /// This correlation's identity.
177    pub fn identity(&self) -> RuleIdentity {
178        RuleIdentity {
179            kind: RuleKind::Correlation,
180            id: self.id.clone(),
181            title: self.title.clone(),
182        }
183    }
184
185    /// The documentation fields a downstream consumer needs to explain a
186    /// firing.
187    pub fn bundle_metadata(&self) -> RuleBundleMetadata {
188        RuleBundleMetadata {
189            identity: self.identity(),
190            level: self.level,
191            tags: self.tags.clone(),
192            description: self.description.clone(),
193            falsepositives: self.falsepositives.clone(),
194            custom_attributes: Arc::clone(&self.custom_attributes),
195        }
196    }
197}
198
199/// Candidate metadata from the rules in `rules` whose own key matches `key`.
200///
201/// Matching on each rule's *own* derived key (rather than testing id and title
202/// separately) is what keeps a rule whose title equals another rule's id out of
203/// the answer.
204pub(crate) fn matching_detections<'a>(
205    rules: impl IntoIterator<Item = &'a CompiledRule>,
206    key: &str,
207    out: &mut Vec<RuleBundleMetadata>,
208) {
209    for rule in rules {
210        if rule.id.as_deref().unwrap_or(&rule.title) == key {
211            out.push(rule.bundle_metadata());
212        }
213    }
214}
215
216/// Candidate metadata from the correlations whose own key matches `key`.
217pub(crate) fn matching_correlations<'a>(
218    correlations: impl IntoIterator<Item = &'a CompiledCorrelation>,
219    key: &str,
220    out: &mut Vec<RuleBundleMetadata>,
221) {
222    for corr in correlations {
223        if corr.id.as_deref().unwrap_or(&corr.title) == key {
224            out.push(corr.bundle_metadata());
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::correlation_engine::{CorrelationConfig, CorrelationEngine};
233    use crate::engine::Engine;
234    use crate::pipeline::parse_pipeline;
235    use crate::router::SchemaRouter;
236    use crate::schema::{OnUnknown, RoutingConfig, RoutingPlan, SchemaBinding, SchemaClassifier};
237    use rsigma_parser::ads::AdsDocument;
238    use rsigma_parser::parse_sigma_yaml;
239
240    const DOCUMENTED: &str = r#"
241title: Whoami execution
242id: rule-whoami
243description: Detects whoami execution, a common discovery step.
244logsource:
245    category: process_creation
246    product: windows
247detection:
248    selection:
249        CommandLine|contains: whoami
250    condition: selection
251level: high
252falsepositives:
253    - Administrators enumerating their own privileges
254tags:
255    - attack.discovery
256    - attack.t1033
257custom_attributes:
258    rsigma.ads.strategy: Watch process creation for the whoami binary.
259    rsigma.ads.technical_context: Requires process_creation telemetry.
260    rsigma.ads.blind_spots:
261        - A renamed binary evades the command-line match.
262    rsigma.ads.validation: Run whoami in a lab and confirm the rule fires.
263    rsigma.ads.priority: High because discovery precedes lateral movement.
264    rsigma.ads.response:
265        - Confirm the user and host.
266"#;
267
268    fn engine(yaml: &str) -> Engine {
269        let mut engine = Engine::new();
270        engine
271            .add_collection(&parse_sigma_yaml(yaml).unwrap())
272            .unwrap();
273        engine
274    }
275
276    #[test]
277    fn a_detection_rule_resolves_by_its_id() {
278        let engine = engine(DOCUMENTED);
279        let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
280            panic!("expected a unique match");
281        };
282        assert_eq!(meta.identity.kind, RuleKind::Detection);
283        assert_eq!(meta.identity.title, "Whoami execution");
284        assert_eq!(meta.identity.key(), "rule-whoami");
285    }
286
287    #[test]
288    fn every_ads_section_survives_compilation() {
289        let engine = engine(DOCUMENTED);
290        let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
291            panic!("expected a unique match");
292        };
293        let doc = AdsDocument::from_carriers(meta.as_ref());
294        assert!(
295            doc.missing_required().is_empty(),
296            "missing: {:?}",
297            doc.missing_required()
298        );
299    }
300
301    #[test]
302    fn a_rule_without_an_id_resolves_by_its_title() {
303        let engine = engine(
304            r#"
305title: Untitled discovery
306logsource:
307    category: process_creation
308detection:
309    selection:
310        CommandLine: whoami
311    condition: selection
312"#,
313        );
314        assert!(matches!(
315            engine.rule_metadata("Untitled discovery"),
316            RuleMetadataLookup::Unique(_)
317        ));
318    }
319
320    #[test]
321    fn a_title_matching_another_rules_id_does_not_cross_match() {
322        // The second rule's title is the first rule's id. Only the rule whose
323        // own derived key is `rule-whoami` may answer, and the second rule has
324        // an id of its own so its title is never its key.
325        let engine = engine(&format!(
326            "{DOCUMENTED}---
327title: rule-whoami
328id: rule-decoy
329logsource:
330    category: process_creation
331detection:
332    selection:
333        CommandLine: decoy
334    condition: selection
335"
336        ));
337        let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
338            panic!("expected a unique match");
339        };
340        assert_eq!(meta.identity.title, "Whoami execution");
341    }
342
343    #[test]
344    fn an_unknown_key_is_missing() {
345        let engine = engine(DOCUMENTED);
346        assert_eq!(
347            engine.rule_metadata("rule-absent"),
348            RuleMetadataLookup::Missing
349        );
350    }
351
352    #[test]
353    fn a_correlation_resolves_alongside_the_detections_it_references() {
354        let yaml = format!(
355            "{DOCUMENTED}---
356title: Repeated whoami
357id: corr-whoami
358description: Fires when whoami runs repeatedly for one user.
359correlation:
360    type: event_count
361    rules:
362        - rule-whoami
363    group-by:
364        - User
365    timespan: 5m
366    condition:
367        gte: 2
368level: critical
369"
370        );
371        let mut engine = CorrelationEngine::new(CorrelationConfig::default());
372        engine
373            .add_collection(&parse_sigma_yaml(&yaml).unwrap())
374            .unwrap();
375
376        let RuleMetadataLookup::Unique(corr) = engine.rule_metadata("corr-whoami") else {
377            panic!("expected a unique correlation match");
378        };
379        assert_eq!(corr.identity.kind, RuleKind::Correlation);
380        assert_eq!(
381            corr.description.as_deref(),
382            Some("Fires when whoami runs repeatedly for one user.")
383        );
384
385        let RuleMetadataLookup::Unique(detection) = engine.rule_metadata("rule-whoami") else {
386            panic!("expected a unique detection match");
387        };
388        assert_eq!(detection.identity.kind, RuleKind::Detection);
389    }
390
391    fn router(pipelines: Vec<Vec<crate::pipeline::Pipeline>>, names: &[&str]) -> SchemaRouter {
392        let plan = RoutingPlan::from_config(&RoutingConfig {
393            on_unknown: OnUnknown::Warn,
394            default_pipelines: vec![],
395            aliases: std::collections::HashMap::new(),
396            bindings: names
397                .iter()
398                .map(|n| SchemaBinding {
399                    schema: (*n).to_string(),
400                    pipelines: vec![(*n).to_string()],
401                    logsource: None,
402                })
403                .collect(),
404        });
405        SchemaRouter::build(
406            &parse_sigma_yaml(DOCUMENTED).unwrap(),
407            SchemaClassifier::builtin(),
408            plan,
409            pipelines,
410            CorrelationConfig::default(),
411            false,
412            crate::result::MatchDetailLevel::Off,
413            None,
414            false,
415        )
416        .unwrap()
417    }
418
419    #[test]
420    fn identical_per_schema_variants_collapse_to_one_answer() {
421        // A field-mapping pipeline rewrites detection fields but leaves the
422        // documentation alone, so every per-schema copy documents identically.
423        let ecs = parse_pipeline(
424            r#"
425name: ecs
426priority: 20
427transformations:
428  - id: map
429    type: field_name_mapping
430    mapping:
431      CommandLine: process.command_line
432"#,
433        )
434        .unwrap();
435        let router = router(vec![vec![], vec![ecs]], &["ecs"]);
436        assert!(matches!(
437            router.rule_metadata("rule-whoami"),
438            RuleMetadataLookup::Unique(_)
439        ));
440    }
441
442    #[test]
443    fn per_schema_documentation_differences_stay_visible() {
444        // This pipeline rewrites the response section for its schema only, so
445        // the two copies genuinely disagree and neither may be picked blindly.
446        let ecs = parse_pipeline(
447            r#"
448name: ecs
449priority: 20
450transformations:
451  - id: response
452    type: set_custom_attribute
453    attribute: rsigma.ads.response
454    value: Escalate to the cloud on-call rotation.
455"#,
456        )
457        .unwrap();
458        let router = router(vec![vec![], vec![ecs]], &["ecs"]);
459        let RuleMetadataLookup::Ambiguous(variants) = router.rule_metadata("rule-whoami") else {
460            panic!("expected the per-schema documents to differ");
461        };
462        assert_eq!(variants.len(), 2);
463    }
464}