Skip to main content

rsigma_runtime/enrichment/
template.rs

1//! `TemplateEnricher`: pure string interpolation for enrichment.
2//!
3//! `template` is the simplest of the four primitives: it performs no I/O,
4//! cannot fail at runtime past template parse errors caught at config load,
5//! and is intended for cheap synthetic fields like runbook URLs and summary
6//! strings.
7//!
8//! # Template syntax
9//!
10//! Two forms are recognized:
11//!
12//! - `${<name>}` (single segment, no dot): environment variable lookup.
13//!   Empty when the env var is unset.
14//! - `${detection.<path>}` or `${correlation.<path>}`: kind-specific
15//!   variable. Only the namespace matching the enricher's declared
16//!   [`EnricherKind`](super::EnricherKind) is allowed; the other namespace
17//!   fails [`validate_template_namespace`] at config load.
18//!
19//! Detection paths:
20//! - `rule.title` / `rule.id` / `rule.level`
21//! - `tags` (joined with `,`)
22//! - `fields.<name>` (the matched value of `<name>` from `matched_fields`)
23//! - `event.<dotted.path>` (navigate `DetectionBody::event` by JSON segment)
24//!
25//! Correlation paths:
26//! - `rule.title` / `rule.id` / `rule.level`
27//! - `tags` (joined with `,`)
28//! - `type` (`event_count`, `temporal`, …)
29//! - `aggregated_value` / `timespan_secs`
30//! - `group_key` (joined `field=value,…`) or `group_key.<field>`
31//!
32//! Anything else (unrecognized prefix, dotted env var, etc.) is rejected at
33//! config load, **not** at runtime, so a deployment with a typo never starts
34//! producing partly-rendered enrichments under load.
35
36use 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
47/// Matches `${<contents>}` where contents is anything except `}`.
48///
49/// We deliberately allow non-alphanumeric content inside the braces (dots,
50/// underscores) and leave classification to [`classify_ref`] so error
51/// messages can pinpoint the offending reference.
52static TEMPLATE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\{([^}]+)\}").unwrap());
53
54/// Classification of a single `${...}` reference.
55#[derive(Debug, Clone, PartialEq, Eq)]
56enum VarRef {
57    /// `${detection.<rest>}`
58    Detection(String),
59    /// `${correlation.<rest>}`
60    Correlation(String),
61    /// `${ENV_VAR}` — single segment, no dot.
62    Env(String),
63    /// Anything else (dotted but unknown prefix, empty, …). Always an
64    /// error at config load.
65    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/// Failure modes for [`validate_template_namespace`].
81#[derive(Debug, Clone)]
82pub enum TemplateError {
83    /// Reference uses the opposite namespace from the enricher's declared
84    /// kind (e.g. `${correlation.*}` inside a `kind: detection` enricher).
85    CrossNamespace {
86        enricher_id: String,
87        enricher_kind: EnricherKind,
88        reference: String,
89        field: &'static str,
90    },
91    /// Reference is malformed (empty `${}`, dotted prefix that is neither
92    /// `detection.` nor `correlation.`, …).
93    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
127/// Validate that every `${...}` reference inside `text` matches the
128/// enricher's declared kind.
129///
130/// `field` is included in the error message so operators can find the
131/// offending YAML key (e.g. `template`, `url`, `headers.Authorization`).
132/// Called at config load time for every templated config value across every
133/// enricher; rejects the daemon startup on the first failure rather than
134/// the first runtime hit.
135pub 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
167/// Render `text` against `result`, expanding every `${...}` reference.
168///
169/// Values for missing fields render as the empty string (matching the
170/// source-side `TemplateExpander` behaviour). Cross-namespace references
171/// are caught at config load by [`validate_template_namespace`] and
172/// therefore must not reach this function; if one does, it renders as
173/// the empty string rather than panicking, since the same render path is
174/// reused by tests.
175pub fn render_template(text: &str, result: &EvaluationResult) -> String {
176    render_with(text, result, |s| s)
177}
178
179/// Like [`render_template`], but JSON-string-escapes every substituted value.
180///
181/// Use this for a templated body that is a JSON document: interpolated values
182/// (rule titles, event field strings) are attacker-influenced, so a value
183/// containing a quote, backslash, or control character would otherwise break
184/// the rendered payload. The escaper serializes each substitution as a JSON
185/// string and strips the surrounding quotes, yielding content safe to drop
186/// inside a JSON string literal. `url`/`headers` keep [`render_template`]
187/// (identity escaping) because they are not JSON.
188pub fn render_template_json(text: &str, result: &EvaluationResult) -> String {
189    render_with(text, result, json_escape_value)
190}
191
192/// Shared render core: substitute every `${...}` reference, then run each
193/// substituted value through `escape`.
194fn 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: &regex::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
219/// JSON-string-escape a substituted value: serialize it as a JSON string and
220/// drop the surrounding quotes.
221fn 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
314/// Navigate a JSON value by dotted path (`"a.b.c"`).
315///
316/// Numeric segments index into arrays; everything else looks up object
317/// keys. Returns `None` on any miss. Mirrors the behaviour of
318/// [`crate::sources::TemplateExpander`]'s navigator so the two surfaces
319/// behave identically for operators.
320fn 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
345/// Format an `f64` matching the JSON `serde_json` default: integers as
346/// `"73"`, fractions as `"3.5"`. Avoids `to_string()`'s scientific
347/// notation drift for large values.
348fn 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
356// ---------------------------------------------------------------------------
357// TemplateEnricher implementation
358// ---------------------------------------------------------------------------
359
360/// Pure-template enricher: renders a single template string and writes the
361/// rendered value into `enrichments[<inject_field>]`.
362///
363/// All template namespace validation has happened at config load via
364/// [`validate_template_namespace`], so `enrich()` is infallible past
365/// runtime checks (the only failure mode is an opaque internal panic from
366/// the regex engine, which would itself be a bug).
367pub 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    /// Construct a `TemplateEnricher`.
379    ///
380    /// `template` is **not** re-validated here; callers must ensure
381    /// [`validate_template_namespace`] has been run at config load.
382    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// `EnrichError` / `EnrichErrorKind` are referenced by the trait definition
441// above via `super::*`; this `_use` keeps unused-import warnings off when
442// future expansions fold in custom errors here without changing the bound.
443#[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        // A rule title with a quote, backslash, newline, and a control char
489        // would break a JSON body without escaping.
490        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}