Skip to main content

rsigma_eval/pipeline/transformations/
mod.rs

1//! Pipeline transformations that mutate `SigmaRule` AST nodes.
2//!
3//! All 26 pySigma transformation types are implemented as variants of the
4//! [`Transformation`] enum. Each variant carries its configuration parameters
5//! and is applied via the [`Transformation::apply`] method.
6
7mod helpers;
8#[cfg(test)]
9mod tests;
10
11use std::collections::HashMap;
12
13use regex::Regex;
14
15use rsigma_parser::{SigmaRule, SigmaValue};
16
17use super::conditions::{DetectionItemCondition, FieldNameCondition};
18use super::state::PipelineState;
19use crate::error::{EvalError, Result};
20
21// =============================================================================
22// Transformation enum
23// =============================================================================
24
25/// All supported pipeline transformation types.
26#[derive(Debug, Clone)]
27pub enum Transformation {
28    /// Map field names via a lookup table.
29    ///
30    /// Supports pySigma-compatible one-to-many mapping: a single source name
31    /// can map to a list of alternative field names. When more than one
32    /// alternative is present, the matched detection item is replaced with
33    /// an OR-conjunction (`AnyOf`) of items, one per alternative — preserving
34    /// the rule's original AND structure across the rest of the items in the
35    /// same selection via a Cartesian expansion.
36    ///
37    /// For correlation rules, `group_by` fields are expanded to include all
38    /// alternatives (alias names are left untouched). `aliases` mapping values
39    /// and threshold `field` reject one-to-many mappings with an error since
40    /// those positions are inherently scalar.
41    FieldNameMapping {
42        mapping: HashMap<String, Vec<String>>,
43    },
44
45    /// Map field name prefixes.
46    FieldNamePrefixMapping { mapping: HashMap<String, String> },
47
48    /// Add a prefix to all matched field names.
49    FieldNamePrefix { prefix: String },
50
51    /// Add a suffix to all matched field names.
52    FieldNameSuffix { suffix: String },
53
54    /// Remove matching detection items.
55    DropDetectionItem,
56
57    /// Add field=value conditions to the rule's detection.
58    AddCondition {
59        conditions: HashMap<String, SigmaValue>,
60        /// If true, negate the added conditions.
61        negated: bool,
62    },
63
64    /// Replace logsource fields.
65    ChangeLogsource {
66        category: Option<String>,
67        product: Option<String>,
68        service: Option<String>,
69    },
70
71    /// Regex replacement in string values.
72    ///
73    /// When `skip_special` is true, replacement is applied only to the plain
74    /// (non-wildcard) segments of `SigmaString`, preserving `*` and `?` wildcards.
75    /// Mirrors pySigma's `ReplaceStringTransformation.skip_special`.
76    ReplaceString {
77        regex: String,
78        replacement: String,
79        skip_special: bool,
80    },
81
82    /// Expand `%name%` placeholders with pipeline variables.
83    ValuePlaceholders,
84
85    /// Replace unresolved `%name%` placeholders with `*` wildcard.
86    WildcardPlaceholders,
87
88    /// Store expression template (no-op for eval, kept for YAML compat).
89    QueryExpressionPlaceholders { expression: String },
90
91    /// Set key-value in pipeline state.
92    SetState { key: String, value: String },
93
94    /// Fail if rule conditions match.
95    RuleFailure { message: String },
96
97    /// Fail if detection item conditions match.
98    DetectionItemFailure { message: String },
99
100    /// Apply a named function to field names (lowercase, uppercase, etc.).
101    /// In pySigma this takes a Python callable; we support named functions.
102    FieldNameTransform {
103        /// One of: "lower", "upper", "title", "snake_case"
104        transform_func: String,
105        /// Explicit overrides: field → new_name (applied instead of the function).
106        mapping: HashMap<String, String>,
107    },
108
109    /// Decompose the `Hashes` field into per-algorithm fields.
110    ///
111    /// `Hashes: "SHA1=abc,MD5=def"` → `FileSHA1: abc` + `FileMD5: def`
112    HashesFields {
113        /// Allowed hash algorithms (e.g. `["MD5", "SHA1", "SHA256"]`).
114        valid_hash_algos: Vec<String>,
115        /// Prefix for generated field names (e.g. `"File"` → `FileMD5`).
116        field_prefix: String,
117        /// If true, omit algo name from field (use just prefix).
118        drop_algo_prefix: bool,
119    },
120
121    /// Map string values via a lookup table.
122    ///
123    /// Supports one-to-many mapping: a single value can map to multiple
124    /// alternatives (pySigma compat). When one-to-many is used, the detection
125    /// item's values list is expanded in place.
126    MapString {
127        mapping: HashMap<String, Vec<String>>,
128    },
129
130    /// Set all values of matching detection items to a fixed value.
131    SetValue { value: SigmaValue },
132
133    /// Convert detection item values to a different type.
134    /// Supported: "str", "int", "float", "bool".
135    ConvertType { target_type: String },
136
137    /// Convert plain string values to regex patterns.
138    Regex,
139
140    /// Add a field name to the rule's output `fields` list.
141    AddField { field: String },
142
143    /// Remove a field name from the rule's output `fields` list.
144    RemoveField { field: String },
145
146    /// Set (replace) the rule's output `fields` list.
147    SetField { fields: Vec<String> },
148
149    /// Set a custom attribute on the rule.
150    ///
151    /// Stores the key-value pair in `SigmaRule.custom_attributes` as a
152    /// `serde_yaml::Value::String`. Backends / engines can read these to
153    /// modify per-rule behavior (e.g. `rsigma.suppress`, `rsigma.action`).
154    /// Mirrors pySigma's `SetCustomAttributeTransformation`.
155    SetCustomAttribute { attribute: String, value: String },
156
157    /// Apply a case transformation to string values.
158    /// Supported: "lower", "upper", "snake_case".
159    CaseTransformation { case_type: String },
160
161    /// Nested sub-pipeline: apply a list of transformations as a group.
162    /// The inner items share the same conditions as the outer item.
163    Nest {
164        items: Vec<super::TransformationItem>,
165    },
166}
167
168// =============================================================================
169// Application logic
170// =============================================================================
171
172impl Transformation {
173    /// Apply this transformation to a `SigmaRule`, mutating it in place.
174    ///
175    /// Returns `Ok(true)` if the transformation was applied, `Ok(false)` if skipped.
176    pub fn apply(
177        &self,
178        rule: &mut SigmaRule,
179        state: &mut PipelineState,
180        detection_item_conditions: &[DetectionItemCondition],
181        field_name_conditions: &[FieldNameCondition],
182        field_name_cond_not: bool,
183    ) -> Result<bool> {
184        match self {
185            Transformation::FieldNameMapping { mapping } => {
186                helpers::apply_field_name_transform(
187                    rule,
188                    state,
189                    field_name_conditions,
190                    field_name_cond_not,
191                    |name| mapping.get(name).cloned(),
192                )?;
193                Ok(true)
194            }
195
196            Transformation::FieldNamePrefixMapping { mapping } => {
197                helpers::apply_field_name_transform(
198                    rule,
199                    state,
200                    field_name_conditions,
201                    field_name_cond_not,
202                    |name| {
203                        for (prefix, replacement) in mapping {
204                            if name.starts_with(prefix.as_str()) {
205                                return Some(vec![format!(
206                                    "{}{}",
207                                    replacement,
208                                    &name[prefix.len()..]
209                                )]);
210                            }
211                        }
212                        None
213                    },
214                )?;
215                Ok(true)
216            }
217
218            Transformation::FieldNamePrefix { prefix } => {
219                helpers::apply_field_name_transform(
220                    rule,
221                    state,
222                    field_name_conditions,
223                    field_name_cond_not,
224                    |name| Some(vec![format!("{prefix}{name}")]),
225                )?;
226                Ok(true)
227            }
228
229            Transformation::FieldNameSuffix { suffix } => {
230                helpers::apply_field_name_transform(
231                    rule,
232                    state,
233                    field_name_conditions,
234                    field_name_cond_not,
235                    |name| Some(vec![format!("{name}{suffix}")]),
236                )?;
237                Ok(true)
238            }
239
240            Transformation::DropDetectionItem => {
241                helpers::drop_detection_items(
242                    rule,
243                    state,
244                    detection_item_conditions,
245                    field_name_conditions,
246                    field_name_cond_not,
247                );
248                Ok(true)
249            }
250
251            Transformation::AddCondition {
252                conditions,
253                negated,
254            } => {
255                helpers::add_conditions(rule, conditions, *negated);
256                Ok(true)
257            }
258
259            Transformation::ChangeLogsource {
260                category,
261                product,
262                service,
263            } => {
264                if let Some(cat) = category {
265                    rule.logsource.category = Some(cat.clone());
266                }
267                if let Some(prod) = product {
268                    rule.logsource.product = Some(prod.clone());
269                }
270                if let Some(svc) = service {
271                    rule.logsource.service = Some(svc.clone());
272                }
273                Ok(true)
274            }
275
276            Transformation::ReplaceString {
277                regex,
278                replacement,
279                skip_special,
280            } => {
281                let re = Regex::new(regex)
282                    .map_err(|e| EvalError::InvalidModifiers(format!("bad regex: {e}")))?;
283                helpers::replace_strings_in_rule(
284                    rule,
285                    state,
286                    detection_item_conditions,
287                    field_name_conditions,
288                    field_name_cond_not,
289                    &re,
290                    replacement,
291                    *skip_special,
292                );
293                Ok(true)
294            }
295
296            Transformation::ValuePlaceholders => {
297                helpers::expand_placeholders_in_rule(rule, state, false);
298                Ok(true)
299            }
300
301            Transformation::WildcardPlaceholders => {
302                helpers::expand_placeholders_in_rule(rule, state, true);
303                Ok(true)
304            }
305
306            Transformation::QueryExpressionPlaceholders { expression } => {
307                state.set_state(
308                    "query_expression_template".to_string(),
309                    serde_json::Value::String(expression.clone()),
310                );
311                Ok(true)
312            }
313
314            Transformation::SetState { key, value } => {
315                state.set_state(key.clone(), serde_json::Value::String(value.clone()));
316                Ok(true)
317            }
318
319            Transformation::RuleFailure { message } => Err(EvalError::InvalidModifiers(format!(
320                "Pipeline rule failure: {message} (rule: {})",
321                rule.title
322            ))),
323
324            Transformation::DetectionItemFailure { message } => {
325                let has_match =
326                    helpers::rule_has_matching_item(rule, state, detection_item_conditions);
327                if has_match {
328                    Err(EvalError::InvalidModifiers(format!(
329                        "Pipeline detection item failure: {message} (rule: {})",
330                        rule.title
331                    )))
332                } else {
333                    Ok(false)
334                }
335            }
336
337            Transformation::FieldNameTransform {
338                transform_func,
339                mapping,
340            } => {
341                let func = transform_func.clone();
342                let map = mapping.clone();
343                helpers::apply_field_name_transform(
344                    rule,
345                    state,
346                    field_name_conditions,
347                    field_name_cond_not,
348                    |name| {
349                        if let Some(mapped) = map.get(name) {
350                            return Some(vec![mapped.clone()]);
351                        }
352                        Some(vec![helpers::apply_named_string_fn(&func, name)])
353                    },
354                )?;
355                Ok(true)
356            }
357
358            Transformation::HashesFields {
359                valid_hash_algos,
360                field_prefix,
361                drop_algo_prefix,
362            } => {
363                helpers::decompose_hashes_field(
364                    rule,
365                    valid_hash_algos,
366                    field_prefix,
367                    *drop_algo_prefix,
368                );
369                Ok(true)
370            }
371
372            Transformation::MapString { mapping } => {
373                helpers::map_string_values(
374                    rule,
375                    state,
376                    detection_item_conditions,
377                    field_name_conditions,
378                    field_name_cond_not,
379                    mapping,
380                );
381                Ok(true)
382            }
383
384            Transformation::SetValue { value } => {
385                helpers::set_detection_item_values(
386                    rule,
387                    state,
388                    detection_item_conditions,
389                    field_name_conditions,
390                    field_name_cond_not,
391                    value,
392                );
393                Ok(true)
394            }
395
396            Transformation::ConvertType { target_type } => {
397                helpers::convert_detection_item_types(
398                    rule,
399                    state,
400                    detection_item_conditions,
401                    field_name_conditions,
402                    field_name_cond_not,
403                    target_type,
404                );
405                Ok(true)
406            }
407
408            Transformation::Regex => {
409                // No-op: marking that plain strings should be treated as regex.
410                // In eval mode all matching goes through our compiled matchers,
411                // so there is nothing to mutate. Kept for YAML compat.
412                Ok(false)
413            }
414
415            Transformation::AddField { field } => {
416                if !rule.fields.contains(field) {
417                    rule.fields.push(field.clone());
418                }
419                Ok(true)
420            }
421
422            Transformation::RemoveField { field } => {
423                rule.fields.retain(|f| f != field);
424                Ok(true)
425            }
426
427            Transformation::SetField { fields } => {
428                rule.fields = fields.clone();
429                Ok(true)
430            }
431
432            Transformation::SetCustomAttribute { attribute, value } => {
433                rule.custom_attributes
434                    .insert(attribute.clone(), serde_yaml::Value::String(value.clone()));
435                Ok(true)
436            }
437
438            Transformation::CaseTransformation { case_type } => {
439                helpers::apply_case_transformation(
440                    rule,
441                    state,
442                    detection_item_conditions,
443                    field_name_conditions,
444                    field_name_cond_not,
445                    case_type,
446                );
447                Ok(true)
448            }
449
450            Transformation::Nest { items } => {
451                for item in items {
452                    let mut merged_det_conds: Vec<DetectionItemCondition> =
453                        detection_item_conditions.to_vec();
454                    merged_det_conds.extend(item.detection_item_conditions.clone());
455
456                    let mut merged_field_conds: Vec<FieldNameCondition> =
457                        field_name_conditions.to_vec();
458                    merged_field_conds.extend(item.field_name_conditions.clone());
459
460                    let rule_ok = if item.rule_conditions.is_empty() {
461                        true
462                    } else {
463                        super::conditions::all_rule_conditions_match(
464                            &item.rule_conditions,
465                            rule,
466                            state,
467                        )
468                    };
469
470                    if rule_ok {
471                        item.transformation.apply(
472                            rule,
473                            state,
474                            &merged_det_conds,
475                            &merged_field_conds,
476                            item.field_name_cond_not || field_name_cond_not,
477                        )?;
478                        if let Some(ref id) = item.id {
479                            state.mark_applied(id);
480                        }
481                    }
482                }
483                Ok(true)
484            }
485        }
486    }
487}