Skip to main content

rsigma_eval/
rule_tune.rs

1//! False-positive-driven Sigma filter rule proposals.
2//!
3//! Tuning contrasts events classified as false positives with known true
4//! positives, proposes the narrowest filter that separates them, and verifies
5//! the result through the same filter application path used by [`Engine`].
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use rsigma_parser::ast::{LogSource, SigmaCollection, SigmaRule};
10use rsigma_parser::lint::Severity;
11use serde::Serialize;
12use serde_json::Value;
13
14use crate::Engine;
15use crate::event::JsonEvent;
16use crate::rule_draft::draft_core::{
17    ValueForm, emit_form, infer_form, profile_fields, score_field, yaml_str, yaml_title_str,
18};
19use crate::rule_draft::{DraftConfig, Stability};
20
21/// Tunables for a tuning run.
22#[derive(Debug, Clone)]
23pub struct TuneConfig {
24    /// Maximum fields in one filter selection.
25    pub max_fields: usize,
26    /// Minimum fields required in every emitted selection.
27    pub min_fields: usize,
28    /// Maximum exact values emitted as one OR list.
29    pub max_value_cardinality: usize,
30    /// Minimum shared token length for inferred string forms.
31    pub min_token_len: usize,
32    /// Minimum FP events required for every emitted selection.
33    pub min_cluster_support: usize,
34    /// Maximum selections emitted in one filter rule.
35    pub max_clusters: usize,
36    /// Emit cleanly separable clusters even when some FPs remain uncovered.
37    pub allow_partial: bool,
38    /// Caller-supplied filter UUID. The core never generates randomness.
39    pub filter_id: Option<String>,
40    /// Filter author metadata.
41    pub author: String,
42}
43
44impl Default for TuneConfig {
45    fn default() -> Self {
46        Self {
47            max_fields: 4,
48            min_fields: 2,
49            max_value_cardinality: 8,
50            min_token_len: 4,
51            min_cluster_support: 2,
52            max_clusters: 5,
53            allow_partial: false,
54            filter_id: None,
55            author: "rsigma rule tune".to_string(),
56        }
57    }
58}
59
60/// Why a tuning proposal could not be produced.
61#[derive(Debug, thiserror::Error)]
62pub enum TuneError {
63    /// A reviewability or inference bound is invalid.
64    #[error("invalid tuning config: {0}")]
65    InvalidConfig(String),
66    /// No false-positive events were provided.
67    #[error("no false-positive events to tune")]
68    NoFalsePositives,
69    /// No true-positive events were provided.
70    #[error("no true-positive events to protect")]
71    NoTruePositives,
72    /// Some labeled exemplars do not fire the target rule before filtering.
73    #[error(
74        "labeled exemplars do not fire the target rule before filtering \
75         (false-positive indexes: {fp:?}, true-positive indexes: {tp:?})"
76    )]
77    NonFiringExemplars {
78        /// False-positive indexes that did not fire.
79        fp: Vec<usize>,
80        /// True-positive indexes that did not fire.
81        tp: Vec<usize>,
82    },
83    /// No stable scalar field could be profiled.
84    #[error("no candidate fields survived profiling across {0} false positives")]
85    NoCandidateFields(usize),
86    /// No verified separator protected every TP and covered the required FPs.
87    #[error(
88        "no clean separator found (closest fields: {closest:?}, blocking true-positive indexes: \
89         {blocking_tp:?}, uncovered false-positive indexes: {uncovered_fp:?})"
90    )]
91    NoCleanSeparator {
92        /// Highest-ranked fields considered.
93        closest: Vec<String>,
94        /// True positives suppressed by the closest candidate.
95        blocking_tp: Vec<usize>,
96        /// False positives not covered by the partial proposal.
97        uncovered_fp: Vec<usize>,
98    },
99    /// Emitted YAML failed its own parse, lint, compile, or verification pass.
100    #[error("internal error: emitted filter failed to {stage}: {message}")]
101    Internal {
102        /// Failing stage.
103        stage: String,
104        /// Underlying message.
105        message: String,
106    },
107}
108
109/// Why one profiled field was selected or rejected.
110#[derive(Debug, Clone, Serialize)]
111#[serde(rename_all = "snake_case")]
112pub enum TuneFieldDisposition {
113    /// Included in at least one emitted selection.
114    Selected,
115    /// The field form would suppress one or more protected true positives.
116    MatchesTruePositive,
117    /// The field was useful but ranked below the selected fields.
118    LowerRank,
119    /// No stable value form could be inferred.
120    Volatile,
121}
122
123/// One profiled field in the tuning rationale.
124#[derive(Debug, Clone, Serialize)]
125pub struct TuneFieldReport {
126    /// Dot-joined field path.
127    pub field: String,
128    /// Contrastive score used for deterministic ranking.
129    pub score: f64,
130    /// FP-side value stability.
131    pub stability: Stability,
132    /// Sigma modifier chain selected for the field.
133    pub modifier: String,
134    /// Display values or inferred pattern.
135    pub values: Vec<String>,
136    /// Number of protected TPs matched by this field alone.
137    pub true_positive_hits: usize,
138    /// Selection/rejection rationale.
139    pub disposition: TuneFieldDisposition,
140}
141
142/// One emitted selection and the FP exemplars it covers.
143#[derive(Debug, Clone, Serialize)]
144pub struct TuneSelectionReport {
145    /// Detection identifier in the filter rule.
146    pub name: String,
147    /// Fields included in this conjunction.
148    pub fields: Vec<String>,
149    /// Original FP indexes covered by this selection.
150    pub false_positive_indexes: Vec<usize>,
151}
152
153/// Before/after verification counts.
154#[derive(Debug, Clone, Serialize)]
155pub struct TuneVerification {
156    /// FPs firing before the filter.
157    pub false_positives_before: usize,
158    /// FPs firing after the filter.
159    pub false_positives_after: usize,
160    /// TPs firing before the filter.
161    pub true_positives_before: usize,
162    /// TPs firing after the filter.
163    pub true_positives_after: usize,
164}
165
166/// Backtest expectation evidence attached by a caller.
167#[derive(Debug, Clone, Serialize)]
168pub struct TuneExpectationDiff {
169    /// Existing bounds for the target rule from the supplied expectations file.
170    pub existing: Vec<String>,
171    /// Target fires over the FP corpus before filtering.
172    pub false_positives_before: usize,
173    /// Target fires over the FP corpus after filtering.
174    pub false_positives_after: usize,
175    /// Target fires over the TP corpus before filtering.
176    pub true_positives_before: usize,
177    /// Target fires over the TP corpus after filtering.
178    pub true_positives_after: usize,
179    /// Paste-ready expectations YAML for the two supplied corpora.
180    pub fragment: String,
181}
182
183/// A verified tuning proposal and its rationale.
184#[derive(Debug, Clone, Serialize)]
185pub struct TuneReport {
186    /// Paste-ready Sigma filter rule.
187    pub filter_yaml: String,
188    /// Ranked field rationale.
189    pub fields: Vec<TuneFieldReport>,
190    /// Emitted selection clusters.
191    pub selections: Vec<TuneSelectionReport>,
192    /// Closed before/after verification.
193    pub verification: TuneVerification,
194    /// Fraction of supplied FPs suppressed by the proposal.
195    pub false_positive_coverage: f64,
196    /// Advisory notes, including title targeting fallback and partial coverage.
197    pub warnings: Vec<String>,
198    /// Optional before/after backtest expectation evidence.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub expectation_diff: Option<TuneExpectationDiff>,
201}
202
203#[derive(Debug, Clone)]
204struct Selection {
205    name: String,
206    entries: Vec<(String, ValueForm)>,
207    fp_indexes: Vec<usize>,
208}
209
210#[derive(Debug)]
211struct GroupProposal {
212    selection: Selection,
213    fields: Vec<TuneFieldReport>,
214    blocking_tp: Vec<usize>,
215}
216
217/// Propose and verify a Sigma filter for one target rule.
218pub fn tune_rule(
219    rule: &SigmaRule,
220    false_positives: &[Value],
221    true_positives: &[Value],
222    config: &TuneConfig,
223) -> Result<TuneReport, TuneError> {
224    for (name, value) in [
225        ("max_fields", config.max_fields),
226        ("min_fields", config.min_fields),
227        ("max_value_cardinality", config.max_value_cardinality),
228        ("min_token_len", config.min_token_len),
229        ("min_cluster_support", config.min_cluster_support),
230        ("max_clusters", config.max_clusters),
231    ] {
232        if value == 0 {
233            return Err(TuneError::InvalidConfig(format!(
234                "{name} must be greater than zero"
235            )));
236        }
237    }
238    if config.min_fields > config.max_fields {
239        return Err(TuneError::InvalidConfig(format!(
240            "min_fields ({}) cannot exceed max_fields ({})",
241            config.min_fields, config.max_fields
242        )));
243    }
244    if false_positives.is_empty() {
245        return Err(TuneError::NoFalsePositives);
246    }
247    if true_positives.is_empty() {
248        return Err(TuneError::NoTruePositives);
249    }
250    let fp_before = firing_indexes(rule, false_positives)?;
251    let tp_before = firing_indexes(rule, true_positives)?;
252    if fp_before.len() != false_positives.len() || tp_before.len() != true_positives.len() {
253        return Err(TuneError::NonFiringExemplars {
254            fp: missing_indexes(false_positives.len(), &fp_before),
255            tp: missing_indexes(true_positives.len(), &tp_before),
256        });
257    }
258    if false_positives.len() < config.min_cluster_support {
259        return Err(TuneError::NoCleanSeparator {
260            closest: Vec::new(),
261            blocking_tp: Vec::new(),
262            uncovered_fp: (0..false_positives.len()).collect(),
263        });
264    }
265
266    let all_fp_indexes: Vec<usize> = (0..false_positives.len()).collect();
267    let whole = propose_group(
268        rule,
269        false_positives,
270        &all_fp_indexes,
271        true_positives,
272        config,
273        "selection",
274    )?;
275
276    let (mut proposals, mut uncovered) = if whole.blocking_tp.is_empty() {
277        (vec![whole], Vec::new())
278    } else {
279        propose_clusters(rule, false_positives, true_positives, config, whole)?
280    };
281
282    for (index, proposal) in proposals.iter_mut().enumerate() {
283        proposal.selection.name = if index == 0 {
284            "selection".to_string()
285        } else {
286            format!("selection_{}", index + 1)
287        };
288    }
289
290    let target = rule.id.as_deref().unwrap_or(&rule.title);
291    let mut warnings = Vec::new();
292    if rule.id.is_none() {
293        warnings.push(format!(
294            "target rule has no id; filter targets exact title '{}'",
295            rule.title
296        ));
297    }
298    let selections: Vec<Selection> = proposals.iter().map(|p| p.selection.clone()).collect();
299    let mut filter_yaml = emit_filter_yaml(
300        rule,
301        target,
302        &selections,
303        false_positives.len() - uncovered.len(),
304        true_positives.len(),
305        config,
306    );
307    validate_filter_yaml(&filter_yaml)?;
308
309    let fp_after = firing_indexes_with_filter(rule, &filter_yaml, false_positives)?;
310    let tp_after = firing_indexes_with_filter(rule, &filter_yaml, true_positives)?;
311    if !tp_after.iter().copied().eq(0..true_positives.len())
312        || fp_after.iter().any(|index| !uncovered.contains(index))
313    {
314        return Err(TuneError::Internal {
315            stage: "verify".to_string(),
316            message: format!(
317                "expected all {} TPs and only uncovered FPs to fire; got TP indexes {tp_after:?}, \
318                 FP indexes {fp_after:?}",
319                true_positives.len()
320            ),
321        });
322    }
323    if fp_after != uncovered {
324        uncovered = fp_after.clone();
325        filter_yaml = emit_filter_yaml(
326            rule,
327            target,
328            &selections,
329            false_positives.len() - uncovered.len(),
330            true_positives.len(),
331            config,
332        );
333        validate_filter_yaml(&filter_yaml)?;
334    }
335    if !uncovered.is_empty() {
336        warnings.push(format!(
337            "partial proposal leaves false-positive indexes {uncovered:?} uncovered"
338        ));
339    }
340
341    let selected_fields: BTreeSet<&str> = selections
342        .iter()
343        .flat_map(|selection| selection.entries.iter().map(|(field, _)| field.as_str()))
344        .collect();
345    let mut fields = merge_field_reports(proposals.into_iter().flat_map(|p| p.fields));
346    for field in &mut fields {
347        if selected_fields.contains(field.field.as_str()) {
348            field.disposition = TuneFieldDisposition::Selected;
349        }
350    }
351
352    let selection_reports = selections
353        .iter()
354        .map(|selection| TuneSelectionReport {
355            name: selection.name.clone(),
356            fields: selection
357                .entries
358                .iter()
359                .map(|(field, _)| field.clone())
360                .collect(),
361            false_positive_indexes: selection.fp_indexes.clone(),
362        })
363        .collect();
364
365    Ok(TuneReport {
366        filter_yaml,
367        fields,
368        selections: selection_reports,
369        verification: TuneVerification {
370            false_positives_before: false_positives.len(),
371            false_positives_after: fp_after.len(),
372            true_positives_before: true_positives.len(),
373            true_positives_after: tp_after.len(),
374        },
375        false_positive_coverage: (false_positives.len() - uncovered.len()) as f64
376            / false_positives.len() as f64,
377        warnings,
378        expectation_diff: None,
379    })
380}
381
382fn propose_group(
383    rule: &SigmaRule,
384    all_false_positives: &[Value],
385    fp_indexes: &[usize],
386    true_positives: &[Value],
387    config: &TuneConfig,
388    name: &str,
389) -> Result<GroupProposal, TuneError> {
390    let values: Vec<&Value> = fp_indexes
391        .iter()
392        .map(|&index| &all_false_positives[index])
393        .collect();
394    let events: Vec<JsonEvent<'_>> = values
395        .iter()
396        .map(|value| JsonEvent::borrow(value))
397        .collect();
398    let draft_config = DraftConfig {
399        max_fields: config.max_fields,
400        min_fields: 1,
401        min_prevalence: 1.0,
402        max_value_cardinality: config.max_value_cardinality,
403        min_token_len: config.min_token_len,
404        ..DraftConfig::default()
405    };
406    let mut warnings = Vec::new();
407    let mut profiles = profile_fields(&events, &draft_config, &mut warnings);
408    for profile in &mut profiles {
409        infer_form(profile, &draft_config);
410        profile.score = score_field(profile, false);
411    }
412    profiles.retain(|profile| profile.form.is_some() && profile.stability != Stability::Volatile);
413    if profiles.is_empty() {
414        return Err(TuneError::NoCandidateFields(fp_indexes.len()));
415    }
416
417    let mut ranked = Vec::new();
418    for profile in profiles {
419        let entry = (
420            profile.field().to_string(),
421            profile.form.clone().expect("retained form"),
422        );
423        let selection = Selection {
424            name: name.to_string(),
425            entries: vec![entry],
426            fp_indexes: fp_indexes.to_vec(),
427        };
428        let yaml = emit_filter_yaml(
429            rule,
430            rule.id.as_deref().unwrap_or(&rule.title),
431            std::slice::from_ref(&selection),
432            fp_indexes.len(),
433            true_positives.len(),
434            config,
435        );
436        let tp_after = firing_indexes_with_filter(rule, &yaml, true_positives)?;
437        let tp_hits = true_positives.len() - tp_after.len();
438        let adjusted_score = profile.score * (1.0 - tp_hits as f64 / true_positives.len() as f64);
439        ranked.push((profile, tp_hits, adjusted_score));
440    }
441    ranked.sort_by(|(a, a_hits, a_score), (b, b_hits, b_score)| {
442        a_hits
443            .cmp(b_hits)
444            .then_with(|| {
445                b_score
446                    .partial_cmp(a_score)
447                    .unwrap_or(std::cmp::Ordering::Equal)
448            })
449            .then_with(|| a.field().cmp(b.field()))
450    });
451
452    let mut entries = Vec::new();
453    let mut blocking_tp: Vec<usize> = (0..true_positives.len()).collect();
454    let mut selected = BTreeSet::new();
455    while entries.len() < config.max_fields && selected.len() < ranked.len() {
456        let mut best: Option<(usize, Vec<usize>)> = None;
457        for (index, (profile, _, _)) in ranked.iter().enumerate() {
458            if selected.contains(&index) {
459                continue;
460            }
461            let mut candidate_entries = entries.clone();
462            candidate_entries.push((
463                profile.field().to_string(),
464                profile.form.clone().expect("ranked form"),
465            ));
466            let selection = Selection {
467                name: name.to_string(),
468                entries: candidate_entries,
469                fp_indexes: fp_indexes.to_vec(),
470            };
471            let yaml = emit_filter_yaml(
472                rule,
473                rule.id.as_deref().unwrap_or(&rule.title),
474                std::slice::from_ref(&selection),
475                fp_indexes.len(),
476                true_positives.len(),
477                config,
478            );
479            let tp_after = firing_indexes_with_filter(rule, &yaml, true_positives)?;
480            let candidate_blocking = missing_indexes(true_positives.len(), &tp_after);
481            if best
482                .as_ref()
483                .is_none_or(|(_, current)| candidate_blocking.len() < current.len())
484            {
485                best = Some((index, candidate_blocking));
486            }
487        }
488        let Some((index, candidate_blocking)) = best else {
489            break;
490        };
491        selected.insert(index);
492        let profile = &ranked[index].0;
493        entries.push((
494            profile.field().to_string(),
495            profile.form.clone().expect("ranked form"),
496        ));
497        blocking_tp = candidate_blocking;
498        if blocking_tp.is_empty() && entries.len() >= config.min_fields {
499            break;
500        }
501    }
502    if entries.len() < config.min_fields {
503        blocking_tp = (0..true_positives.len()).collect();
504    }
505
506    let fields = ranked
507        .into_iter()
508        .map(|(profile, tp_hits, adjusted_score)| TuneFieldReport {
509            field: profile.field().to_string(),
510            score: adjusted_score,
511            stability: profile.stability,
512            modifier: profile
513                .form
514                .as_ref()
515                .map_or_else(String::new, |form| form.modifier().to_string()),
516            values: profile
517                .form
518                .as_ref()
519                .map_or_else(Vec::new, ValueForm::display_values),
520            true_positive_hits: tp_hits,
521            disposition: if tp_hits > 0 {
522                TuneFieldDisposition::MatchesTruePositive
523            } else {
524                TuneFieldDisposition::LowerRank
525            },
526        })
527        .collect();
528
529    Ok(GroupProposal {
530        selection: Selection {
531            name: name.to_string(),
532            entries,
533            fp_indexes: fp_indexes.to_vec(),
534        },
535        fields,
536        blocking_tp,
537    })
538}
539
540fn propose_clusters(
541    rule: &SigmaRule,
542    false_positives: &[Value],
543    true_positives: &[Value],
544    config: &TuneConfig,
545    whole: GroupProposal,
546) -> Result<(Vec<GroupProposal>, Vec<usize>), TuneError> {
547    let partitions = scalar_partitions(false_positives);
548    let mut best_full: Option<Vec<GroupProposal>> = None;
549    let mut best_partial: Option<(Vec<GroupProposal>, Vec<usize>)> = None;
550
551    for groups in partitions.values() {
552        if groups.len() < 2 || groups.len() > config.max_clusters {
553            continue;
554        }
555        if groups
556            .values()
557            .any(|indexes| indexes.len() < config.min_cluster_support)
558        {
559            continue;
560        }
561
562        let mut proposals = Vec::new();
563        let mut uncovered = Vec::new();
564        for indexes in groups.values() {
565            match propose_group(
566                rule,
567                false_positives,
568                indexes,
569                true_positives,
570                config,
571                "selection",
572            ) {
573                Ok(proposal) if proposal.blocking_tp.is_empty() => proposals.push(proposal),
574                Ok(_) | Err(TuneError::NoCandidateFields(_)) => {
575                    uncovered.extend(indexes.iter().copied());
576                }
577                Err(error) => return Err(error),
578            }
579        }
580        uncovered.sort_unstable();
581        if uncovered.is_empty() {
582            if best_full
583                .as_ref()
584                .is_none_or(|best| proposals.len() < best.len())
585            {
586                best_full = Some(proposals);
587            }
588            continue;
589        }
590        if config.allow_partial && !proposals.is_empty() {
591            let covered = false_positives.len() - uncovered.len();
592            let replace = best_partial.as_ref().is_none_or(|(_, best_uncovered)| {
593                covered > false_positives.len() - best_uncovered.len()
594            });
595            if replace {
596                best_partial = Some((proposals, uncovered));
597            }
598        }
599    }
600
601    if let Some(proposals) = best_full {
602        return Ok((proposals, Vec::new()));
603    }
604    if let Some(partial) = best_partial {
605        return Ok(partial);
606    }
607
608    Err(TuneError::NoCleanSeparator {
609        closest: whole
610            .fields
611            .iter()
612            .take(config.max_fields)
613            .map(|field| field.field.clone())
614            .collect(),
615        blocking_tp: whole.blocking_tp,
616        uncovered_fp: if config.allow_partial {
617            (0..false_positives.len()).collect()
618        } else {
619            Vec::new()
620        },
621    })
622}
623
624fn scalar_partitions(events: &[Value]) -> BTreeMap<String, BTreeMap<String, Vec<usize>>> {
625    let mut fields: BTreeMap<String, BTreeMap<String, Vec<usize>>> = BTreeMap::new();
626    for (index, value) in events.iter().enumerate() {
627        let event = JsonEvent::borrow(value);
628        for field in crate::event::Event::field_keys(&event) {
629            let field = field.into_owned();
630            let Some(value) = crate::event::Event::get_field(&event, &field) else {
631                continue;
632            };
633            let key = match value {
634                crate::event::EventValue::Str(value) => value.to_string(),
635                crate::event::EventValue::Int(value) => value.to_string(),
636                crate::event::EventValue::Float(value) => value.to_string(),
637                crate::event::EventValue::Bool(value) => value.to_string(),
638                crate::event::EventValue::Null
639                | crate::event::EventValue::Array(_)
640                | crate::event::EventValue::Map(_) => continue,
641            };
642            fields
643                .entry(field)
644                .or_default()
645                .entry(key)
646                .or_default()
647                .push(index);
648        }
649    }
650    fields.retain(|_, groups| groups.values().map(Vec::len).sum::<usize>() == events.len());
651    fields
652}
653
654fn emit_filter_yaml(
655    rule: &SigmaRule,
656    target: &str,
657    selections: &[Selection],
658    fp_covered: usize,
659    tp_total: usize,
660    config: &TuneConfig,
661) -> String {
662    let mut out = String::new();
663    out.push_str(&format!(
664        "title: {}\n",
665        yaml_title_str(&format!("Tuning filter for {}", rule.title))
666    ));
667    if let Some(id) = &config.filter_id {
668        out.push_str(&format!("id: {}\n", yaml_str(id)));
669    }
670    out.push_str(&format!(
671        "description: {}\n",
672        yaml_str(&format!(
673            "Suppresses {fp_covered} observed false-positive exemplars; verified against {tp_total} true-positive exemplars."
674        ))
675    ));
676    out.push_str(&format!("author: {}\n", yaml_str(&config.author)));
677    emit_logsource(&mut out, &rule.logsource);
678    out.push_str("filter:\n");
679    out.push_str("    rules:\n");
680    out.push_str(&format!("        - {}\n", yaml_str(target)));
681    for selection in selections {
682        out.push_str(&format!("    {}:\n", selection.name));
683        for (field, form) in &selection.entries {
684            emit_form(&mut out, field, form, "        ");
685        }
686    }
687    if selections.len() == 1 {
688        out.push_str("    condition: not selection\n");
689    } else {
690        let names = selections
691            .iter()
692            .map(|selection| selection.name.as_str())
693            .collect::<Vec<_>>()
694            .join(" or ");
695        out.push_str(&format!("    condition: not ({names})\n"));
696    }
697    out
698}
699
700fn emit_logsource(out: &mut String, logsource: &LogSource) {
701    out.push_str("logsource:\n");
702    for (key, value) in [
703        ("category", logsource.category.as_deref()),
704        ("product", logsource.product.as_deref()),
705        ("service", logsource.service.as_deref()),
706        ("definition", logsource.definition.as_deref()),
707    ] {
708        if let Some(value) = value {
709            out.push_str(&format!("    {key}: {}\n", yaml_str(value)));
710        }
711    }
712    let mut custom: Vec<_> = logsource.custom.iter().collect();
713    custom.sort_by_key(|(key, _)| *key);
714    for (key, value) in custom {
715        out.push_str(&format!("    {}: {}\n", yaml_str(key), yaml_str(value)));
716    }
717}
718
719fn validate_filter_yaml(yaml: &str) -> Result<(), TuneError> {
720    let collection =
721        rsigma_parser::parse_sigma_yaml(yaml).map_err(|error| TuneError::Internal {
722            stage: "parse".to_string(),
723            message: error.to_string(),
724        })?;
725    if collection.filters.len() != 1 || collection.has_errors() {
726        return Err(TuneError::Internal {
727            stage: "parse".to_string(),
728            message: format!(
729                "expected one filter and no document errors, got {} filters and {:?}",
730                collection.filters.len(),
731                collection.errors
732            ),
733        });
734    }
735    let errors: Vec<_> = rsigma_parser::lint_yaml_str(yaml)
736        .into_iter()
737        .filter(|warning| warning.severity == Severity::Error)
738        .map(|warning| warning.to_string())
739        .collect();
740    if errors.is_empty() {
741        Ok(())
742    } else {
743        Err(TuneError::Internal {
744            stage: "lint".to_string(),
745            message: errors.join("; "),
746        })
747    }
748}
749
750fn firing_indexes(rule: &SigmaRule, events: &[Value]) -> Result<Vec<usize>, TuneError> {
751    let mut collection = SigmaCollection::new();
752    collection.rules.push(rule.clone());
753    evaluate_collection(&collection, events)
754}
755
756fn firing_indexes_with_filter(
757    rule: &SigmaRule,
758    filter_yaml: &str,
759    events: &[Value],
760) -> Result<Vec<usize>, TuneError> {
761    let parsed =
762        rsigma_parser::parse_sigma_yaml(filter_yaml).map_err(|error| TuneError::Internal {
763            stage: "parse".to_string(),
764            message: error.to_string(),
765        })?;
766    let mut collection = SigmaCollection::new();
767    collection.rules.push(rule.clone());
768    collection.filters.extend(parsed.filters);
769    evaluate_collection(&collection, events)
770}
771
772fn evaluate_collection(
773    collection: &SigmaCollection,
774    events: &[Value],
775) -> Result<Vec<usize>, TuneError> {
776    let mut engine = Engine::new();
777    engine
778        .add_collection(collection)
779        .map_err(|error| TuneError::Internal {
780            stage: "compile".to_string(),
781            message: error.to_string(),
782        })?;
783    Ok(events
784        .iter()
785        .enumerate()
786        .filter_map(|(index, value)| {
787            let event = JsonEvent::borrow(value);
788            (!engine.evaluate(&event).is_empty()).then_some(index)
789        })
790        .collect())
791}
792
793fn missing_indexes(total: usize, present: &[usize]) -> Vec<usize> {
794    let present: BTreeSet<usize> = present.iter().copied().collect();
795    (0..total)
796        .filter(|index| !present.contains(index))
797        .collect()
798}
799
800fn merge_field_reports(reports: impl Iterator<Item = TuneFieldReport>) -> Vec<TuneFieldReport> {
801    let mut by_field: BTreeMap<String, TuneFieldReport> = BTreeMap::new();
802    for report in reports {
803        by_field
804            .entry(report.field.clone())
805            .and_modify(|existing| {
806                if report.score > existing.score {
807                    *existing = report.clone();
808                }
809            })
810            .or_insert(report);
811    }
812    let mut reports: Vec<_> = by_field.into_values().collect();
813    reports.sort_by(|a, b| {
814        b.score
815            .partial_cmp(&a.score)
816            .unwrap_or(std::cmp::Ordering::Equal)
817            .then_with(|| a.field.cmp(&b.field))
818    });
819    reports
820}
821
822#[cfg(test)]
823mod tests {
824    use serde_json::json;
825
826    use super::*;
827
828    fn rule() -> SigmaRule {
829        rsigma_parser::parse_sigma_yaml(
830            r#"
831title: Suspicious Backup Tool
832id: 929a690e-bef0-4204-a928-ef5e620d6fcc
833logsource:
834    category: process_creation
835    product: windows
836detection:
837    selection:
838        Image|endswith: '\backup.exe'
839    condition: selection
840level: medium
841"#,
842        )
843        .unwrap()
844        .rules
845        .remove(0)
846    }
847
848    fn config() -> TuneConfig {
849        TuneConfig {
850            filter_id: Some("3f7b1c2e-9a44-4d1e-8f61-2b0c5d9e7a10".to_string()),
851            min_cluster_support: 1,
852            ..TuneConfig::default()
853        }
854    }
855
856    #[test]
857    fn emits_verified_filter_with_clean_polarity() {
858        let fps = vec![
859            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
860            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
861        ];
862        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
863
864        let report = tune_rule(&rule(), &fps, &tps, &config()).unwrap();
865
866        assert_eq!(report.verification.false_positives_after, 0);
867        assert_eq!(report.verification.true_positives_after, 1);
868        assert!(report.filter_yaml.contains("condition: not selection"));
869        assert!(report.filter_yaml.contains("category: process_creation"));
870        assert!(report.filter_yaml.contains("Image:"));
871        assert!(report.filter_yaml.contains("User: svc_backup"));
872        assert_eq!(report.selections[0].fields.len(), 2);
873        assert!(!report.filter_yaml.contains("status:"));
874        assert!(
875            rsigma_parser::lint_yaml_str(&report.filter_yaml)
876                .iter()
877                .all(|warning| warning.severity != Severity::Error)
878        );
879    }
880
881    #[test]
882    fn rejects_nonfiring_labels_before_profiling() {
883        let fps = vec![json!({"Image": r"C:\Windows\notepad.exe", "User": "svc"})];
884        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
885
886        let error = tune_rule(&rule(), &fps, &tps, &config()).unwrap_err();
887        assert!(matches!(
888            error,
889            TuneError::NonFiringExemplars { fp, tp }
890                if fp == vec![0] && tp.is_empty()
891        ));
892    }
893
894    #[test]
895    fn refuses_single_event_memorization_by_default() {
896        let fps = vec![json!({
897            "Image": r"C:\Program Files\Veeam\backup.exe",
898            "User": "svc_backup"
899        })];
900        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
901        let config = TuneConfig {
902            filter_id: Some("3f7b1c2e-9a44-4d1e-8f61-2b0c5d9e7a10".to_string()),
903            ..TuneConfig::default()
904        };
905
906        let error = tune_rule(&rule(), &fps, &tps, &config).unwrap_err();
907        assert!(matches!(
908            error,
909            TuneError::NoCleanSeparator { uncovered_fp, .. } if uncovered_fp == vec![0]
910        ));
911    }
912
913    #[test]
914    fn rejects_zero_token_length() {
915        let fps = vec![
916            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
917            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
918        ];
919        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
920        let config = TuneConfig {
921            min_token_len: 0,
922            ..config()
923        };
924
925        let error = tune_rule(&rule(), &fps, &tps, &config).unwrap_err();
926        assert!(matches!(
927            error,
928            TuneError::InvalidConfig(message) if message.contains("min_token_len")
929        ));
930    }
931
932    #[test]
933    fn rejects_minimum_fields_above_maximum() {
934        let fps = vec![
935            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
936            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
937        ];
938        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
939        let config = TuneConfig {
940            min_fields: 3,
941            max_fields: 2,
942            ..config()
943        };
944
945        let error = tune_rule(&rule(), &fps, &tps, &config).unwrap_err();
946        assert!(matches!(
947            error,
948            TuneError::InvalidConfig(message) if message.contains("cannot exceed")
949        ));
950    }
951
952    #[test]
953    fn emits_multiple_selections_for_disjoint_fp_clusters() {
954        let fps = vec![
955            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
956            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
957            json!({"Image": r"D:\Tools\Acronis\backup.exe", "User": "svc_acronis"}),
958            json!({"Image": r"D:\Tools\Acronis\backup.exe", "User": "svc_acronis"}),
959        ];
960        let tps = vec![
961            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_acronis"}),
962            json!({"Image": r"D:\Tools\Acronis\backup.exe", "User": "svc_backup"}),
963        ];
964
965        let report = tune_rule(&rule(), &fps, &tps, &config()).unwrap();
966
967        assert_eq!(report.selections.len(), 2);
968        assert!(
969            report
970                .filter_yaml
971                .contains("condition: not (selection or selection_2)")
972        );
973        assert_eq!(report.verification.false_positives_after, 0);
974        assert_eq!(report.verification.true_positives_after, 2);
975    }
976
977    #[test]
978    fn one_of_supports_six_benign_values_without_clustering() {
979        let users = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot"];
980        let fps: Vec<Value> = users
981            .iter()
982            .map(|user| {
983                json!({
984                    "Image": r"C:\Program Files\Veeam\backup.exe",
985                    "User": user
986                })
987            })
988            .collect();
989        let tps = vec![json!({
990            "Image": r"C:\Program Files\Veeam\backup.exe",
991            "User": "attacker"
992        })];
993
994        let report = tune_rule(&rule(), &fps, &tps, &config()).unwrap();
995
996        assert_eq!(report.selections.len(), 1);
997        assert!(report.filter_yaml.contains("foxtrot"));
998    }
999
1000    #[test]
1001    fn partial_mode_emits_only_clean_supported_clusters() {
1002        let fps = vec![
1003            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_good"}),
1004            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_good"}),
1005            json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"}),
1006            json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"}),
1007        ];
1008        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
1009        let partial = TuneConfig {
1010            allow_partial: true,
1011            min_cluster_support: 2,
1012            ..config()
1013        };
1014
1015        let report = tune_rule(&rule(), &fps, &tps, &partial).unwrap();
1016
1017        assert_eq!(report.verification.false_positives_after, 2);
1018        assert_eq!(report.verification.true_positives_after, 1);
1019        assert_eq!(report.false_positive_coverage, 0.5);
1020        assert!(
1021            report
1022                .warnings
1023                .iter()
1024                .any(|warning| warning.contains("[2, 3]"))
1025        );
1026    }
1027
1028    #[test]
1029    fn default_mode_refuses_an_inseparable_cluster() {
1030        let fps = vec![
1031            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_good"}),
1032            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_good"}),
1033            json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"}),
1034            json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"}),
1035        ];
1036        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
1037
1038        let error = tune_rule(&rule(), &fps, &tps, &config()).unwrap_err();
1039        assert!(matches!(error, TuneError::NoCleanSeparator { .. }));
1040    }
1041
1042    #[test]
1043    fn title_fallback_is_explicit_and_deterministic() {
1044        let mut target = rule();
1045        target.id = None;
1046        let fps = vec![
1047            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
1048            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
1049        ];
1050        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
1051
1052        let first = tune_rule(&target, &fps, &tps, &config()).unwrap();
1053        let second = tune_rule(&target, &fps, &tps, &config()).unwrap();
1054
1055        assert_eq!(first.filter_yaml, second.filter_yaml);
1056        assert!(first.filter_yaml.contains("- 'Suspicious Backup Tool'"));
1057        assert!(
1058            first
1059                .warnings
1060                .iter()
1061                .any(|warning| warning.contains("exact title"))
1062        );
1063    }
1064}