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