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
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}