1use 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#[derive(Debug, Clone)]
23pub struct TuneConfig {
24 pub max_fields: usize,
26 pub min_fields: usize,
28 pub max_value_cardinality: usize,
30 pub min_token_len: usize,
32 pub min_cluster_support: usize,
34 pub max_clusters: usize,
36 pub allow_partial: bool,
38 pub filter_id: Option<String>,
40 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#[derive(Debug, thiserror::Error)]
62pub enum TuneError {
63 #[error("invalid tuning config: {0}")]
65 InvalidConfig(String),
66 #[error("no false-positive events to tune")]
68 NoFalsePositives,
69 #[error("no true-positive events to protect")]
71 NoTruePositives,
72 #[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 fp: Vec<usize>,
80 tp: Vec<usize>,
82 },
83 #[error("no candidate fields survived profiling across {0} false positives")]
85 NoCandidateFields(usize),
86 #[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 closest: Vec<String>,
94 blocking_tp: Vec<usize>,
96 uncovered_fp: Vec<usize>,
98 },
99 #[error("internal error: emitted filter failed to {stage}: {message}")]
101 Internal {
102 stage: String,
104 message: String,
106 },
107}
108
109#[derive(Debug, Clone, Serialize)]
111#[serde(rename_all = "snake_case")]
112pub enum TuneFieldDisposition {
113 Selected,
115 MatchesTruePositive,
117 LowerRank,
119 Volatile,
121}
122
123#[derive(Debug, Clone, Serialize)]
125pub struct TuneFieldReport {
126 pub field: String,
128 pub score: f64,
130 pub stability: Stability,
132 pub modifier: String,
134 pub values: Vec<String>,
136 pub true_positive_hits: usize,
138 pub disposition: TuneFieldDisposition,
140}
141
142#[derive(Debug, Clone, Serialize)]
144pub struct TuneSelectionReport {
145 pub name: String,
147 pub fields: Vec<String>,
149 pub false_positive_indexes: Vec<usize>,
151}
152
153#[derive(Debug, Clone, Serialize)]
155pub struct TuneVerification {
156 pub false_positives_before: usize,
158 pub false_positives_after: usize,
160 pub true_positives_before: usize,
162 pub true_positives_after: usize,
164}
165
166#[derive(Debug, Clone, Serialize)]
168pub struct TuneExpectationDiff {
169 pub existing: Vec<String>,
171 pub false_positives_before: usize,
173 pub false_positives_after: usize,
175 pub true_positives_before: usize,
177 pub true_positives_after: usize,
179 pub fragment: String,
181}
182
183#[derive(Debug, Clone, Serialize)]
185pub struct TuneReport {
186 pub filter_yaml: String,
188 pub fields: Vec<TuneFieldReport>,
190 pub selections: Vec<TuneSelectionReport>,
192 pub verification: TuneVerification,
194 pub false_positive_coverage: f64,
196 pub warnings: Vec<String>,
198 #[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
217pub 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}