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