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}