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