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 /// `yaml_serde::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(), yaml_serde::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}