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
291        .id
292        .as_deref()
293        .or(rule.name.as_deref())
294        .unwrap_or(&rule.title);
295    let mut warnings = Vec::new();
296    if rule.id.is_none() && rule.name.is_none() {
297        warnings.push(format!(
298            "target rule has no id or name; filter targets exact title '{}'",
299            rule.title
300        ));
301    }
302    let selections: Vec<Selection> = proposals.iter().map(|p| p.selection.clone()).collect();
303    let mut filter_yaml = emit_filter_yaml(
304        rule,
305        target,
306        &selections,
307        false_positives.len() - uncovered.len(),
308        true_positives.len(),
309        config,
310    );
311    validate_filter_yaml(&filter_yaml)?;
312
313    let fp_after = firing_indexes_with_filter(rule, &filter_yaml, false_positives)?;
314    let tp_after = firing_indexes_with_filter(rule, &filter_yaml, true_positives)?;
315    if !tp_after.iter().copied().eq(0..true_positives.len())
316        || fp_after.iter().any(|index| !uncovered.contains(index))
317    {
318        return Err(TuneError::Internal {
319            stage: "verify".to_string(),
320            message: format!(
321                "expected all {} TPs and only uncovered FPs to fire; got TP indexes {tp_after:?}, \
322                 FP indexes {fp_after:?}",
323                true_positives.len()
324            ),
325        });
326    }
327    if fp_after != uncovered {
328        uncovered = fp_after.clone();
329        filter_yaml = emit_filter_yaml(
330            rule,
331            target,
332            &selections,
333            false_positives.len() - uncovered.len(),
334            true_positives.len(),
335            config,
336        );
337        validate_filter_yaml(&filter_yaml)?;
338    }
339    if !uncovered.is_empty() {
340        warnings.push(format!(
341            "partial proposal leaves false-positive indexes {uncovered:?} uncovered"
342        ));
343    }
344
345    let selected_fields: BTreeSet<&str> = selections
346        .iter()
347        .flat_map(|selection| selection.entries.iter().map(|(field, _)| field.as_str()))
348        .collect();
349    let mut fields = merge_field_reports(proposals.into_iter().flat_map(|p| p.fields));
350    for field in &mut fields {
351        if selected_fields.contains(field.field.as_str()) {
352            field.disposition = TuneFieldDisposition::Selected;
353        }
354    }
355
356    let selection_reports = selections
357        .iter()
358        .map(|selection| TuneSelectionReport {
359            name: selection.name.clone(),
360            fields: selection
361                .entries
362                .iter()
363                .map(|(field, _)| field.clone())
364                .collect(),
365            false_positive_indexes: selection.fp_indexes.clone(),
366        })
367        .collect();
368
369    Ok(TuneReport {
370        filter_yaml,
371        fields,
372        selections: selection_reports,
373        verification: TuneVerification {
374            false_positives_before: false_positives.len(),
375            false_positives_after: fp_after.len(),
376            true_positives_before: true_positives.len(),
377            true_positives_after: tp_after.len(),
378        },
379        false_positive_coverage: (false_positives.len() - uncovered.len()) as f64
380            / false_positives.len() as f64,
381        warnings,
382        expectation_diff: None,
383    })
384}
385
386fn propose_group(
387    rule: &SigmaRule,
388    all_false_positives: &[Value],
389    fp_indexes: &[usize],
390    true_positives: &[Value],
391    config: &TuneConfig,
392    name: &str,
393) -> Result<GroupProposal, TuneError> {
394    let values: Vec<&Value> = fp_indexes
395        .iter()
396        .map(|&index| &all_false_positives[index])
397        .collect();
398    let events: Vec<JsonEvent<'_>> = values
399        .iter()
400        .map(|value| JsonEvent::borrow(value))
401        .collect();
402    let draft_config = DraftConfig {
403        max_fields: config.max_fields,
404        min_fields: 1,
405        min_prevalence: 1.0,
406        max_value_cardinality: config.max_value_cardinality,
407        min_token_len: config.min_token_len,
408        ..DraftConfig::default()
409    };
410    let mut warnings = Vec::new();
411    let mut profiles = profile_fields(&events, &draft_config, &mut warnings);
412    for profile in &mut profiles {
413        infer_form(profile, &draft_config);
414        profile.score = score_field(profile, false);
415    }
416    profiles.retain(|profile| profile.form.is_some() && profile.stability != Stability::Volatile);
417    if profiles.is_empty() {
418        return Err(TuneError::NoCandidateFields(fp_indexes.len()));
419    }
420
421    let mut ranked = Vec::new();
422    for profile in profiles {
423        let entry = (
424            profile.field().to_string(),
425            profile.form.clone().expect("retained form"),
426        );
427        let selection = Selection {
428            name: name.to_string(),
429            entries: vec![entry],
430            fp_indexes: fp_indexes.to_vec(),
431        };
432        let yaml = emit_filter_yaml(
433            rule,
434            rule.id.as_deref().unwrap_or(&rule.title),
435            std::slice::from_ref(&selection),
436            fp_indexes.len(),
437            true_positives.len(),
438            config,
439        );
440        let tp_after = firing_indexes_with_filter(rule, &yaml, true_positives)?;
441        let tp_hits = true_positives.len() - tp_after.len();
442        let adjusted_score = profile.score * (1.0 - tp_hits as f64 / true_positives.len() as f64);
443        ranked.push((profile, tp_hits, adjusted_score));
444    }
445    ranked.sort_by(|(a, a_hits, a_score), (b, b_hits, b_score)| {
446        a_hits
447            .cmp(b_hits)
448            .then_with(|| {
449                b_score
450                    .partial_cmp(a_score)
451                    .unwrap_or(std::cmp::Ordering::Equal)
452            })
453            .then_with(|| a.field().cmp(b.field()))
454    });
455
456    let mut entries = Vec::new();
457    let mut blocking_tp: Vec<usize> = (0..true_positives.len()).collect();
458    let mut selected = BTreeSet::new();
459    while entries.len() < config.max_fields && selected.len() < ranked.len() {
460        let mut best: Option<(usize, Vec<usize>)> = None;
461        for (index, (profile, _, _)) in ranked.iter().enumerate() {
462            if selected.contains(&index) {
463                continue;
464            }
465            let mut candidate_entries = entries.clone();
466            candidate_entries.push((
467                profile.field().to_string(),
468                profile.form.clone().expect("ranked form"),
469            ));
470            let selection = Selection {
471                name: name.to_string(),
472                entries: candidate_entries,
473                fp_indexes: fp_indexes.to_vec(),
474            };
475            let yaml = emit_filter_yaml(
476                rule,
477                rule.id.as_deref().unwrap_or(&rule.title),
478                std::slice::from_ref(&selection),
479                fp_indexes.len(),
480                true_positives.len(),
481                config,
482            );
483            let tp_after = firing_indexes_with_filter(rule, &yaml, true_positives)?;
484            let candidate_blocking = missing_indexes(true_positives.len(), &tp_after);
485            if best
486                .as_ref()
487                .is_none_or(|(_, current)| candidate_blocking.len() < current.len())
488            {
489                best = Some((index, candidate_blocking));
490            }
491        }
492        let Some((index, candidate_blocking)) = best else {
493            break;
494        };
495        selected.insert(index);
496        let profile = &ranked[index].0;
497        entries.push((
498            profile.field().to_string(),
499            profile.form.clone().expect("ranked form"),
500        ));
501        blocking_tp = candidate_blocking;
502        if blocking_tp.is_empty() && entries.len() >= config.min_fields {
503            break;
504        }
505    }
506    if entries.len() < config.min_fields {
507        blocking_tp = (0..true_positives.len()).collect();
508    }
509
510    let fields = ranked
511        .into_iter()
512        .map(|(profile, tp_hits, adjusted_score)| TuneFieldReport {
513            field: profile.field().to_string(),
514            score: adjusted_score,
515            stability: profile.stability,
516            modifier: profile
517                .form
518                .as_ref()
519                .map_or_else(String::new, |form| form.modifier().to_string()),
520            values: profile
521                .form
522                .as_ref()
523                .map_or_else(Vec::new, ValueForm::display_values),
524            true_positive_hits: tp_hits,
525            disposition: if tp_hits > 0 {
526                TuneFieldDisposition::MatchesTruePositive
527            } else {
528                TuneFieldDisposition::LowerRank
529            },
530        })
531        .collect();
532
533    Ok(GroupProposal {
534        selection: Selection {
535            name: name.to_string(),
536            entries,
537            fp_indexes: fp_indexes.to_vec(),
538        },
539        fields,
540        blocking_tp,
541    })
542}
543
544fn propose_clusters(
545    rule: &SigmaRule,
546    false_positives: &[Value],
547    true_positives: &[Value],
548    config: &TuneConfig,
549    whole: GroupProposal,
550) -> Result<(Vec<GroupProposal>, Vec<usize>), TuneError> {
551    let partitions = scalar_partitions(false_positives);
552    let mut best_full: Option<Vec<GroupProposal>> = None;
553    let mut best_partial: Option<(Vec<GroupProposal>, Vec<usize>)> = None;
554
555    for groups in partitions.values() {
556        if groups.len() < 2 || groups.len() > config.max_clusters {
557            continue;
558        }
559        if groups
560            .values()
561            .any(|indexes| indexes.len() < config.min_cluster_support)
562        {
563            continue;
564        }
565
566        let mut proposals = Vec::new();
567        let mut uncovered = Vec::new();
568        for indexes in groups.values() {
569            match propose_group(
570                rule,
571                false_positives,
572                indexes,
573                true_positives,
574                config,
575                "selection",
576            ) {
577                Ok(proposal) if proposal.blocking_tp.is_empty() => proposals.push(proposal),
578                Ok(_) | Err(TuneError::NoCandidateFields(_)) => {
579                    uncovered.extend(indexes.iter().copied());
580                }
581                Err(error) => return Err(error),
582            }
583        }
584        uncovered.sort_unstable();
585        if uncovered.is_empty() {
586            if best_full
587                .as_ref()
588                .is_none_or(|best| proposals.len() < best.len())
589            {
590                best_full = Some(proposals);
591            }
592            continue;
593        }
594        if config.allow_partial && !proposals.is_empty() {
595            let covered = false_positives.len() - uncovered.len();
596            let replace = best_partial.as_ref().is_none_or(|(_, best_uncovered)| {
597                covered > false_positives.len() - best_uncovered.len()
598            });
599            if replace {
600                best_partial = Some((proposals, uncovered));
601            }
602        }
603    }
604
605    if let Some(proposals) = best_full {
606        return Ok((proposals, Vec::new()));
607    }
608    if let Some(partial) = best_partial {
609        return Ok(partial);
610    }
611
612    Err(TuneError::NoCleanSeparator {
613        closest: whole
614            .fields
615            .iter()
616            .take(config.max_fields)
617            .map(|field| field.field.clone())
618            .collect(),
619        blocking_tp: whole.blocking_tp,
620        uncovered_fp: if config.allow_partial {
621            (0..false_positives.len()).collect()
622        } else {
623            Vec::new()
624        },
625    })
626}
627
628fn scalar_partitions(events: &[Value]) -> BTreeMap<String, BTreeMap<String, Vec<usize>>> {
629    let mut fields: BTreeMap<String, BTreeMap<String, Vec<usize>>> = BTreeMap::new();
630    for (index, value) in events.iter().enumerate() {
631        let event = JsonEvent::borrow(value);
632        for field in crate::event::Event::field_keys(&event) {
633            let field = field.into_owned();
634            let Some(value) = crate::event::Event::get_field(&event, &field) else {
635                continue;
636            };
637            let key = match value {
638                crate::event::EventValue::Str(value) => value.to_string(),
639                crate::event::EventValue::Int(value) => value.to_string(),
640                crate::event::EventValue::Float(value) => value.to_string(),
641                crate::event::EventValue::Bool(value) => value.to_string(),
642                crate::event::EventValue::Null
643                | crate::event::EventValue::Array(_)
644                | crate::event::EventValue::Map(_) => continue,
645            };
646            fields
647                .entry(field)
648                .or_default()
649                .entry(key)
650                .or_default()
651                .push(index);
652        }
653    }
654    fields.retain(|_, groups| groups.values().map(Vec::len).sum::<usize>() == events.len());
655    fields
656}
657
658fn emit_filter_yaml(
659    rule: &SigmaRule,
660    target: &str,
661    selections: &[Selection],
662    fp_covered: usize,
663    tp_total: usize,
664    config: &TuneConfig,
665) -> String {
666    let mut out = String::new();
667    out.push_str(&format!(
668        "title: {}\n",
669        yaml_title_str(&format!("Tuning filter for {}", rule.title))
670    ));
671    if let Some(id) = &config.filter_id {
672        out.push_str(&format!("id: {}\n", yaml_str(id)));
673    }
674    out.push_str(&format!(
675        "description: {}\n",
676        yaml_str(&format!(
677            "Suppresses {fp_covered} observed false-positive exemplars; verified against {tp_total} true-positive exemplars."
678        ))
679    ));
680    out.push_str(&format!("author: {}\n", yaml_str(&config.author)));
681    emit_logsource(&mut out, &rule.logsource);
682    out.push_str("filter:\n");
683    out.push_str("    rules:\n");
684    out.push_str(&format!("        - {}\n", yaml_str(target)));
685    for selection in selections {
686        out.push_str(&format!("    {}:\n", selection.name));
687        for (field, form) in &selection.entries {
688            emit_form(&mut out, field, form, "        ");
689        }
690    }
691    if selections.len() == 1 {
692        out.push_str("    condition: not selection\n");
693    } else {
694        let names = selections
695            .iter()
696            .map(|selection| selection.name.as_str())
697            .collect::<Vec<_>>()
698            .join(" or ");
699        out.push_str(&format!("    condition: not ({names})\n"));
700    }
701    out
702}
703
704fn emit_logsource(out: &mut String, logsource: &LogSource) {
705    out.push_str("logsource:\n");
706    for (key, value) in [
707        ("category", logsource.category.as_deref()),
708        ("product", logsource.product.as_deref()),
709        ("service", logsource.service.as_deref()),
710        ("definition", logsource.definition.as_deref()),
711    ] {
712        if let Some(value) = value {
713            out.push_str(&format!("    {key}: {}\n", yaml_str(value)));
714        }
715    }
716    let mut custom: Vec<_> = logsource.custom.iter().collect();
717    custom.sort_by_key(|(key, _)| *key);
718    for (key, value) in custom {
719        out.push_str(&format!("    {}: {}\n", yaml_str(key), yaml_str(value)));
720    }
721}
722
723fn validate_filter_yaml(yaml: &str) -> Result<(), TuneError> {
724    let collection =
725        rsigma_parser::parse_sigma_yaml(yaml).map_err(|error| TuneError::Internal {
726            stage: "parse".to_string(),
727            message: error.to_string(),
728        })?;
729    if collection.filters.len() != 1 || collection.has_errors() {
730        return Err(TuneError::Internal {
731            stage: "parse".to_string(),
732            message: format!(
733                "expected one filter and no document errors, got {} filters and {:?}",
734                collection.filters.len(),
735                collection.errors
736            ),
737        });
738    }
739    let errors: Vec<_> = rsigma_parser::lint_yaml_str(yaml)
740        .into_iter()
741        .filter(|warning| warning.severity == Severity::Error)
742        .map(|warning| warning.to_string())
743        .collect();
744    if errors.is_empty() {
745        Ok(())
746    } else {
747        Err(TuneError::Internal {
748            stage: "lint".to_string(),
749            message: errors.join("; "),
750        })
751    }
752}
753
754fn firing_indexes(rule: &SigmaRule, events: &[Value]) -> Result<Vec<usize>, TuneError> {
755    let mut collection = SigmaCollection::new();
756    collection.rules.push(rule.clone());
757    evaluate_collection(&collection, events)
758}
759
760fn firing_indexes_with_filter(
761    rule: &SigmaRule,
762    filter_yaml: &str,
763    events: &[Value],
764) -> Result<Vec<usize>, TuneError> {
765    let parsed =
766        rsigma_parser::parse_sigma_yaml(filter_yaml).map_err(|error| TuneError::Internal {
767            stage: "parse".to_string(),
768            message: error.to_string(),
769        })?;
770    let mut collection = SigmaCollection::new();
771    collection.rules.push(rule.clone());
772    collection.filters.extend(parsed.filters);
773    evaluate_collection(&collection, events)
774}
775
776fn evaluate_collection(
777    collection: &SigmaCollection,
778    events: &[Value],
779) -> Result<Vec<usize>, TuneError> {
780    let mut engine = Engine::new();
781    engine
782        .add_collection(collection)
783        .map_err(|error| TuneError::Internal {
784            stage: "compile".to_string(),
785            message: error.to_string(),
786        })?;
787    Ok(events
788        .iter()
789        .enumerate()
790        .filter_map(|(index, value)| {
791            let event = JsonEvent::borrow(value);
792            (!engine.evaluate(&event).is_empty()).then_some(index)
793        })
794        .collect())
795}
796
797fn missing_indexes(total: usize, present: &[usize]) -> Vec<usize> {
798    let present: BTreeSet<usize> = present.iter().copied().collect();
799    (0..total)
800        .filter(|index| !present.contains(index))
801        .collect()
802}
803
804fn merge_field_reports(reports: impl Iterator<Item = TuneFieldReport>) -> Vec<TuneFieldReport> {
805    let mut by_field: BTreeMap<String, TuneFieldReport> = BTreeMap::new();
806    for report in reports {
807        by_field
808            .entry(report.field.clone())
809            .and_modify(|existing| {
810                if report.score > existing.score {
811                    *existing = report.clone();
812                }
813            })
814            .or_insert(report);
815    }
816    let mut reports: Vec<_> = by_field.into_values().collect();
817    reports.sort_by(|a, b| {
818        b.score
819            .partial_cmp(&a.score)
820            .unwrap_or(std::cmp::Ordering::Equal)
821            .then_with(|| a.field.cmp(&b.field))
822    });
823    reports
824}
825
826#[cfg(test)]
827mod tests {
828    use serde_json::json;
829
830    use super::*;
831
832    fn rule() -> SigmaRule {
833        rsigma_parser::parse_sigma_yaml(
834            r#"
835title: Suspicious Backup Tool
836id: 929a690e-bef0-4204-a928-ef5e620d6fcc
837logsource:
838    category: process_creation
839    product: windows
840detection:
841    selection:
842        Image|endswith: '\backup.exe'
843    condition: selection
844level: medium
845"#,
846        )
847        .unwrap()
848        .rules
849        .remove(0)
850    }
851
852    fn config() -> TuneConfig {
853        TuneConfig {
854            filter_id: Some("3f7b1c2e-9a44-4d1e-8f61-2b0c5d9e7a10".to_string()),
855            min_cluster_support: 1,
856            ..TuneConfig::default()
857        }
858    }
859
860    #[test]
861    fn emits_verified_filter_with_clean_polarity() {
862        let fps = vec![
863            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
864            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
865        ];
866        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
867
868        let report = tune_rule(&rule(), &fps, &tps, &config()).unwrap();
869
870        assert_eq!(report.verification.false_positives_after, 0);
871        assert_eq!(report.verification.true_positives_after, 1);
872        assert!(report.filter_yaml.contains("condition: not selection"));
873        assert!(report.filter_yaml.contains("category: process_creation"));
874        assert!(report.filter_yaml.contains("Image:"));
875        assert!(report.filter_yaml.contains("User: svc_backup"));
876        assert_eq!(report.selections[0].fields.len(), 2);
877        assert!(!report.filter_yaml.contains("status:"));
878        assert!(
879            rsigma_parser::lint_yaml_str(&report.filter_yaml)
880                .iter()
881                .all(|warning| warning.severity != Severity::Error)
882        );
883    }
884
885    #[test]
886    fn rejects_nonfiring_labels_before_profiling() {
887        let fps = vec![json!({"Image": r"C:\Windows\notepad.exe", "User": "svc"})];
888        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
889
890        let error = tune_rule(&rule(), &fps, &tps, &config()).unwrap_err();
891        assert!(matches!(
892            error,
893            TuneError::NonFiringExemplars { fp, tp }
894                if fp == vec![0] && tp.is_empty()
895        ));
896    }
897
898    #[test]
899    fn refuses_single_event_memorization_by_default() {
900        let fps = vec![json!({
901            "Image": r"C:\Program Files\Veeam\backup.exe",
902            "User": "svc_backup"
903        })];
904        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
905        let config = TuneConfig {
906            filter_id: Some("3f7b1c2e-9a44-4d1e-8f61-2b0c5d9e7a10".to_string()),
907            ..TuneConfig::default()
908        };
909
910        let error = tune_rule(&rule(), &fps, &tps, &config).unwrap_err();
911        assert!(matches!(
912            error,
913            TuneError::NoCleanSeparator { uncovered_fp, .. } if uncovered_fp == vec![0]
914        ));
915    }
916
917    #[test]
918    fn rejects_zero_token_length() {
919        let fps = vec![
920            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
921            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
922        ];
923        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
924        let config = TuneConfig {
925            min_token_len: 0,
926            ..config()
927        };
928
929        let error = tune_rule(&rule(), &fps, &tps, &config).unwrap_err();
930        assert!(matches!(
931            error,
932            TuneError::InvalidConfig(message) if message.contains("min_token_len")
933        ));
934    }
935
936    #[test]
937    fn rejects_minimum_fields_above_maximum() {
938        let fps = vec![
939            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
940            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
941        ];
942        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
943        let config = TuneConfig {
944            min_fields: 3,
945            max_fields: 2,
946            ..config()
947        };
948
949        let error = tune_rule(&rule(), &fps, &tps, &config).unwrap_err();
950        assert!(matches!(
951            error,
952            TuneError::InvalidConfig(message) if message.contains("cannot exceed")
953        ));
954    }
955
956    #[test]
957    fn emits_multiple_selections_for_disjoint_fp_clusters() {
958        let fps = vec![
959            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
960            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
961            json!({"Image": r"D:\Tools\Acronis\backup.exe", "User": "svc_acronis"}),
962            json!({"Image": r"D:\Tools\Acronis\backup.exe", "User": "svc_acronis"}),
963        ];
964        let tps = vec![
965            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_acronis"}),
966            json!({"Image": r"D:\Tools\Acronis\backup.exe", "User": "svc_backup"}),
967        ];
968
969        let report = tune_rule(&rule(), &fps, &tps, &config()).unwrap();
970
971        assert_eq!(report.selections.len(), 2);
972        assert!(
973            report
974                .filter_yaml
975                .contains("condition: not (selection or selection_2)")
976        );
977        assert_eq!(report.verification.false_positives_after, 0);
978        assert_eq!(report.verification.true_positives_after, 2);
979    }
980
981    #[test]
982    fn one_of_supports_six_benign_values_without_clustering() {
983        let users = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot"];
984        let fps: Vec<Value> = users
985            .iter()
986            .map(|user| {
987                json!({
988                    "Image": r"C:\Program Files\Veeam\backup.exe",
989                    "User": user
990                })
991            })
992            .collect();
993        let tps = vec![json!({
994            "Image": r"C:\Program Files\Veeam\backup.exe",
995            "User": "attacker"
996        })];
997
998        let report = tune_rule(&rule(), &fps, &tps, &config()).unwrap();
999
1000        assert_eq!(report.selections.len(), 1);
1001        assert!(report.filter_yaml.contains("foxtrot"));
1002    }
1003
1004    #[test]
1005    fn partial_mode_emits_only_clean_supported_clusters() {
1006        let fps = vec![
1007            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_good"}),
1008            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_good"}),
1009            json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"}),
1010            json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"}),
1011        ];
1012        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
1013        let partial = TuneConfig {
1014            allow_partial: true,
1015            min_cluster_support: 2,
1016            ..config()
1017        };
1018
1019        let report = tune_rule(&rule(), &fps, &tps, &partial).unwrap();
1020
1021        assert_eq!(report.verification.false_positives_after, 2);
1022        assert_eq!(report.verification.true_positives_after, 1);
1023        assert_eq!(report.false_positive_coverage, 0.5);
1024        assert!(
1025            report
1026                .warnings
1027                .iter()
1028                .any(|warning| warning.contains("[2, 3]"))
1029        );
1030    }
1031
1032    #[test]
1033    fn default_mode_refuses_an_inseparable_cluster() {
1034        let fps = vec![
1035            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_good"}),
1036            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_good"}),
1037            json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"}),
1038            json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"}),
1039        ];
1040        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
1041
1042        let error = tune_rule(&rule(), &fps, &tps, &config()).unwrap_err();
1043        assert!(matches!(error, TuneError::NoCleanSeparator { .. }));
1044    }
1045
1046    #[test]
1047    fn title_fallback_is_explicit_and_deterministic() {
1048        let mut target = rule();
1049        target.id = None;
1050        let fps = vec![
1051            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
1052            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
1053        ];
1054        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
1055
1056        let first = tune_rule(&target, &fps, &tps, &config()).unwrap();
1057        let second = tune_rule(&target, &fps, &tps, &config()).unwrap();
1058
1059        assert_eq!(first.filter_yaml, second.filter_yaml);
1060        assert!(first.filter_yaml.contains("- 'Suspicious Backup Tool'"));
1061        assert!(
1062            first
1063                .warnings
1064                .iter()
1065                .any(|warning| warning.contains("exact title"))
1066        );
1067    }
1068
1069    #[test]
1070    fn name_is_targeted_when_rule_has_no_id() {
1071        let mut target = rule();
1072        target.id = None;
1073        target.name = Some("suspicious_backup_tool".to_string());
1074        let fps = vec![
1075            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
1076            json!({"Image": r"C:\Program Files\Veeam\backup.exe", "User": "svc_backup"}),
1077        ];
1078        let tps = vec![json!({"Image": r"C:\Temp\backup.exe", "User": "attacker"})];
1079
1080        let report = tune_rule(&target, &fps, &tps, &config()).unwrap();
1081
1082        assert!(report.filter_yaml.contains("- suspicious_backup_tool"));
1083        assert!(!report.filter_yaml.contains("Suspicious Backup Tool'"));
1084        assert!(report.warnings.is_empty());
1085    }
1086}