rsigma_runtime/enrichment/
template.rs1use std::sync::LazyLock;
37
38use async_trait::async_trait;
39use regex::Regex;
40use rsigma_eval::{EvaluationResult, ResultBody};
41use rsigma_parser::Level;
42
43use super::{
44 EnrichError, EnrichErrorKind, Enricher, EnricherKind, OnError, Scope, inject_enrichment,
45};
46
47static TEMPLATE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\{([^}]+)\}").unwrap());
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56enum VarRef {
57 Detection(String),
59 Correlation(String),
61 Env(String),
63 Invalid(String),
66}
67
68fn classify_ref(name: &str) -> VarRef {
69 if let Some(rest) = name.strip_prefix("detection.") {
70 VarRef::Detection(rest.to_string())
71 } else if let Some(rest) = name.strip_prefix("correlation.") {
72 VarRef::Correlation(rest.to_string())
73 } else if name.contains('.') || name.is_empty() {
74 VarRef::Invalid(name.to_string())
75 } else {
76 VarRef::Env(name.to_string())
77 }
78}
79
80#[derive(Debug, Clone)]
82pub enum TemplateError {
83 CrossNamespace {
86 enricher_id: String,
87 enricher_kind: EnricherKind,
88 reference: String,
89 field: &'static str,
90 },
91 Malformed {
94 enricher_id: String,
95 reference: String,
96 field: &'static str,
97 },
98}
99
100impl std::fmt::Display for TemplateError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 TemplateError::CrossNamespace {
104 enricher_id,
105 enricher_kind,
106 reference,
107 field,
108 } => write!(
109 f,
110 "enricher '{enricher_id}' (kind: {kind}) references '${{{reference}}}' in field '{field}'; this is the wrong namespace for a {kind} enricher",
111 kind = enricher_kind.as_str(),
112 ),
113 TemplateError::Malformed {
114 enricher_id,
115 reference,
116 field,
117 } => write!(
118 f,
119 "enricher '{enricher_id}': malformed template reference '${{{reference}}}' in field '{field}'; expected ${{detection.*}}, ${{correlation.*}}, or ${{ENV_VAR}}",
120 ),
121 }
122 }
123}
124
125impl std::error::Error for TemplateError {}
126
127pub fn validate_template_namespace(
136 text: &str,
137 enricher_kind: EnricherKind,
138 enricher_id: &str,
139 field: &'static str,
140) -> Result<(), TemplateError> {
141 for caps in TEMPLATE_RE.captures_iter(text) {
142 let inner = caps.get(1).unwrap().as_str();
143 match classify_ref(inner) {
144 VarRef::Env(_) => {}
145 VarRef::Detection(_) if enricher_kind == EnricherKind::Detection => {}
146 VarRef::Correlation(_) if enricher_kind == EnricherKind::Correlation => {}
147 VarRef::Detection(_) | VarRef::Correlation(_) => {
148 return Err(TemplateError::CrossNamespace {
149 enricher_id: enricher_id.to_string(),
150 enricher_kind,
151 reference: inner.to_string(),
152 field,
153 });
154 }
155 VarRef::Invalid(_) => {
156 return Err(TemplateError::Malformed {
157 enricher_id: enricher_id.to_string(),
158 reference: inner.to_string(),
159 field,
160 });
161 }
162 }
163 }
164 Ok(())
165}
166
167pub fn render_template(text: &str, result: &EvaluationResult) -> String {
176 render_with(text, result, |s| s)
177}
178
179pub fn render_template_json(text: &str, result: &EvaluationResult) -> String {
189 render_with(text, result, json_escape_value)
190}
191
192fn render_with<F: Fn(String) -> String>(
195 text: &str,
196 result: &EvaluationResult,
197 escape: F,
198) -> String {
199 TEMPLATE_RE
200 .replace_all(text, |caps: ®ex::Captures| {
201 let inner = caps.get(1).unwrap().as_str();
202 let raw = match classify_ref(inner) {
203 VarRef::Env(name) => std::env::var(name).unwrap_or_default(),
204 VarRef::Detection(path) => match &result.body {
205 ResultBody::Detection(_) => render_detection_path(&path, result),
206 ResultBody::Correlation(_) => String::new(),
207 },
208 VarRef::Correlation(path) => match &result.body {
209 ResultBody::Correlation(_) => render_correlation_path(&path, result),
210 ResultBody::Detection(_) => String::new(),
211 },
212 VarRef::Invalid(_) => String::new(),
213 };
214 escape(raw)
215 })
216 .into_owned()
217}
218
219fn json_escape_value(s: String) -> String {
222 let quoted = serde_json::to_string(&s).unwrap_or_else(|_| "\"\"".to_string());
223 quoted
224 .get(1..quoted.len().saturating_sub(1))
225 .unwrap_or("")
226 .to_string()
227}
228
229fn render_detection_path(path: &str, result: &EvaluationResult) -> String {
230 let body = match result.as_detection() {
231 Some(b) => b,
232 None => return String::new(),
233 };
234 if let Some(rest) = path.strip_prefix("rule.") {
235 return render_rule_field(rest, result);
236 }
237 if path == "tags" {
238 return result.header.tags.join(",");
239 }
240 if let Some(name) = path.strip_prefix("fields.") {
241 for fm in &body.matched_fields {
242 if fm.field == name {
243 return json_to_string(&fm.value);
244 }
245 }
246 return String::new();
247 }
248 if let Some(rest) = path.strip_prefix("event.") {
249 if let Some(event) = &body.event {
250 return navigate_json(event, rest)
251 .map(json_to_string)
252 .unwrap_or_default();
253 }
254 return String::new();
255 }
256 if path == "event" {
257 return body.event.as_ref().map(json_to_string).unwrap_or_default();
258 }
259 String::new()
260}
261
262fn render_correlation_path(path: &str, result: &EvaluationResult) -> String {
263 let body = match result.as_correlation() {
264 Some(b) => b,
265 None => return String::new(),
266 };
267 if let Some(rest) = path.strip_prefix("rule.") {
268 return render_rule_field(rest, result);
269 }
270 if path == "tags" {
271 return result.header.tags.join(",");
272 }
273 if path == "type" {
274 return body.correlation_type.as_str().to_string();
275 }
276 if path == "aggregated_value" {
277 return format_f64(body.aggregated_value);
278 }
279 if path == "timespan_secs" {
280 return body.timespan_secs.to_string();
281 }
282 if path == "group_key" {
283 return body
284 .group_key
285 .iter()
286 .map(|(k, v)| format!("{k}={v}"))
287 .collect::<Vec<_>>()
288 .join(",");
289 }
290 if let Some(name) = path.strip_prefix("group_key.") {
291 for (k, v) in &body.group_key {
292 if k == name {
293 return v.clone();
294 }
295 }
296 return String::new();
297 }
298 String::new()
299}
300
301fn render_rule_field(rest: &str, result: &EvaluationResult) -> String {
302 match rest {
303 "title" => result.header.rule_title.clone(),
304 "id" => result.header.rule_id.clone().unwrap_or_default(),
305 "level" => result
306 .header
307 .level
308 .map(|l: Level| l.as_str().to_string())
309 .unwrap_or_default(),
310 _ => String::new(),
311 }
312}
313
314fn navigate_json<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
321 let mut current = value;
322 for segment in path.split('.') {
323 match current {
324 serde_json::Value::Object(map) => current = map.get(segment)?,
325 serde_json::Value::Array(arr) => {
326 let idx: usize = segment.parse().ok()?;
327 current = arr.get(idx)?;
328 }
329 _ => return None,
330 }
331 }
332 Some(current)
333}
334
335fn json_to_string(value: &serde_json::Value) -> String {
336 match value {
337 serde_json::Value::String(s) => s.clone(),
338 serde_json::Value::Null => String::new(),
339 serde_json::Value::Bool(b) => b.to_string(),
340 serde_json::Value::Number(n) => n.to_string(),
341 other => other.to_string(),
342 }
343}
344
345fn format_f64(v: f64) -> String {
349 if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e15 {
350 format!("{}", v as i64)
351 } else {
352 v.to_string()
353 }
354}
355
356pub struct TemplateEnricher {
368 id: String,
369 kind: EnricherKind,
370 inject_field: String,
371 template: String,
372 timeout: std::time::Duration,
373 on_error: OnError,
374 scope: Scope,
375}
376
377impl TemplateEnricher {
378 pub fn new(
383 id: String,
384 kind: EnricherKind,
385 inject_field: String,
386 template: String,
387 timeout: std::time::Duration,
388 on_error: OnError,
389 scope: Scope,
390 ) -> Self {
391 Self {
392 id,
393 kind,
394 inject_field,
395 template,
396 timeout,
397 on_error,
398 scope,
399 }
400 }
401}
402
403#[async_trait]
404impl Enricher for TemplateEnricher {
405 fn kind(&self) -> EnricherKind {
406 self.kind
407 }
408
409 fn id(&self) -> &str {
410 &self.id
411 }
412
413 fn inject_field(&self) -> &str {
414 &self.inject_field
415 }
416
417 fn timeout(&self) -> std::time::Duration {
418 self.timeout
419 }
420
421 fn scope(&self) -> &Scope {
422 &self.scope
423 }
424
425 fn on_error(&self) -> OnError {
426 self.on_error
427 }
428
429 async fn enrich(&self, result: &mut EvaluationResult) -> Result<(), EnrichError> {
430 let rendered = render_template(&self.template, result);
431 inject_enrichment(
432 result,
433 &self.inject_field,
434 serde_json::Value::String(rendered),
435 );
436 Ok(())
437 }
438}
439
440#[allow(dead_code)]
444fn _use_err(_e: EnrichError) -> EnrichErrorKind {
445 EnrichErrorKind::TemplateRender(String::new())
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use std::collections::HashMap;
452 use std::sync::Arc;
453
454 use rsigma_eval::result::{DetectionBody, FieldMatch, ResultBody, RuleHeader};
455
456 fn detection(title: &str, field: &str, value: serde_json::Value) -> EvaluationResult {
457 EvaluationResult {
458 header: RuleHeader {
459 rule_title: title.to_string(),
460 rule_id: Some("rule-1".to_string()),
461 level: Some(Level::High),
462 tags: vec![],
463 custom_attributes: Arc::new(HashMap::new()),
464 enrichments: None,
465 },
466 body: ResultBody::Detection(DetectionBody {
467 matched_selections: vec!["selection".to_string()],
468 matched_fields: vec![FieldMatch::new(field, value)],
469 event: None,
470 }),
471 }
472 }
473
474 #[test]
475 fn identity_render_is_unchanged() {
476 let r = detection("Whoami", "CommandLine", serde_json::json!("whoami /all"));
477 assert_eq!(
478 render_template(
479 "rule=${detection.rule.title} cmd=${detection.fields.CommandLine}",
480 &r
481 ),
482 "rule=Whoami cmd=whoami /all",
483 );
484 }
485
486 #[test]
487 fn json_escape_keeps_payload_valid_with_hostile_values() {
488 let r = detection("evil \" \\ \n\u{0001} title", "x", serde_json::json!("v"));
491 let body = render_template_json(r#"{"text":"${detection.rule.title}"}"#, &r);
492 let parsed: serde_json::Value =
493 serde_json::from_str(&body).expect("escaped body must be valid JSON");
494 assert_eq!(parsed["text"], "evil \" \\ \n\u{0001} title");
495 }
496
497 #[test]
498 fn json_escape_leaves_safe_values_untouched() {
499 let r = detection("PlainTitle", "x", serde_json::json!("v"));
500 let body = render_template_json(r#"{"text":"${detection.rule.title}"}"#, &r);
501 assert_eq!(body, r#"{"text":"PlainTitle"}"#);
502 }
503}