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    TEMPLATE_RE
177        .replace_all(text, |caps: &regex::Captures| {
178            let inner = caps.get(1).unwrap().as_str();
179            match classify_ref(inner) {
180                VarRef::Env(name) => std::env::var(name).unwrap_or_default(),
181                VarRef::Detection(path) => match &result.body {
182                    ResultBody::Detection(_) => render_detection_path(&path, result),
183                    ResultBody::Correlation(_) => String::new(),
184                },
185                VarRef::Correlation(path) => match &result.body {
186                    ResultBody::Correlation(_) => render_correlation_path(&path, result),
187                    ResultBody::Detection(_) => String::new(),
188                },
189                VarRef::Invalid(_) => String::new(),
190            }
191        })
192        .into_owned()
193}
194
195fn render_detection_path(path: &str, result: &EvaluationResult) -> String {
196    let body = match result.as_detection() {
197        Some(b) => b,
198        None => return String::new(),
199    };
200    if let Some(rest) = path.strip_prefix("rule.") {
201        return render_rule_field(rest, result);
202    }
203    if path == "tags" {
204        return result.header.tags.join(",");
205    }
206    if let Some(name) = path.strip_prefix("fields.") {
207        for fm in &body.matched_fields {
208            if fm.field == name {
209                return json_to_string(&fm.value);
210            }
211        }
212        return String::new();
213    }
214    if let Some(rest) = path.strip_prefix("event.") {
215        if let Some(event) = &body.event {
216            return navigate_json(event, rest)
217                .map(json_to_string)
218                .unwrap_or_default();
219        }
220        return String::new();
221    }
222    if path == "event" {
223        return body.event.as_ref().map(json_to_string).unwrap_or_default();
224    }
225    String::new()
226}
227
228fn render_correlation_path(path: &str, result: &EvaluationResult) -> String {
229    let body = match result.as_correlation() {
230        Some(b) => b,
231        None => return String::new(),
232    };
233    if let Some(rest) = path.strip_prefix("rule.") {
234        return render_rule_field(rest, result);
235    }
236    if path == "tags" {
237        return result.header.tags.join(",");
238    }
239    if path == "type" {
240        return body.correlation_type.as_str().to_string();
241    }
242    if path == "aggregated_value" {
243        return format_f64(body.aggregated_value);
244    }
245    if path == "timespan_secs" {
246        return body.timespan_secs.to_string();
247    }
248    if path == "group_key" {
249        return body
250            .group_key
251            .iter()
252            .map(|(k, v)| format!("{k}={v}"))
253            .collect::<Vec<_>>()
254            .join(",");
255    }
256    if let Some(name) = path.strip_prefix("group_key.") {
257        for (k, v) in &body.group_key {
258            if k == name {
259                return v.clone();
260            }
261        }
262        return String::new();
263    }
264    String::new()
265}
266
267fn render_rule_field(rest: &str, result: &EvaluationResult) -> String {
268    match rest {
269        "title" => result.header.rule_title.clone(),
270        "id" => result.header.rule_id.clone().unwrap_or_default(),
271        "level" => result
272            .header
273            .level
274            .map(|l: Level| l.as_str().to_string())
275            .unwrap_or_default(),
276        _ => String::new(),
277    }
278}
279
280/// Navigate a JSON value by dotted path (`"a.b.c"`).
281///
282/// Numeric segments index into arrays; everything else looks up object
283/// keys. Returns `None` on any miss. Mirrors the behaviour of
284/// [`crate::sources::TemplateExpander`]'s navigator so the two surfaces
285/// behave identically for operators.
286fn navigate_json<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
287    let mut current = value;
288    for segment in path.split('.') {
289        match current {
290            serde_json::Value::Object(map) => current = map.get(segment)?,
291            serde_json::Value::Array(arr) => {
292                let idx: usize = segment.parse().ok()?;
293                current = arr.get(idx)?;
294            }
295            _ => return None,
296        }
297    }
298    Some(current)
299}
300
301fn json_to_string(value: &serde_json::Value) -> String {
302    match value {
303        serde_json::Value::String(s) => s.clone(),
304        serde_json::Value::Null => String::new(),
305        serde_json::Value::Bool(b) => b.to_string(),
306        serde_json::Value::Number(n) => n.to_string(),
307        other => other.to_string(),
308    }
309}
310
311/// Format an `f64` matching the JSON `serde_json` default: integers as
312/// `"73"`, fractions as `"3.5"`. Avoids `to_string()`'s scientific
313/// notation drift for large values.
314fn format_f64(v: f64) -> String {
315    if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e15 {
316        format!("{}", v as i64)
317    } else {
318        v.to_string()
319    }
320}
321
322// ---------------------------------------------------------------------------
323// TemplateEnricher implementation
324// ---------------------------------------------------------------------------
325
326/// Pure-template enricher: renders a single template string and writes the
327/// rendered value into `enrichments[<inject_field>]`.
328///
329/// All template namespace validation has happened at config load via
330/// [`validate_template_namespace`], so `enrich()` is infallible past
331/// runtime checks (the only failure mode is an opaque internal panic from
332/// the regex engine, which would itself be a bug).
333pub struct TemplateEnricher {
334    id: String,
335    kind: EnricherKind,
336    inject_field: String,
337    template: String,
338    timeout: std::time::Duration,
339    on_error: OnError,
340    scope: Scope,
341}
342
343impl TemplateEnricher {
344    /// Construct a `TemplateEnricher`.
345    ///
346    /// `template` is **not** re-validated here; callers must ensure
347    /// [`validate_template_namespace`] has been run at config load.
348    pub fn new(
349        id: String,
350        kind: EnricherKind,
351        inject_field: String,
352        template: String,
353        timeout: std::time::Duration,
354        on_error: OnError,
355        scope: Scope,
356    ) -> Self {
357        Self {
358            id,
359            kind,
360            inject_field,
361            template,
362            timeout,
363            on_error,
364            scope,
365        }
366    }
367}
368
369#[async_trait]
370impl Enricher for TemplateEnricher {
371    fn kind(&self) -> EnricherKind {
372        self.kind
373    }
374
375    fn id(&self) -> &str {
376        &self.id
377    }
378
379    fn inject_field(&self) -> &str {
380        &self.inject_field
381    }
382
383    fn timeout(&self) -> std::time::Duration {
384        self.timeout
385    }
386
387    fn scope(&self) -> &Scope {
388        &self.scope
389    }
390
391    fn on_error(&self) -> OnError {
392        self.on_error
393    }
394
395    async fn enrich(&self, result: &mut EvaluationResult) -> Result<(), EnrichError> {
396        let rendered = render_template(&self.template, result);
397        inject_enrichment(
398            result,
399            &self.inject_field,
400            serde_json::Value::String(rendered),
401        );
402        Ok(())
403    }
404}
405
406// `EnrichError` / `EnrichErrorKind` are referenced by the trait definition
407// above via `super::*`; this `_use` keeps unused-import warnings off when
408// future expansions fold in custom errors here without changing the bound.
409#[allow(dead_code)]
410fn _use_err(_e: EnrichError) -> EnrichErrorKind {
411    EnrichErrorKind::TemplateRender(String::new())
412}