1use 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#[derive(Debug, Clone, Default, Deserialize)]
48pub struct RiskFile {
49 #[serde(default)]
52 pub strip_event: bool,
53 #[serde(default)]
56 pub scope: Option<ScopeConfig>,
57 #[serde(default)]
59 pub score: ScoreFile,
60 #[serde(default)]
62 pub objects: Vec<ObjectFile>,
63 #[serde(default)]
65 pub emit_risk_events: bool,
66 #[serde(default)]
68 pub nats_subject: Option<String>,
69 #[serde(default)]
71 pub incident: Option<IncidentFile>,
72}
73
74#[derive(Debug, Clone, Default, Deserialize)]
76pub struct IncidentFile {
77 #[serde(default, with = "humantime_opt")]
79 pub window: Option<Duration>,
80 #[serde(default)]
82 pub score_threshold: Option<i64>,
83 #[serde(default)]
85 pub tactic_count_threshold: Option<u64>,
86 #[serde(default, with = "humantime_opt")]
88 pub cooldown: Option<Duration>,
89 #[serde(default)]
91 pub include: IncludeLabel,
92 #[serde(default)]
94 pub nats_subject: Option<String>,
95 #[serde(default)]
97 pub caps: Option<RiskCapsFile>,
98}
99
100#[derive(Debug, Clone, Copy, Default, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum IncludeLabel {
104 #[default]
106 Refs,
107 Results,
109}
110
111#[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#[derive(Debug, Clone, Default, Deserialize)]
124pub struct ScopeConfig {
125 #[serde(default)]
127 pub rules: Vec<String>,
128 #[serde(default)]
130 pub tags: Vec<String>,
131 #[serde(default)]
133 pub levels: Vec<String>,
134}
135
136#[derive(Debug, Clone, Default, Deserialize)]
138pub struct ScoreFile {
139 #[serde(default)]
142 pub attribute: Option<String>,
143 #[serde(default)]
145 pub tag_scores: HashMap<String, i64>,
146 #[serde(default)]
148 pub tag_reducer: ReducerLabel,
149 #[serde(default)]
151 pub level_scores: HashMap<Level, i64>,
152 #[serde(default)]
154 pub default_score: i64,
155}
156
157#[derive(Debug, Clone, Copy, Default, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum ReducerLabel {
161 #[default]
163 Sum,
164 Max,
166}
167
168#[derive(Debug, Clone, Default, Deserialize)]
170pub struct ObjectFile {
171 #[serde(rename = "type")]
173 pub object_type: String,
174 pub selector: String,
176}
177
178#[derive(Debug)]
180pub enum RiskConfigError {
181 Io(std::io::Error, PathBuf),
183 Yaml(yaml_serde::Error),
185 Scope(String),
187 ObjectSelector(SelectorParseError),
189 EmptyObjectType,
191 NoObjects,
193 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
225pub 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
232pub 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
242pub 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
292const DEFAULT_WINDOW: Duration = Duration::from_secs(24 * 3600);
294const DEFAULT_COOLDOWN: Duration = Duration::from_secs(3600);
296
297fn 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
330mod 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}