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}