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