Skip to main content

rsigma_eval/pipeline/
mod.rs

1//! Processing pipeline system for transforming Sigma rules before evaluation.
2//!
3//! Pipelines are parsed from YAML and applied to `SigmaRule` AST nodes before
4//! compilation, transforming field names, logsources, values, and detection
5//! structure.
6//!
7//! # Architecture
8//!
9//! 1. Parse pipeline(s) from YAML
10//! 2. Sort by priority (lower = first)
11//! 3. For each rule: apply all pipeline transformations in order
12//! 4. Compile the transformed rule
13//! 5. Evaluate against events
14//!
15//! # Example
16//!
17//! ```rust
18//! use rsigma_eval::pipeline::{Pipeline, parse_pipeline};
19//!
20//! let yaml = r#"
21//! name: Sysmon Field Mapping
22//! priority: 10
23//! transformations:
24//!   - id: sysmon_field_mapping
25//!     type: field_name_mapping
26//!     mapping:
27//!       CommandLine: process.command_line
28//!       ParentImage: process.parent.executable
29//!     rule_conditions:
30//!       - type: logsource
31//!         product: windows
32//! "#;
33//!
34//! let pipeline = parse_pipeline(yaml).unwrap();
35//! assert_eq!(pipeline.name, "Sysmon Field Mapping");
36//! ```
37
38pub mod builtin;
39pub mod conditions;
40pub mod finalizers;
41mod parsing;
42pub mod sources;
43pub mod state;
44pub mod transformations;
45
46#[cfg(test)]
47mod tests;
48
49use std::collections::HashMap;
50
51use rsigma_parser::{CorrelationRule, SigmaCollection, SigmaRule};
52
53use crate::error::{EvalError, Result};
54
55pub use conditions::{
56    DetectionItemCondition, FieldNameCondition, NamedRuleCondition, RuleCondition,
57    eval_condition_expr,
58};
59pub use finalizers::Finalizer;
60pub use parsing::{
61    parse_pipeline, parse_pipeline_file, parse_sources, parse_sources_dir, parse_sources_file,
62    parse_transformation_items, validate_source_refs,
63};
64pub use state::PipelineState;
65pub use transformations::Transformation;
66
67// =============================================================================
68// Pipeline types
69// =============================================================================
70
71/// A processing pipeline consisting of ordered transformations with conditions.
72#[derive(Debug, Clone)]
73pub struct Pipeline {
74    /// Pipeline name.
75    pub name: String,
76    /// Priority (lower runs first). Default: 0.
77    pub priority: i32,
78    /// Pipeline variables used for placeholder expansion.
79    pub vars: HashMap<String, Vec<String>>,
80    /// Ordered list of transformations with their conditions.
81    pub transformations: Vec<TransformationItem>,
82    /// Finalizers (stored for YAML compat; eval-mode ignores them).
83    pub finalizers: Vec<Finalizer>,
84    /// Template references (`${source.*}`) found during parsing.
85    ///
86    /// Source *declarations* live in standalone source files loaded via
87    /// `--source` (see [`parse_sources_file`]); a pipeline only carries the
88    /// references it makes to them.
89    pub source_refs: Vec<sources::SourceRef>,
90}
91
92/// A single transformation with its gating conditions.
93#[derive(Debug, Clone)]
94pub struct TransformationItem {
95    /// Optional ID for tracking in pipeline state.
96    pub id: Option<String>,
97    /// The transformation to apply.
98    pub transformation: Transformation,
99    /// Rule-level conditions (all must match for the transformation to fire).
100    pub rule_conditions: Vec<NamedRuleCondition>,
101    /// Optional logical expression over condition IDs.
102    pub rule_cond_expr: Option<String>,
103    /// Detection-item-level conditions.
104    pub detection_item_conditions: Vec<DetectionItemCondition>,
105    /// Field-name-level conditions.
106    pub field_name_conditions: Vec<FieldNameCondition>,
107    /// If true, negate the field name conditions.
108    pub field_name_cond_not: bool,
109}
110
111// =============================================================================
112// Pipeline application
113// =============================================================================
114
115impl Pipeline {
116    /// Apply this pipeline to a single `SigmaRule`, mutating it in place.
117    pub fn apply(&self, rule: &mut SigmaRule, state: &mut PipelineState) -> Result<()> {
118        state.reset_rule();
119
120        for item in &self.transformations {
121            // Check rule-level conditions
122            if !self.check_rule_conditions(rule, state, item) {
123                continue;
124            }
125
126            state.reset_detection_item();
127
128            // Apply the transformation
129            let applied = item.transformation.apply(
130                rule,
131                state,
132                &item.detection_item_conditions,
133                &item.field_name_conditions,
134                item.field_name_cond_not,
135            )?;
136
137            // Track application in state
138            if applied && let Some(ref id) = item.id {
139                state.mark_applied(id);
140            }
141        }
142
143        Ok(())
144    }
145
146    /// Apply this pipeline to all rules in a collection.
147    ///
148    /// Returns cloned, transformed rules (originals are not modified).
149    ///
150    /// One `PipelineState` is shared by every rule, so `set_state` values and
151    /// `applied_items` accumulate across the collection. For several pipelines
152    /// at once, or for per-rule applied ids and state, use
153    /// [`transform_collection`].
154    pub fn apply_to_collection(&self, collection: &SigmaCollection) -> Result<Vec<SigmaRule>> {
155        let mut state = PipelineState::new(self.vars.clone());
156        let mut transformed = Vec::with_capacity(collection.rules.len());
157
158        for rule in &collection.rules {
159            let mut cloned = rule.clone();
160            self.apply(&mut cloned, &mut state)?;
161            transformed.push(cloned);
162        }
163
164        Ok(transformed)
165    }
166
167    fn check_rule_conditions(
168        &self,
169        rule: &SigmaRule,
170        state: &PipelineState,
171        item: &TransformationItem,
172    ) -> bool {
173        if item.rule_conditions.is_empty() {
174            return true;
175        }
176
177        if let Some(ref expr) = item.rule_cond_expr {
178            let mut results = HashMap::new();
179            for (i, named) in item.rule_conditions.iter().enumerate() {
180                let id = named.id.clone().unwrap_or_else(|| format!("cond_{i}"));
181                results.insert(id, named.condition.matches_rule(rule, state));
182            }
183            return eval_condition_expr(expr, &results);
184        }
185
186        // Default: all conditions must match (AND)
187        item.rule_conditions
188            .iter()
189            .all(|c| c.condition.matches_rule(rule, state))
190    }
191
192    /// Apply this pipeline to a correlation rule, mutating it in place.
193    ///
194    /// Only correlation-applicable transformations fire:
195    /// - `FieldNameMapping` / `FieldNamePrefixMapping` — remap `group_by` and
196    ///   `aliases` mapping values
197    /// - `FieldNamePrefix` / `FieldNameSuffix` — modify `group_by` and alias values
198    /// - `SetCustomAttribute` — set key-value on `custom_attributes`
199    /// - `SetState` — update pipeline state
200    /// - `RuleFailure` — error if conditions match
201    ///
202    /// Detection-specific transforms (value replacements, detection item
203    /// manipulation, etc.) are silently skipped.
204    pub fn apply_to_correlation(
205        &self,
206        corr: &mut CorrelationRule,
207        state: &mut PipelineState,
208    ) -> Result<()> {
209        state.reset_rule();
210
211        for item in &self.transformations {
212            if !self.check_correlation_conditions(corr, state, item) {
213                continue;
214            }
215
216            state.reset_detection_item();
217
218            let applied = apply_correlation_transformation(corr, &item.transformation, state)?;
219
220            if applied && let Some(ref id) = item.id {
221                state.mark_applied(id);
222            }
223        }
224
225        Ok(())
226    }
227
228    /// Returns `true` if this pipeline contains any `${source.*}` template
229    /// references (and therefore depends on external dynamic sources).
230    pub fn is_dynamic(&self) -> bool {
231        !self.source_refs.is_empty()
232    }
233
234    /// Returns a slice of all source references found during parsing.
235    pub fn dynamic_references(&self) -> &[sources::SourceRef] {
236        &self.source_refs
237    }
238
239    fn check_correlation_conditions(
240        &self,
241        corr: &CorrelationRule,
242        state: &PipelineState,
243        item: &TransformationItem,
244    ) -> bool {
245        if item.rule_conditions.is_empty() {
246            return true;
247        }
248
249        if let Some(ref expr) = item.rule_cond_expr {
250            let mut results = HashMap::new();
251            for (i, named) in item.rule_conditions.iter().enumerate() {
252                let id = named.id.clone().unwrap_or_else(|| format!("cond_{i}"));
253                results.insert(id, named.condition.matches_correlation(corr, state));
254            }
255            return eval_condition_expr(expr, &results);
256        }
257
258        item.rule_conditions
259            .iter()
260            .all(|c| c.condition.matches_correlation(corr, state))
261    }
262}
263
264/// Apply a single transformation to a correlation rule.
265///
266/// Returns `true` if the transformation was meaningfully applied.
267fn apply_correlation_transformation(
268    corr: &mut CorrelationRule,
269    transformation: &Transformation,
270    state: &mut PipelineState,
271) -> Result<bool> {
272    match transformation {
273        Transformation::FieldNameMapping { mapping } => {
274            // Match pySigma's FieldMappingTransformationBase.apply() for
275            // correlation rules: group_by expands all alternatives, while
276            // aliases and threshold field reject one-to-many mappings.
277            let alias_names: std::collections::HashSet<String> =
278                corr.aliases.iter().map(|a| a.alias.clone()).collect();
279
280            // aliases: error if any mapping value has multiple alternatives
281            for alias in &mut corr.aliases {
282                for (rule_ref, field_name) in &mut alias.mapping {
283                    if let Some(alts) = mapping.get(field_name.as_str())
284                        && alts.len() > 1
285                    {
286                        return Err(EvalError::InvalidModifiers(format!(
287                            "field_name_mapping one-to-many cannot be applied to \
288                             correlation alias mapping (alias '{}', rule '{}', \
289                             field '{}' maps to {} alternatives)",
290                            alias.alias,
291                            rule_ref,
292                            field_name,
293                            alts.len(),
294                        )));
295                    } else if let Some(alts) = mapping.get(field_name.as_str()) {
296                        *field_name = alts[0].clone();
297                    }
298                }
299            }
300
301            // group_by: expand all alternatives (skip alias names)
302            corr.group_by = corr
303                .group_by
304                .iter()
305                .flat_map(|field_name| {
306                    if alias_names.contains(field_name.as_str()) {
307                        vec![field_name.clone()]
308                    } else if let Some(alts) = mapping.get(field_name.as_str()) {
309                        if alts.len() > 1 {
310                            log::warn!(
311                                "correlation '{}': group_by field '{}' has a one-to-many \
312                                 mapping ({} alternatives: {:?}); expanding all — \
313                                 correlation grouping may be broader than intended",
314                                corr.title,
315                                field_name,
316                                alts.len(),
317                                alts,
318                            );
319                        }
320                        alts.clone()
321                    } else {
322                        vec![field_name.clone()]
323                    }
324                })
325                .collect();
326
327            // threshold field: error if multiple alternatives
328            if let rsigma_parser::CorrelationCondition::Threshold { ref mut field, .. } =
329                corr.condition
330                && let Some(fields) = field.as_mut()
331            {
332                for f in fields.iter_mut() {
333                    if let Some(alts) = mapping.get(f.as_str()) {
334                        if alts.len() > 1 {
335                            return Err(EvalError::InvalidModifiers(format!(
336                                "field_name_mapping one-to-many cannot be applied to \
337                                 correlation condition field reference ('{}' maps to \
338                                 {} alternatives)",
339                                f,
340                                alts.len(),
341                            )));
342                        }
343                        *f = alts[0].clone();
344                    }
345                }
346            }
347
348            Ok(true)
349        }
350
351        Transformation::FieldNamePrefixMapping { mapping } => {
352            remap_correlation_fields(corr, |name| {
353                for (prefix, replacement) in mapping {
354                    if let Some(rest) = name.strip_prefix(prefix.as_str()) {
355                        return Some(format!("{replacement}{rest}"));
356                    }
357                }
358                None
359            });
360            Ok(true)
361        }
362
363        Transformation::FieldNamePrefix { prefix } => {
364            remap_correlation_fields(corr, |name| Some(format!("{prefix}{name}")));
365            Ok(true)
366        }
367
368        Transformation::FieldNameSuffix { suffix } => {
369            remap_correlation_fields(corr, |name| Some(format!("{name}{suffix}")));
370            Ok(true)
371        }
372
373        Transformation::SetCustomAttribute { attribute, value } => {
374            corr.custom_attributes
375                .insert(attribute.clone(), yaml_serde::Value::String(value.clone()));
376            Ok(true)
377        }
378
379        Transformation::SetState { key, value } => {
380            state.set_state(key.clone(), serde_json::Value::String(value.clone()));
381            Ok(true)
382        }
383
384        Transformation::RuleFailure { message } => Err(EvalError::InvalidModifiers(format!(
385            "Pipeline rule failure: {message} (correlation: {})",
386            corr.title
387        ))),
388
389        // Detection-specific transforms are no-ops for correlations
390        _ => Ok(false),
391    }
392}
393
394/// Apply a field name mapping function to all field references in a correlation rule:
395/// `group_by` entries, `aliases` mapping values, and the `condition` field.
396fn remap_correlation_fields(corr: &mut CorrelationRule, mapper: impl Fn(&str) -> Option<String>) {
397    for field in &mut corr.group_by {
398        if let Some(new_name) = mapper(field) {
399            *field = new_name;
400        }
401    }
402
403    for alias in &mut corr.aliases {
404        let remapped: HashMap<String, String> = alias
405            .mapping
406            .iter()
407            .map(|(rule_ref, field_name)| {
408                let new_name = mapper(field_name).unwrap_or_else(|| field_name.clone());
409                (rule_ref.clone(), new_name)
410            })
411            .collect();
412        alias.mapping = remapped;
413    }
414
415    if let rsigma_parser::CorrelationCondition::Threshold { ref mut field, .. } = corr.condition
416        && let Some(fields) = field.as_mut()
417    {
418        for f in fields.iter_mut() {
419            if let Some(new_name) = mapper(f) {
420                *f = new_name;
421            }
422        }
423    }
424}
425
426// =============================================================================
427// Multi-pipeline support
428// =============================================================================
429
430/// Sort pipelines by priority in place, lower first.
431///
432/// Despite the name this only orders the slice; it neither combines the
433/// pipelines nor applies them. Call it before [`apply_pipelines`] and friends,
434/// which walk the slice as given. [`Engine::add_pipeline`](crate::Engine) sorts
435/// on insert, so engine callers get this for free.
436pub fn merge_pipelines(pipelines: &mut [Pipeline]) {
437    pipelines.sort_by_key(|p| p.priority);
438}
439
440/// Apply multiple pipelines to a rule, in the order of the slice.
441///
442/// Ordering is the caller's: sort with [`merge_pipelines`] first if the
443/// pipelines are meant to run by `priority`.
444///
445/// Each pipeline gets its own `PipelineState`, but the state is carried across
446/// transformations within a single pipeline.
447pub fn apply_pipelines(pipelines: &[Pipeline], rule: &mut SigmaRule) -> Result<()> {
448    for pipeline in pipelines {
449        let mut state = PipelineState::new(pipeline.vars.clone());
450        pipeline.apply(rule, &mut state)?;
451    }
452    Ok(())
453}
454
455/// Apply multiple pipelines to a rule, returning the merged [`PipelineState`].
456///
457/// Runs the pipelines in slice order, like [`apply_pipelines`].
458///
459/// Unlike [`apply_pipelines`], this function accumulates state from all pipelines
460/// into a single `PipelineState` so that conversion backends can read values set
461/// by `SetState` and `QueryExpressionPlaceholders` transformations.
462pub fn apply_pipelines_with_state(
463    pipelines: &[Pipeline],
464    rule: &mut SigmaRule,
465) -> Result<PipelineState> {
466    let mut merged = PipelineState::default();
467    for pipeline in pipelines {
468        let mut state = PipelineState::new(pipeline.vars.clone());
469        pipeline.apply(rule, &mut state)?;
470        for (k, v) in state.state {
471            merged.state.insert(k, v);
472        }
473        merged.applied_items.extend(state.applied_items);
474        merged.vars.extend(state.vars);
475    }
476    Ok(merged)
477}
478
479/// A rule after pipeline application, with the transformations that fired.
480///
481/// Returned by [`transform_rule`] and [`transform_collection`] for callers that
482/// need to read the rewritten rule itself rather than evaluate it: a collector
483/// deriving which log channels to subscribe to from the post-pipeline
484/// `logsource`, a report showing the injected conditions, or a test asserting
485/// that a mapping applied.
486#[derive(Debug, Clone)]
487pub struct TransformedRule {
488    /// The rule after every pipeline ran.
489    pub rule: SigmaRule,
490    /// Ids of the transformations that fired, sorted. Transformations without
491    /// an `id:` are not tracked, so this can be empty even though `rule`
492    /// changed.
493    pub applied_items: Vec<String>,
494    /// The merged state the pipelines accumulated (see
495    /// [`apply_pipelines_with_state`]).
496    pub state: PipelineState,
497}
498
499/// Apply `pipelines` to a clone of `rule` and return the rewritten rule.
500///
501/// The input rule is left untouched. This is the inspection counterpart to
502/// loading rules into an engine: the engine applies the same pipelines and then
503/// keeps only the compiled form, so a caller that needs the rewritten Sigma AST
504/// (injected conditions, renamed fields, a `change_logsource` rewrite) asks for
505/// it here.
506///
507/// Pipelines run in slice order, like [`apply_pipelines`]. Sort with
508/// [`merge_pipelines`] first to match what an [`Engine`](crate::Engine) does,
509/// since it keeps its own pipelines sorted by `priority`.
510///
511/// Call this once per rule set load, not per event: it clones and re-transforms
512/// the rule, exactly like the load path does. When only the rewritten logsource
513/// matters, prefer reading it off the loaded compiled rules instead, which costs
514/// nothing extra.
515///
516/// # Example
517///
518/// ```rust
519/// use rsigma_eval::pipeline::{parse_pipeline, transform_rule};
520/// use rsigma_parser::parse_sigma_yaml;
521///
522/// let pipeline = parse_pipeline(
523///     r#"
524/// name: sysmon routing
525/// transformations:
526///   - id: process_creation
527///     type: add_condition
528///     conditions:
529///       EventID: 1
530///     rule_conditions:
531///       - type: logsource
532///         category: process_creation
533///   - id: sysmon_logsource
534///     type: change_logsource
535///     service: sysmon
536///     rule_conditions:
537///       - type: logsource
538///         product: windows
539/// "#,
540/// )?;
541///
542/// let collection = parse_sigma_yaml(
543///     r#"
544/// title: Whoami
545/// logsource:
546///     product: windows
547///     category: process_creation
548/// detection:
549///     selection:
550///         CommandLine|contains: whoami
551///     condition: selection
552/// "#,
553/// )?;
554///
555/// let transformed = transform_rule(&[pipeline], &collection.rules[0])?;
556///
557/// assert_eq!(transformed.rule.logsource.service.as_deref(), Some("sysmon"));
558/// assert!(transformed.applied_items.contains(&"process_creation".to_string()));
559/// # Ok::<(), Box<dyn std::error::Error>>(())
560/// ```
561pub fn transform_rule(pipelines: &[Pipeline], rule: &SigmaRule) -> Result<TransformedRule> {
562    let mut transformed = rule.clone();
563    let state = apply_pipelines_with_state(pipelines, &mut transformed)?;
564    let mut applied_items: Vec<String> = state.applied_items.iter().cloned().collect();
565    applied_items.sort();
566    Ok(TransformedRule {
567        rule: transformed,
568        applied_items,
569        state,
570    })
571}
572
573/// Apply `pipelines` to every detection rule in `collection`.
574///
575/// Per-rule equivalent of [`transform_rule`], in collection order. Correlation
576/// and filter rules are not included; correlation rules transform through
577/// [`apply_pipelines_to_correlation`].
578///
579/// Each rule is transformed with its own state, so `applied_items` and `state`
580/// on each result describe that rule alone. This is the difference from
581/// [`Pipeline::apply_to_collection`], which shares one state across the whole
582/// collection and takes a single pipeline.
583pub fn transform_collection(
584    pipelines: &[Pipeline],
585    collection: &SigmaCollection,
586) -> Result<Vec<TransformedRule>> {
587    collection
588        .rules
589        .iter()
590        .map(|rule| transform_rule(pipelines, rule))
591        .collect()
592}
593
594/// Apply multiple pipelines to a correlation rule in slice order, returning the
595/// merged pipeline state.
596///
597/// As with [`apply_pipelines`], sort with [`merge_pipelines`] first to run them
598/// by `priority`.
599pub fn apply_pipelines_to_correlation(
600    pipelines: &[Pipeline],
601    corr: &mut CorrelationRule,
602) -> Result<PipelineState> {
603    let mut merged = PipelineState::default();
604    for pipeline in pipelines {
605        let mut state = PipelineState::new(pipeline.vars.clone());
606        pipeline.apply_to_correlation(corr, &mut state)?;
607        for (k, v) in state.state {
608            merged.state.insert(k, v);
609        }
610        merged.applied_items.extend(state.applied_items);
611        merged.vars.extend(state.vars);
612    }
613    Ok(merged)
614}