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}