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
106/// The outcome of resolving a rule key to metadata.
107#[derive(Debug, Clone, PartialEq)]
108pub enum RuleMetadataLookup {
109    /// No loaded rule carries the key. Expected when an incident outlives the
110    /// rule that opened it across a reload.
111    Missing,
112    /// Exactly one distinct metadata document carries the key.
113    Unique(Box<RuleBundleMetadata>),
114    /// Several rules carry the key with differing metadata, in the order the
115    /// engines were built. Callers must decide how to present the conflict
116    /// rather than being handed an arbitrary one.
117    Ambiguous(Vec<RuleBundleMetadata>),
118}
119
120impl RuleMetadataLookup {
121    /// Collapse candidate variants into a lookup outcome, treating identical
122    /// documents as one. Routed rule sets compile the same rule once per
123    /// pipeline-set, so most keys yield several byte-identical candidates.
124    pub fn from_variants(variants: Vec<RuleBundleMetadata>) -> Self {
125        let mut distinct: Vec<RuleBundleMetadata> = Vec::new();
126        for variant in variants {
127            if !distinct.contains(&variant) {
128                distinct.push(variant);
129            }
130        }
131        match distinct.len() {
132            0 => RuleMetadataLookup::Missing,
133            1 => RuleMetadataLookup::Unique(Box::new(distinct.remove(0))),
134            _ => RuleMetadataLookup::Ambiguous(distinct),
135        }
136    }
137
138    /// Every candidate document, empty when the key is unknown.
139    pub fn variants(&self) -> &[RuleBundleMetadata] {
140        match self {
141            RuleMetadataLookup::Missing => &[],
142            RuleMetadataLookup::Unique(one) => std::slice::from_ref(one),
143            RuleMetadataLookup::Ambiguous(many) => many,
144        }
145    }
146}
147
148impl CompiledRule {
149    /// This rule's identity as a detection rule.
150    pub fn identity(&self) -> RuleIdentity {
151        RuleIdentity {
152            kind: RuleKind::Detection,
153            id: self.id.clone(),
154            title: self.title.clone(),
155        }
156    }
157
158    /// The documentation fields a downstream consumer needs to explain a match.
159    pub fn bundle_metadata(&self) -> RuleBundleMetadata {
160        RuleBundleMetadata {
161            identity: self.identity(),
162            level: self.level,
163            tags: self.tags.clone(),
164            description: self.description.clone(),
165            falsepositives: self.falsepositives.clone(),
166            custom_attributes: Arc::clone(&self.custom_attributes),
167        }
168    }
169}
170
171impl CompiledCorrelation {
172    /// This correlation's identity.
173    pub fn identity(&self) -> RuleIdentity {
174        RuleIdentity {
175            kind: RuleKind::Correlation,
176            id: self.id.clone(),
177            title: self.title.clone(),
178        }
179    }
180
181    /// The documentation fields a downstream consumer needs to explain a
182    /// firing.
183    pub fn bundle_metadata(&self) -> RuleBundleMetadata {
184        RuleBundleMetadata {
185            identity: self.identity(),
186            level: self.level,
187            tags: self.tags.clone(),
188            description: self.description.clone(),
189            falsepositives: self.falsepositives.clone(),
190            custom_attributes: Arc::clone(&self.custom_attributes),
191        }
192    }
193}
194
195/// Candidate metadata from the rules in `rules` whose own key matches `key`.
196///
197/// Matching on each rule's *own* derived key (rather than testing id and title
198/// separately) is what keeps a rule whose title equals another rule's id out of
199/// the answer.
200pub(crate) fn matching_detections<'a>(
201    rules: impl IntoIterator<Item = &'a CompiledRule>,
202    key: &str,
203    out: &mut Vec<RuleBundleMetadata>,
204) {
205    for rule in rules {
206        if rule.id.as_deref().unwrap_or(&rule.title) == key {
207            out.push(rule.bundle_metadata());
208        }
209    }
210}
211
212/// Candidate metadata from the correlations whose own key matches `key`.
213pub(crate) fn matching_correlations<'a>(
214    correlations: impl IntoIterator<Item = &'a CompiledCorrelation>,
215    key: &str,
216    out: &mut Vec<RuleBundleMetadata>,
217) {
218    for corr in correlations {
219        if corr.id.as_deref().unwrap_or(&corr.title) == key {
220            out.push(corr.bundle_metadata());
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::correlation_engine::{CorrelationConfig, CorrelationEngine};
229    use crate::engine::Engine;
230    use crate::pipeline::parse_pipeline;
231    use crate::router::SchemaRouter;
232    use crate::schema::{OnUnknown, RoutingConfig, RoutingPlan, SchemaBinding, SchemaClassifier};
233    use rsigma_parser::ads::AdsDocument;
234    use rsigma_parser::parse_sigma_yaml;
235
236    const DOCUMENTED: &str = r#"
237title: Whoami execution
238id: rule-whoami
239description: Detects whoami execution, a common discovery step.
240logsource:
241    category: process_creation
242    product: windows
243detection:
244    selection:
245        CommandLine|contains: whoami
246    condition: selection
247level: high
248falsepositives:
249    - Administrators enumerating their own privileges
250tags:
251    - attack.discovery
252    - attack.t1033
253custom_attributes:
254    rsigma.ads.strategy: Watch process creation for the whoami binary.
255    rsigma.ads.technical_context: Requires process_creation telemetry.
256    rsigma.ads.blind_spots:
257        - A renamed binary evades the command-line match.
258    rsigma.ads.validation: Run whoami in a lab and confirm the rule fires.
259    rsigma.ads.priority: High because discovery precedes lateral movement.
260    rsigma.ads.response:
261        - Confirm the user and host.
262"#;
263
264    fn engine(yaml: &str) -> Engine {
265        let mut engine = Engine::new();
266        engine
267            .add_collection(&parse_sigma_yaml(yaml).unwrap())
268            .unwrap();
269        engine
270    }
271
272    #[test]
273    fn a_detection_rule_resolves_by_its_id() {
274        let engine = engine(DOCUMENTED);
275        let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
276            panic!("expected a unique match");
277        };
278        assert_eq!(meta.identity.kind, RuleKind::Detection);
279        assert_eq!(meta.identity.title, "Whoami execution");
280        assert_eq!(meta.identity.key(), "rule-whoami");
281    }
282
283    #[test]
284    fn every_ads_section_survives_compilation() {
285        let engine = engine(DOCUMENTED);
286        let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
287            panic!("expected a unique match");
288        };
289        let doc = AdsDocument::from_carriers(meta.as_ref());
290        assert!(
291            doc.missing_required().is_empty(),
292            "missing: {:?}",
293            doc.missing_required()
294        );
295    }
296
297    #[test]
298    fn a_rule_without_an_id_resolves_by_its_title() {
299        let engine = engine(
300            r#"
301title: Untitled discovery
302logsource:
303    category: process_creation
304detection:
305    selection:
306        CommandLine: whoami
307    condition: selection
308"#,
309        );
310        assert!(matches!(
311            engine.rule_metadata("Untitled discovery"),
312            RuleMetadataLookup::Unique(_)
313        ));
314    }
315
316    #[test]
317    fn a_title_matching_another_rules_id_does_not_cross_match() {
318        // The second rule's title is the first rule's id. Only the rule whose
319        // own derived key is `rule-whoami` may answer, and the second rule has
320        // an id of its own so its title is never its key.
321        let engine = engine(&format!(
322            "{DOCUMENTED}---
323title: rule-whoami
324id: rule-decoy
325logsource:
326    category: process_creation
327detection:
328    selection:
329        CommandLine: decoy
330    condition: selection
331"
332        ));
333        let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
334            panic!("expected a unique match");
335        };
336        assert_eq!(meta.identity.title, "Whoami execution");
337    }
338
339    #[test]
340    fn an_unknown_key_is_missing() {
341        let engine = engine(DOCUMENTED);
342        assert_eq!(
343            engine.rule_metadata("rule-absent"),
344            RuleMetadataLookup::Missing
345        );
346    }
347
348    #[test]
349    fn a_correlation_resolves_alongside_the_detections_it_references() {
350        let yaml = format!(
351            "{DOCUMENTED}---
352title: Repeated whoami
353id: corr-whoami
354description: Fires when whoami runs repeatedly for one user.
355correlation:
356    type: event_count
357    rules:
358        - rule-whoami
359    group-by:
360        - User
361    timespan: 5m
362    condition:
363        gte: 2
364level: critical
365"
366        );
367        let mut engine = CorrelationEngine::new(CorrelationConfig::default());
368        engine
369            .add_collection(&parse_sigma_yaml(&yaml).unwrap())
370            .unwrap();
371
372        let RuleMetadataLookup::Unique(corr) = engine.rule_metadata("corr-whoami") else {
373            panic!("expected a unique correlation match");
374        };
375        assert_eq!(corr.identity.kind, RuleKind::Correlation);
376        assert_eq!(
377            corr.description.as_deref(),
378            Some("Fires when whoami runs repeatedly for one user.")
379        );
380
381        let RuleMetadataLookup::Unique(detection) = engine.rule_metadata("rule-whoami") else {
382            panic!("expected a unique detection match");
383        };
384        assert_eq!(detection.identity.kind, RuleKind::Detection);
385    }
386
387    fn router(pipelines: Vec<Vec<crate::pipeline::Pipeline>>, names: &[&str]) -> SchemaRouter {
388        let plan = RoutingPlan::from_config(&RoutingConfig {
389            on_unknown: OnUnknown::Warn,
390            default_pipelines: vec![],
391            aliases: std::collections::HashMap::new(),
392            bindings: names
393                .iter()
394                .map(|n| SchemaBinding {
395                    schema: (*n).to_string(),
396                    pipelines: vec![(*n).to_string()],
397                    logsource: None,
398                })
399                .collect(),
400        });
401        SchemaRouter::build(
402            &parse_sigma_yaml(DOCUMENTED).unwrap(),
403            SchemaClassifier::builtin(),
404            plan,
405            pipelines,
406            CorrelationConfig::default(),
407            false,
408            crate::result::MatchDetailLevel::Off,
409            None,
410            false,
411        )
412        .unwrap()
413    }
414
415    #[test]
416    fn identical_per_schema_variants_collapse_to_one_answer() {
417        // A field-mapping pipeline rewrites detection fields but leaves the
418        // documentation alone, so every per-schema copy documents identically.
419        let ecs = parse_pipeline(
420            r#"
421name: ecs
422priority: 20
423transformations:
424  - id: map
425    type: field_name_mapping
426    mapping:
427      CommandLine: process.command_line
428"#,
429        )
430        .unwrap();
431        let router = router(vec![vec![], vec![ecs]], &["ecs"]);
432        assert!(matches!(
433            router.rule_metadata("rule-whoami"),
434            RuleMetadataLookup::Unique(_)
435        ));
436    }
437
438    #[test]
439    fn per_schema_documentation_differences_stay_visible() {
440        // This pipeline rewrites the response section for its schema only, so
441        // the two copies genuinely disagree and neither may be picked blindly.
442        let ecs = parse_pipeline(
443            r#"
444name: ecs
445priority: 20
446transformations:
447  - id: response
448    type: set_custom_attribute
449    attribute: rsigma.ads.response
450    value: Escalate to the cloud on-call rotation.
451"#,
452        )
453        .unwrap();
454        let router = router(vec![vec![], vec![ecs]], &["ecs"]);
455        let RuleMetadataLookup::Ambiguous(variants) = router.rule_metadata("rule-whoami") else {
456            panic!("expected the per-schema documents to differ");
457        };
458        assert_eq!(variants.len(), 2);
459    }
460}