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