Skip to main content

rsigma_runtime/risk/
object.rs

1//! Risk-object (entity) extraction.
2//!
3//! Each firing detection names one or more risk objects, each a `{type, value}`
4//! pair (`user`, `host`, `src_ip`, ...). An object is extracted by resolving a
5//! field selector against the result; a selector that resolves to nothing
6//! contributes no object, so there are no phantom entities. One detection can
7//! raise risk on several objects at once, exactly the Splunk RBA model.
8
9use rsigma_eval::EvaluationResult;
10use serde::Serialize;
11
12use crate::selector::Selector;
13
14/// A typed selector that extracts one kind of risk object from a result.
15#[derive(Debug, Clone)]
16pub struct ObjectSelector {
17    /// The risk-object type label, e.g. `user`, `host`, `src_ip`.
18    pub object_type: String,
19    /// The field selector resolving the entity value.
20    pub selector: Selector,
21}
22
23/// A single extracted risk object.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct RiskObject {
26    /// The risk-object type, e.g. `user`.
27    #[serde(rename = "type")]
28    pub object_type: String,
29    /// The entity value, e.g. `alice`.
30    pub value: String,
31}
32
33/// Resolve every configured object selector against a result, returning the
34/// distinct risk objects it names (deduplicated on `(type, value)`, order
35/// preserved). A selector resolving to a non-scalar or absent value is skipped.
36pub fn extract(result: &EvaluationResult, selectors: &[ObjectSelector]) -> Vec<RiskObject> {
37    let mut out: Vec<RiskObject> = Vec::new();
38    for sel in selectors {
39        let Some(value) = sel.selector.resolve(result) else {
40            continue;
41        };
42        let Some(value) = scalar_to_string(&value) else {
43            continue;
44        };
45        let object = RiskObject {
46            object_type: sel.object_type.clone(),
47            value,
48        };
49        if !out.contains(&object) {
50            out.push(object);
51        }
52    }
53    out
54}
55
56/// Stringify a scalar JSON value. Arrays, objects, and null yield `None`, so a
57/// selector pointing at a structured field contributes no entity.
58fn scalar_to_string(value: &serde_json::Value) -> Option<String> {
59    match value {
60        serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
61        serde_json::Value::String(_) => None,
62        serde_json::Value::Number(n) => Some(n.to_string()),
63        serde_json::Value::Bool(b) => Some(b.to_string()),
64        serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
65            None
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use rsigma_eval::{DetectionBody, FieldMatch, ResultBody, RuleHeader};
74    use rsigma_parser::Level;
75    use std::collections::HashMap;
76    use std::sync::Arc;
77
78    fn detection() -> EvaluationResult {
79        EvaluationResult {
80            header: RuleHeader {
81                rule_title: "t".to_string(),
82                rule_id: Some("r".to_string()),
83                level: Some(Level::High),
84                tags: vec![],
85                custom_attributes: Arc::new(HashMap::new()),
86                enrichments: Some(
87                    serde_json::json!({"user": "alice"})
88                        .as_object()
89                        .unwrap()
90                        .clone(),
91                ),
92            },
93            body: ResultBody::Detection(DetectionBody {
94                matched_selections: vec![],
95                matched_fields: vec![
96                    FieldMatch::new("SourceIp", serde_json::json!("10.0.0.1")),
97                    FieldMatch::new("SourceIp", serde_json::json!("10.0.0.1")),
98                ],
99                event: Some(serde_json::json!({"host": {"name": "dc01"}})),
100            }),
101        }
102    }
103
104    fn sel(object_type: &str, raw: &str) -> ObjectSelector {
105        ObjectSelector {
106            object_type: object_type.to_string(),
107            selector: Selector::parse(raw).unwrap(),
108        }
109    }
110
111    #[test]
112    fn extracts_across_namespaces() {
113        let objects = extract(
114            &detection(),
115            &[
116                sel("src_ip", "match.SourceIp"),
117                sel("host", "event.host.name"),
118                sel("user", "enrichment.user"),
119            ],
120        );
121        assert_eq!(
122            objects,
123            vec![
124                RiskObject {
125                    object_type: "src_ip".into(),
126                    value: "10.0.0.1".into()
127                },
128                RiskObject {
129                    object_type: "host".into(),
130                    value: "dc01".into()
131                },
132                RiskObject {
133                    object_type: "user".into(),
134                    value: "alice".into()
135                },
136            ]
137        );
138    }
139
140    #[test]
141    fn missing_selector_contributes_nothing() {
142        let objects = extract(&detection(), &[sel("user", "event.nope")]);
143        assert!(objects.is_empty());
144    }
145
146    #[test]
147    fn duplicate_objects_are_collapsed() {
148        let objects = extract(
149            &detection(),
150            &[
151                sel("src_ip", "match.SourceIp"),
152                sel("src_ip", "match.SourceIp"),
153            ],
154        );
155        assert_eq!(objects.len(), 1);
156    }
157}