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    /// Unresolved dynamic include directive.
168    ///
169    /// Represents `include: "${source.name}"` in the pipeline YAML. This is a
170    /// placeholder that will be expanded into actual transformations when
171    /// dynamic sources are resolved (Phase 2). At evaluation time, it is a
172    /// no-op.
173    Include { template: String },
174}
175
176// =============================================================================
177// Application logic
178// =============================================================================
179
180impl Transformation {
181    /// Apply this transformation to a `SigmaRule`, mutating it in place.
182    ///
183    /// Returns `Ok(true)` if the transformation was applied, `Ok(false)` if skipped.
184    pub fn apply(
185        &self,
186        rule: &mut SigmaRule,
187        state: &mut PipelineState,
188        detection_item_conditions: &[DetectionItemCondition],
189        field_name_conditions: &[FieldNameCondition],
190        field_name_cond_not: bool,
191    ) -> Result<bool> {
192        match self {
193            Transformation::FieldNameMapping { mapping } => {
194                helpers::apply_field_name_transform(
195                    rule,
196                    state,
197                    field_name_conditions,
198                    field_name_cond_not,
199                    |name| mapping.get(name).cloned(),
200                )?;
201                Ok(true)
202            }
203
204            Transformation::FieldNamePrefixMapping { mapping } => {
205                helpers::apply_field_name_transform(
206                    rule,
207                    state,
208                    field_name_conditions,
209                    field_name_cond_not,
210                    |name| {
211                        for (prefix, replacement) in mapping {
212                            if name.starts_with(prefix.as_str()) {
213                                return Some(vec![format!(
214                                    "{}{}",
215                                    replacement,
216                                    &name[prefix.len()..]
217                                )]);
218                            }
219                        }
220                        None
221                    },
222                )?;
223                Ok(true)
224            }
225
226            Transformation::FieldNamePrefix { prefix } => {
227                helpers::apply_field_name_transform(
228                    rule,
229                    state,
230                    field_name_conditions,
231                    field_name_cond_not,
232                    |name| Some(vec![format!("{prefix}{name}")]),
233                )?;
234                Ok(true)
235            }
236
237            Transformation::FieldNameSuffix { suffix } => {
238                helpers::apply_field_name_transform(
239                    rule,
240                    state,
241                    field_name_conditions,
242                    field_name_cond_not,
243                    |name| Some(vec![format!("{name}{suffix}")]),
244                )?;
245                Ok(true)
246            }
247
248            Transformation::DropDetectionItem => {
249                helpers::drop_detection_items(
250                    rule,
251                    state,
252                    detection_item_conditions,
253                    field_name_conditions,
254                    field_name_cond_not,
255                );
256                Ok(true)
257            }
258
259            Transformation::AddCondition {
260                conditions,
261                negated,
262            } => {
263                helpers::add_conditions(rule, conditions, *negated);
264                Ok(true)
265            }
266
267            Transformation::ChangeLogsource {
268                category,
269                product,
270                service,
271            } => {
272                if let Some(cat) = category {
273                    rule.logsource.category = Some(cat.clone());
274                }
275                if let Some(prod) = product {
276                    rule.logsource.product = Some(prod.clone());
277                }
278                if let Some(svc) = service {
279                    rule.logsource.service = Some(svc.clone());
280                }
281                Ok(true)
282            }
283
284            Transformation::ReplaceString {
285                regex,
286                replacement,
287                skip_special,
288            } => {
289                let re = Regex::new(regex)
290                    .map_err(|e| EvalError::InvalidModifiers(format!("bad regex: {e}")))?;
291                helpers::replace_strings_in_rule(
292                    rule,
293                    state,
294                    detection_item_conditions,
295                    field_name_conditions,
296                    field_name_cond_not,
297                    &re,
298                    replacement,
299                    *skip_special,
300                );
301                Ok(true)
302            }
303
304            Transformation::ValuePlaceholders => {
305                helpers::expand_placeholders_in_rule(rule, state, false);
306                Ok(true)
307            }
308
309            Transformation::WildcardPlaceholders => {
310                helpers::expand_placeholders_in_rule(rule, state, true);
311                Ok(true)
312            }
313
314            Transformation::QueryExpressionPlaceholders { expression } => {
315                state.set_state(
316                    "query_expression_template".to_string(),
317                    serde_json::Value::String(expression.clone()),
318                );
319                Ok(true)
320            }
321
322            Transformation::SetState { key, value } => {
323                state.set_state(key.clone(), serde_json::Value::String(value.clone()));
324                Ok(true)
325            }
326
327            Transformation::RuleFailure { message } => Err(EvalError::InvalidModifiers(format!(
328                "Pipeline rule failure: {message} (rule: {})",
329                rule.title
330            ))),
331
332            Transformation::DetectionItemFailure { message } => {
333                let has_match =
334                    helpers::rule_has_matching_item(rule, state, detection_item_conditions);
335                if has_match {
336                    Err(EvalError::InvalidModifiers(format!(
337                        "Pipeline detection item failure: {message} (rule: {})",
338                        rule.title
339                    )))
340                } else {
341                    Ok(false)
342                }
343            }
344
345            Transformation::FieldNameTransform {
346                transform_func,
347                mapping,
348            } => {
349                let func = transform_func.clone();
350                let map = mapping.clone();
351                helpers::apply_field_name_transform(
352                    rule,
353                    state,
354                    field_name_conditions,
355                    field_name_cond_not,
356                    |name| {
357                        if let Some(mapped) = map.get(name) {
358                            return Some(vec![mapped.clone()]);
359                        }
360                        Some(vec![helpers::apply_named_string_fn(&func, name)])
361                    },
362                )?;
363                Ok(true)
364            }
365
366            Transformation::HashesFields {
367                valid_hash_algos,
368                field_prefix,
369                drop_algo_prefix,
370            } => {
371                helpers::decompose_hashes_field(
372                    rule,
373                    valid_hash_algos,
374                    field_prefix,
375                    *drop_algo_prefix,
376                );
377                Ok(true)
378            }
379
380            Transformation::MapString { mapping } => {
381                helpers::map_string_values(
382                    rule,
383                    state,
384                    detection_item_conditions,
385                    field_name_conditions,
386                    field_name_cond_not,
387                    mapping,
388                );
389                Ok(true)
390            }
391
392            Transformation::SetValue { value } => {
393                helpers::set_detection_item_values(
394                    rule,
395                    state,
396                    detection_item_conditions,
397                    field_name_conditions,
398                    field_name_cond_not,
399                    value,
400                );
401                Ok(true)
402            }
403
404            Transformation::ConvertType { target_type } => {
405                helpers::convert_detection_item_types(
406                    rule,
407                    state,
408                    detection_item_conditions,
409                    field_name_conditions,
410                    field_name_cond_not,
411                    target_type,
412                );
413                Ok(true)
414            }
415
416            Transformation::Regex => {
417                // No-op: marking that plain strings should be treated as regex.
418                // In eval mode all matching goes through our compiled matchers,
419                // so there is nothing to mutate. Kept for YAML compat.
420                Ok(false)
421            }
422
423            Transformation::AddField { field } => {
424                if !rule.fields.contains(field) {
425                    rule.fields.push(field.clone());
426                }
427                Ok(true)
428            }
429
430            Transformation::RemoveField { field } => {
431                rule.fields.retain(|f| f != field);
432                Ok(true)
433            }
434
435            Transformation::SetField { fields } => {
436                rule.fields = fields.clone();
437                Ok(true)
438            }
439
440            Transformation::SetCustomAttribute { attribute, value } => {
441                rule.custom_attributes
442                    .insert(attribute.clone(), serde_yaml::Value::String(value.clone()));
443                Ok(true)
444            }
445
446            Transformation::CaseTransformation { case_type } => {
447                helpers::apply_case_transformation(
448                    rule,
449                    state,
450                    detection_item_conditions,
451                    field_name_conditions,
452                    field_name_cond_not,
453                    case_type,
454                );
455                Ok(true)
456            }
457
458            Transformation::Nest { items } => {
459                for item in items {
460                    let mut merged_det_conds: Vec<DetectionItemCondition> =
461                        detection_item_conditions.to_vec();
462                    merged_det_conds.extend(item.detection_item_conditions.clone());
463
464                    let mut merged_field_conds: Vec<FieldNameCondition> =
465                        field_name_conditions.to_vec();
466                    merged_field_conds.extend(item.field_name_conditions.clone());
467
468                    let rule_ok = if item.rule_conditions.is_empty() {
469                        true
470                    } else {
471                        super::conditions::all_rule_conditions_match(
472                            &item.rule_conditions,
473                            rule,
474                            state,
475                        )
476                    };
477
478                    if rule_ok {
479                        item.transformation.apply(
480                            rule,
481                            state,
482                            &merged_det_conds,
483                            &merged_field_conds,
484                            item.field_name_cond_not || field_name_cond_not,
485                        )?;
486                        if let Some(ref id) = item.id {
487                            state.mark_applied(id);
488                        }
489                    }
490                }
491                Ok(true)
492            }
493
494            Transformation::Include { .. } => Ok(false),
495        }
496    }
497}