Skip to main content

rsigma_runtime/risk/
config.rs

1//! YAML schema and loader for the risk config file.
2//!
3//! Loaded by the daemon at startup and again on hot-reload. Validation runs at
4//! build time and fails with an error pointing at the offending field (a bad
5//! object selector names the selector; a bad scope reports the scope message),
6//! so the daemon refuses to start on a malformed config rather than silently
7//! mismatching at runtime.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13use serde::Deserialize;
14
15use rsigma_parser::Level;
16
17use crate::Scope;
18use crate::selector::{Selector, SelectorParseError};
19
20use super::RiskLayer;
21use super::accumulator::{IncidentConfig, RiskCaps};
22use super::incident::IncludeMode;
23use super::object::ObjectSelector;
24use super::score::{Reducer, ScoreConfig};
25
26/// Top-level risk config file.
27///
28/// ```yaml
29/// scope:
30///   levels: [low, medium, high, critical]
31/// score:
32///   tag_scores:
33///     "attack.*": 10
34///     crown-jewel: 50
35///   tag_reducer: sum
36///   level_scores:
37///     high: 40
38///     critical: 80
39///   default_score: 1
40/// objects:
41///   - type: user
42///     selector: enrichment.user
43///   - type: src_ip
44///     selector: match.SourceIp
45/// emit_risk_events: false
46/// ```
47#[derive(Debug, Clone, Default, Deserialize)]
48pub struct RiskFile {
49    /// Retain the event for selector resolution but drop raw event payloads
50    /// before sink delivery.
51    #[serde(default)]
52    pub strip_event: bool,
53    /// Restrict which results the layer acts on. Out-of-scope results pass
54    /// through untouched.
55    #[serde(default)]
56    pub scope: Option<ScopeConfig>,
57    /// Risk-score sourcing.
58    #[serde(default)]
59    pub score: ScoreFile,
60    /// Risk-object (entity) selectors. At least one is required.
61    #[serde(default)]
62    pub objects: Vec<ObjectFile>,
63    /// Emit a compact risk event per `(detection, risk object)` pair.
64    #[serde(default)]
65    pub emit_risk_events: bool,
66    /// Optional NATS subject override for emitted risk events.
67    #[serde(default)]
68    pub nats_subject: Option<String>,
69    /// Per-entity risk-incident accumulator. Omitted means annotation only.
70    #[serde(default)]
71    pub incident: Option<IncidentFile>,
72}
73
74/// `incident:` block.
75#[derive(Debug, Clone, Default, Deserialize)]
76pub struct IncidentFile {
77    /// Accumulation window (humantime, e.g. `24h`). Defaults to 24h.
78    #[serde(default, with = "humantime_opt")]
79    pub window: Option<Duration>,
80    /// Score threshold (window risk sum). At least one threshold is required.
81    #[serde(default)]
82    pub score_threshold: Option<i64>,
83    /// Distinct-tactic threshold. At least one threshold is required.
84    #[serde(default)]
85    pub tactic_count_threshold: Option<u64>,
86    /// Per-entity cooldown after a fire (humantime). Defaults to 1h.
87    #[serde(default, with = "humantime_opt")]
88    pub cooldown: Option<Duration>,
89    /// How much contributing detail to embed in an incident.
90    #[serde(default)]
91    pub include: IncludeLabel,
92    /// Optional NATS subject override for emitted incidents.
93    #[serde(default)]
94    pub nats_subject: Option<String>,
95    /// Growth bounds.
96    #[serde(default)]
97    pub caps: Option<RiskCapsFile>,
98}
99
100/// `incident.include` label.
101#[derive(Debug, Clone, Copy, Default, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum IncludeLabel {
104    /// Lightweight references only (default).
105    #[default]
106    Refs,
107    /// Full (event-stripped) contributing results.
108    Results,
109}
110
111/// `incident.caps:` block.
112#[derive(Debug, Clone, Default, Deserialize)]
113pub struct RiskCapsFile {
114    #[serde(default)]
115    pub max_open_entities: Option<usize>,
116    #[serde(default)]
117    pub max_sources_per_entity: Option<usize>,
118    #[serde(default)]
119    pub max_results_per_incident: Option<usize>,
120}
121
122/// `scope:` block, mirroring the alert-pipeline config.
123#[derive(Debug, Clone, Default, Deserialize)]
124pub struct ScopeConfig {
125    /// Rule-id exact matches or rule-title globs.
126    #[serde(default)]
127    pub rules: Vec<String>,
128    /// Tag exact matches or `prefix.*` wildcards.
129    #[serde(default)]
130    pub tags: Vec<String>,
131    /// Severity levels.
132    #[serde(default)]
133    pub levels: Vec<String>,
134}
135
136/// `score:` block.
137#[derive(Debug, Clone, Default, Deserialize)]
138pub struct ScoreFile {
139    /// Custom-attribute key carrying an explicit per-rule score. Defaults to
140    /// `rsigma.risk_score`.
141    #[serde(default)]
142    pub attribute: Option<String>,
143    /// Tag patterns and their scores (exact tag or a `prefix.*` wildcard).
144    #[serde(default)]
145    pub tag_scores: HashMap<String, i64>,
146    /// How multiple matching tag scores combine.
147    #[serde(default)]
148    pub tag_reducer: ReducerLabel,
149    /// Per-severity scores.
150    #[serde(default)]
151    pub level_scores: HashMap<Level, i64>,
152    /// Fallback score when nothing else applies.
153    #[serde(default)]
154    pub default_score: i64,
155}
156
157/// `score.tag_reducer` label.
158#[derive(Debug, Clone, Copy, Default, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum ReducerLabel {
161    /// Add every matching tag score (default).
162    #[default]
163    Sum,
164    /// Take the highest matching tag score.
165    Max,
166}
167
168/// `objects:` entry.
169#[derive(Debug, Clone, Default, Deserialize)]
170pub struct ObjectFile {
171    /// The risk-object type, e.g. `user`, `host`, `src_ip`.
172    #[serde(rename = "type")]
173    pub object_type: String,
174    /// The field selector resolving the entity value.
175    pub selector: String,
176}
177
178/// Errors produced while loading or validating a risk config.
179#[derive(Debug)]
180pub enum RiskConfigError {
181    /// File could not be read.
182    Io(std::io::Error, PathBuf),
183    /// YAML failed to deserialize.
184    Yaml(yaml_serde::Error),
185    /// Scope construction failed.
186    Scope(String),
187    /// An object selector failed to parse.
188    ObjectSelector(SelectorParseError),
189    /// An `objects` entry had an empty `type`.
190    EmptyObjectType,
191    /// No `objects` were configured; the layer would have no entities to score.
192    NoObjects,
193    /// An `incident` block set neither `score_threshold` nor
194    /// `tactic_count_threshold`.
195    NoThreshold,
196}
197
198impl std::fmt::Display for RiskConfigError {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        match self {
201            RiskConfigError::Io(e, p) => {
202                write!(f, "failed to read risk config '{}': {e}", p.display())
203            }
204            RiskConfigError::Yaml(e) => write!(f, "invalid risk YAML: {e}"),
205            RiskConfigError::Scope(message) => write!(f, "scope: {message}"),
206            RiskConfigError::ObjectSelector(e) => write!(f, "objects.selector: {e}"),
207            RiskConfigError::EmptyObjectType => {
208                write!(f, "objects: each entry requires a non-empty `type`")
209            }
210            RiskConfigError::NoObjects => write!(
211                f,
212                "objects is empty; list at least one risk-object selector"
213            ),
214            RiskConfigError::NoThreshold => write!(
215                f,
216                "incident is configured but neither score_threshold nor \
217                 tactic_count_threshold is set; set at least one"
218            ),
219        }
220    }
221}
222
223impl std::error::Error for RiskConfigError {}
224
225/// Read and deserialize a risk config file.
226pub fn load_risk_file(path: &Path) -> Result<RiskFile, RiskConfigError> {
227    let text =
228        std::fs::read_to_string(path).map_err(|e| RiskConfigError::Io(e, path.to_path_buf()))?;
229    yaml_serde::from_str(&text).map_err(RiskConfigError::Yaml)
230}
231
232/// Parse and validate a risk config from a YAML string.
233///
234/// Convenience over [`load_risk_file`] for in-memory inputs (tests and
235/// fuzzing): deserializes then runs the same validation [`build_risk_layer`]
236/// performs.
237pub fn parse_risk_config(text: &str) -> Result<RiskLayer, RiskConfigError> {
238    let file: RiskFile = yaml_serde::from_str(text).map_err(RiskConfigError::Yaml)?;
239    build_risk_layer(file)
240}
241
242/// Validate a parsed file into a runnable [`RiskLayer`].
243pub fn build_risk_layer(file: RiskFile) -> Result<RiskLayer, RiskConfigError> {
244    let scope = match file.scope {
245        Some(s) => Scope::new(s.rules, s.tags, s.levels).map_err(RiskConfigError::Scope)?,
246        None => Scope::default(),
247    };
248
249    let reducer = match file.score.tag_reducer {
250        ReducerLabel::Sum => Reducer::Sum,
251        ReducerLabel::Max => Reducer::Max,
252    };
253    let score = ScoreConfig::new(
254        file.score.attribute,
255        file.score.tag_scores,
256        reducer,
257        file.score.level_scores,
258        file.score.default_score,
259    );
260
261    if file.objects.is_empty() {
262        return Err(RiskConfigError::NoObjects);
263    }
264    let mut objects = Vec::with_capacity(file.objects.len());
265    for obj in file.objects {
266        if obj.object_type.trim().is_empty() {
267            return Err(RiskConfigError::EmptyObjectType);
268        }
269        let selector = Selector::parse(&obj.selector).map_err(RiskConfigError::ObjectSelector)?;
270        objects.push(ObjectSelector {
271            object_type: obj.object_type,
272            selector,
273        });
274    }
275
276    let incident = match file.incident {
277        Some(i) => Some(build_incident_config(i)?),
278        None => None,
279    };
280
281    Ok(RiskLayer::new(
282        scope,
283        file.strip_event,
284        score,
285        objects,
286        file.emit_risk_events,
287        file.nats_subject,
288        incident,
289    ))
290}
291
292/// Default accumulation window when `incident.window` is omitted.
293const DEFAULT_WINDOW: Duration = Duration::from_secs(24 * 3600);
294/// Default per-entity cooldown when `incident.cooldown` is omitted.
295const DEFAULT_COOLDOWN: Duration = Duration::from_secs(3600);
296
297/// Validate an `incident:` block into an [`IncidentConfig`].
298fn build_incident_config(file: IncidentFile) -> Result<IncidentConfig, RiskConfigError> {
299    if file.score_threshold.is_none() && file.tactic_count_threshold.is_none() {
300        return Err(RiskConfigError::NoThreshold);
301    }
302    let include = match file.include {
303        IncludeLabel::Refs => IncludeMode::Refs,
304        IncludeLabel::Results => IncludeMode::Results,
305    };
306    let caps_file = file.caps.unwrap_or_default();
307    let defaults = RiskCaps::default();
308    let caps = RiskCaps {
309        max_open_entities: caps_file
310            .max_open_entities
311            .unwrap_or(defaults.max_open_entities),
312        max_sources_per_entity: caps_file
313            .max_sources_per_entity
314            .unwrap_or(defaults.max_sources_per_entity),
315        max_results_per_incident: caps_file
316            .max_results_per_incident
317            .unwrap_or(defaults.max_results_per_incident),
318    };
319    Ok(IncidentConfig {
320        window: file.window.unwrap_or(DEFAULT_WINDOW),
321        score_threshold: file.score_threshold,
322        tactic_count_threshold: file.tactic_count_threshold,
323        cooldown: file.cooldown.unwrap_or(DEFAULT_COOLDOWN),
324        include,
325        nats_subject: file.nats_subject,
326        caps,
327    })
328}
329
330/// humantime serde adapter for `Option<Duration>`, accepting `null` / missing.
331mod humantime_opt {
332    use std::time::Duration;
333
334    use serde::{Deserialize, Deserializer};
335
336    pub fn deserialize<'de, D>(d: D) -> Result<Option<Duration>, D::Error>
337    where
338        D: Deserializer<'de>,
339    {
340        let raw: Option<String> = Option::deserialize(d)?;
341        match raw {
342            Some(s) => humantime::parse_duration(&s)
343                .map(Some)
344                .map_err(serde::de::Error::custom),
345            None => Ok(None),
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn minimal_config_builds() {
356        let yaml = "objects:\n  - type: user\n    selector: enrichment.user\n";
357        parse_risk_config(yaml).unwrap();
358    }
359
360    #[test]
361    fn empty_objects_is_rejected() {
362        let err = parse_risk_config("score:\n  default_score: 5\n").unwrap_err();
363        assert!(matches!(err, RiskConfigError::NoObjects));
364    }
365
366    #[test]
367    fn bad_object_selector_points_at_the_field() {
368        let yaml = "objects:\n  - type: user\n    selector: bogus.field\n";
369        let err = parse_risk_config(yaml).unwrap_err();
370        let msg = err.to_string();
371        assert!(msg.contains("objects.selector"), "got: {msg}");
372        assert!(msg.contains("bogus.field"), "got: {msg}");
373    }
374
375    #[test]
376    fn empty_object_type_is_rejected() {
377        let yaml = "objects:\n  - type: \"\"\n    selector: enrichment.user\n";
378        let err = parse_risk_config(yaml).unwrap_err();
379        assert!(matches!(err, RiskConfigError::EmptyObjectType));
380    }
381
382    #[test]
383    fn full_config_parses() {
384        let yaml = r#"
385strip_event: true
386scope:
387  levels: [low, medium, high, critical]
388score:
389  tag_scores:
390    "attack.*": 10
391    crown-jewel: 50
392  tag_reducer: max
393  level_scores:
394    high: 40
395    critical: 80
396  default_score: 1
397objects:
398  - type: user
399    selector: enrichment.user
400  - type: src_ip
401    selector: match.SourceIp
402emit_risk_events: true
403nats_subject: risk.events
404"#;
405        parse_risk_config(yaml).unwrap();
406    }
407
408    #[test]
409    fn bad_scope_glob_is_rejected() {
410        let yaml = "scope:\n  rules: [\"[unclosed\"]\nobjects:\n  - type: user\n    selector: enrichment.user\n";
411        let err = parse_risk_config(yaml).unwrap_err();
412        assert!(matches!(err, RiskConfigError::Scope(_)));
413    }
414}