1use std::collections::{BTreeMap, BTreeSet};
30use std::fmt;
31
32use serde::Serialize;
33
34use crate::engine::Engine;
35use crate::event::Event;
36use crate::schema::SchemaClassifier;
37
38pub mod correlation;
39pub(crate) mod draft_core;
40use draft_core::*;
41
42#[derive(Debug, Clone)]
49pub struct DraftConfig {
50 pub max_fields: usize,
52 pub min_fields: usize,
55 pub min_prevalence: f64,
58 pub max_value_cardinality: usize,
61 pub min_token_len: usize,
65 pub max_baseline_token_prevalence: f64,
68 pub include_fields: Vec<String>,
71 pub exclude_fields: Vec<String>,
73 pub title: Option<String>,
75 pub rule_id: Option<String>,
78 pub date: Option<String>,
81 pub logsource_category: Option<String>,
83 pub logsource_product: Option<String>,
84 pub logsource_service: Option<String>,
85 pub evaluate_baseline: bool,
88}
89
90impl Default for DraftConfig {
91 fn default() -> Self {
92 Self {
93 max_fields: 4,
94 min_fields: 2,
95 min_prevalence: 1.0,
96 max_value_cardinality: 4,
97 min_token_len: 4,
98 max_baseline_token_prevalence: 0.05,
99 include_fields: Vec::new(),
100 exclude_fields: Vec::new(),
101 title: None,
102 rule_id: None,
103 date: None,
104 logsource_category: None,
105 logsource_product: None,
106 logsource_service: None,
107 evaluate_baseline: true,
108 }
109 }
110}
111
112#[derive(Debug, thiserror::Error)]
118pub enum DraftError {
119 #[error("no exemplar events to draft from")]
121 NoExemplars,
122 #[error(
125 "no candidate fields: every field was volatile (timestamps, ids, unique values), \
126 excluded, or below the prevalence threshold ({0} exemplars profiled)"
127 )]
128 NoCandidateFields(usize),
129 #[error(
132 "draft cannot match all exemplars: {matched}/{total} match at the {floor}-field floor; \
133 exemplars may be too heterogeneous for one rule (failing exemplar indexes: {failing:?})"
134 )]
135 CannotMatchExemplars {
136 matched: usize,
137 total: usize,
138 floor: usize,
139 failing: Vec<usize>,
140 },
141 #[error(
145 "forced field(s) {fields:?} are absent from exemplar(s) {failing:?}; \
146 remove the --include-field or drop those exemplars"
147 )]
148 ForcedFieldMismatch {
149 fields: Vec<String>,
150 failing: Vec<usize>,
151 },
152 #[error("internal error: emitted draft failed to {stage}: {message}")]
154 Internal { stage: String, message: String },
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
163#[serde(rename_all = "snake_case")]
164pub enum Stability {
165 Constant,
167 Enumerable,
169 Patterned,
171 Volatile,
173}
174
175impl fmt::Display for Stability {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 let s = match self {
178 Stability::Constant => "constant",
179 Stability::Enumerable => "enumerable",
180 Stability::Patterned => "patterned",
181 Stability::Volatile => "volatile",
182 };
183 f.write_str(s)
184 }
185}
186
187#[derive(Debug, Clone, Serialize)]
189pub struct DraftFieldReport {
190 pub field: String,
192 pub score: f64,
194 pub stability: Stability,
196 pub modifier: String,
198 pub values: Vec<String>,
200 pub baseline_prevalence: Option<f64>,
203 pub selected: bool,
205}
206
207#[derive(Debug, Clone)]
209pub struct DraftReport {
210 pub rule_yaml: String,
212 pub fields: Vec<DraftFieldReport>,
214 pub exemplar_total: usize,
216 pub exemplar_matched: usize,
219 pub baseline_total: usize,
221 pub baseline_hits: Option<usize>,
224 pub baseline_hit_rate: Option<f64>,
226 pub warnings: Vec<String>,
228}
229
230#[derive(Debug, Clone)]
231pub(crate) struct DraftCandidate {
232 pub(crate) profiles: Vec<DraftFieldProfile>,
233 pub(crate) selected: Vec<usize>,
234 logsource: DraftLogsource,
235 pub(crate) warnings: Vec<String>,
236}
237
238impl DraftCandidate {
239 pub(crate) fn build<E: Event>(
240 exemplars: &[E],
241 baseline: &[E],
242 config: &DraftConfig,
243 ) -> Result<Self, DraftError> {
244 if exemplars.is_empty() {
245 return Err(DraftError::NoExemplars);
246 }
247 let mut warnings = Vec::new();
248 let mut profiles = profile_fields(exemplars, config, &mut warnings);
249 if profiles.is_empty() {
250 return Err(DraftError::NoCandidateFields(exemplars.len()));
251 }
252 for profile in &mut profiles {
253 infer_form(profile, config);
254 }
255 if !baseline.is_empty() {
256 for profile in &mut profiles {
257 apply_baseline(profile, baseline, config);
258 }
259 }
260 let has_baseline = !baseline.is_empty();
261 for profile in &mut profiles {
262 profile.score = score_field(profile, has_baseline);
263 }
264 profiles.sort_by(|a, b| {
265 b.forced
266 .cmp(&a.forced)
267 .then_with(|| {
268 b.score
269 .partial_cmp(&a.score)
270 .unwrap_or(std::cmp::Ordering::Equal)
271 })
272 .then_with(|| a.field().cmp(b.field()))
273 });
274 let usable: Vec<usize> = profiles
275 .iter()
276 .enumerate()
277 .filter(|(_, profile)| {
278 profile.form.is_some() && profile.stability != Stability::Volatile
279 })
280 .map(|(index, _)| index)
281 .collect();
282 if usable.is_empty() {
283 return Err(DraftError::NoCandidateFields(exemplars.len()));
284 }
285 let selected: Vec<usize> = usable.iter().copied().take(config.max_fields).collect();
286 if selected.len() < config.min_fields {
287 warnings.push(format!(
288 "only {} usable field(s) found (floor is {}); the draft may be broad",
289 selected.len(),
290 config.min_fields
291 ));
292 }
293 let logsource = infer_logsource(exemplars, config, &mut warnings);
294 Ok(Self {
295 profiles,
296 selected,
297 logsource,
298 warnings,
299 })
300 }
301
302 pub(crate) fn emit<E: Event>(&self, exemplars: &[E], config: &DraftConfig) -> String {
303 self.emit_named(exemplars, config, None)
304 }
305
306 pub(crate) fn emit_named<E: Event>(
307 &self,
308 exemplars: &[E],
309 config: &DraftConfig,
310 name: Option<&str>,
311 ) -> String {
312 let detection = build_detection(&self.profiles, &self.selected, exemplars, config);
313 emit_rule_yaml(
314 &self.profiles,
315 &self.selected,
316 &detection,
317 &self.logsource,
318 config,
319 name,
320 )
321 }
322
323 pub(crate) fn drop_lowest_eligible(&mut self, failing: Option<&[usize]>) -> Option<usize> {
324 let absent_in_failing = |index: usize| {
325 failing.is_some_and(|indexes| {
326 indexes
327 .iter()
328 .any(|&event| self.profiles[index].values[event].is_none())
329 })
330 };
331 let position = self
332 .selected
333 .iter()
334 .rposition(|&index| !self.profiles[index].forced && absent_in_failing(index))
335 .or_else(|| {
336 self.selected
337 .iter()
338 .rposition(|&index| !self.profiles[index].forced)
339 })?;
340 Some(self.selected.remove(position))
341 }
342}
343
344pub fn draft_rule<E: Event>(
354 exemplars: &[E],
355 baseline: &[E],
356 config: &DraftConfig,
357) -> Result<DraftReport, DraftError> {
358 let mut candidate = DraftCandidate::build(exemplars, baseline, config)?;
359 let floor = config.min_fields.min(candidate.selected.len()).max(1);
360
361 let (yaml, matched, failing) = loop {
363 let yaml = candidate.emit(exemplars, config);
364 let engine = compile_draft(&yaml)?;
365 let failing: Vec<usize> = exemplars
366 .iter()
367 .enumerate()
368 .filter(|(_, e)| engine.evaluate(e).is_empty())
369 .map(|(i, _)| i)
370 .collect();
371 if failing.is_empty() {
372 break (yaml, exemplars.len(), failing);
373 }
374
375 let absent_in_failing = |i: usize| {
379 failing
380 .iter()
381 .any(|&idx| candidate.profiles[i].values[idx].is_none())
382 };
383
384 let forced_culprits: Vec<String> = candidate
388 .selected
389 .iter()
390 .filter(|&&i| candidate.profiles[i].forced && absent_in_failing(i))
391 .map(|&i| candidate.profiles[i].field().to_string())
392 .collect();
393 if !forced_culprits.is_empty() {
394 return Err(DraftError::ForcedFieldMismatch {
395 fields: forced_culprits,
396 failing,
397 });
398 }
399
400 if candidate.selected.len() <= floor {
401 return Err(DraftError::CannotMatchExemplars {
402 matched: exemplars.len() - failing.len(),
403 total: exemplars.len(),
404 floor,
405 failing,
406 });
407 }
408
409 let Some(dropped) = candidate.drop_lowest_eligible(Some(&failing)) else {
412 return Err(DraftError::CannotMatchExemplars {
413 matched: exemplars.len() - failing.len(),
414 total: exemplars.len(),
415 floor,
416 failing,
417 });
418 };
419 candidate.warnings.push(format!(
420 "relaxed: dropped field '{}' because the draft did not match every exemplar with it",
421 candidate.profiles[dropped].field()
422 ));
423 };
424 debug_assert!(failing.is_empty());
425
426 let (baseline_hits, baseline_hit_rate) = if !baseline.is_empty() && config.evaluate_baseline {
428 let engine = compile_draft(&yaml)?;
429 let hits = baseline
430 .iter()
431 .filter(|e| !engine.evaluate(e).is_empty())
432 .count();
433 let rate = hits as f64 / baseline.len() as f64;
434 if hits > 0 {
435 candidate.warnings.push(format!(
436 "draft matches {hits}/{} baseline events ({:.1}%); consider a tighter field",
437 baseline.len(),
438 rate * 100.0
439 ));
440 }
441 (Some(hits), Some(rate))
442 } else {
443 (None, None)
444 };
445
446 for w in rsigma_parser::lint_yaml_str(&yaml) {
448 candidate
449 .warnings
450 .push(format!("lint {}: {}", w.rule, w.message));
451 }
452
453 let selected_set: BTreeSet<usize> = candidate.selected.iter().copied().collect();
455 let fields = candidate
456 .profiles
457 .iter()
458 .enumerate()
459 .map(|(i, p)| DraftFieldReport {
460 field: p.field().to_string(),
461 score: p.score,
462 stability: p.stability,
463 modifier: p
464 .form
465 .as_ref()
466 .map(|f| f.modifier().trim_start_matches('|').to_string())
467 .unwrap_or_default(),
468 values: p
469 .form
470 .as_ref()
471 .map(|f| f.display_values())
472 .unwrap_or_else(|| {
473 p.distinct()
474 .into_iter()
475 .take(4)
476 .map(|v| v.as_display())
477 .collect()
478 }),
479 baseline_prevalence: p.baseline_prevalence,
480 selected: selected_set.contains(&i),
481 })
482 .collect();
483
484 Ok(DraftReport {
485 rule_yaml: yaml,
486 fields,
487 exemplar_total: exemplars.len(),
488 exemplar_matched: matched,
489 baseline_total: baseline.len(),
490 baseline_hits,
491 baseline_hit_rate,
492 warnings: candidate.warnings,
493 })
494}
495
496struct Selection {
502 name: String,
503 entries: Vec<(String, ValueForm)>,
504}
505
506struct DetectionBlock {
507 selections: Vec<Selection>,
508 condition: String,
509}
510
511fn build_detection<E: Event>(
512 profiles: &[DraftFieldProfile],
513 selected: &[usize],
514 exemplars: &[E],
515 config: &DraftConfig,
516) -> DetectionBlock {
517 if let Some(block) = try_group_split(profiles, selected, exemplars, config) {
521 return block;
522 }
523 let entries: Vec<(String, ValueForm)> = selected
524 .iter()
525 .filter_map(|&i| {
526 profiles[i]
527 .form
528 .clone()
529 .map(|f| (profiles[i].field().to_string(), f))
530 })
531 .collect();
532 DetectionBlock {
533 selections: vec![Selection {
534 name: "selection".to_string(),
535 entries,
536 }],
537 condition: "selection".to_string(),
538 }
539}
540
541const MAX_VALUE_GROUPS: usize = 3;
542
543fn try_group_split<E: Event>(
544 profiles: &[DraftFieldProfile],
545 selected: &[usize],
546 exemplars: &[E],
547 config: &DraftConfig,
548) -> Option<DetectionBlock> {
549 if selected.len() < 2 || exemplars.len() < 2 {
550 return None;
551 }
552 let (splitter_pos, splitter) = selected.iter().enumerate().find_map(|(pos, &i)| {
555 let p = &profiles[i];
556 let d = p.distinct();
557 let all_str = d.iter().all(|v| matches!(v, DraftValue::Str(_)));
558 if all_str && d.len() >= 2 && d.len() <= MAX_VALUE_GROUPS {
559 Some((pos, i))
560 } else {
561 None
562 }
563 })?;
564
565 let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
567 for (idx, v) in profiles[splitter].values.iter().enumerate() {
568 let key = v.as_ref()?.as_display();
569 match groups.iter_mut().find(|(k, _)| *k == key) {
570 Some((_, members)) => members.push(idx),
571 None => groups.push((key, vec![idx])),
572 }
573 }
574 if groups.len() < 2 {
575 return None;
576 }
577 if groups.iter().any(|(_, members)| members.len() < 2) {
580 return None;
581 }
582
583 let improves = selected.iter().enumerate().any(|(pos, &i)| {
586 if pos == splitter_pos {
587 return false;
588 }
589 let p = &profiles[i];
590 if p.distinct().len() < 2 {
591 return false;
592 }
593 groups.iter().all(|(_, members)| {
594 let mut vals = members.iter().filter_map(|&m| p.values[m].as_ref());
595 let first = vals.next();
596 first.is_some() && vals.all(|v| Some(v) == first)
597 })
598 });
599 if !improves {
600 return None;
601 }
602
603 let mut used_names: BTreeMap<String, u32> = BTreeMap::new();
605 let selections: Vec<Selection> = groups
606 .iter()
607 .map(|(key, members)| {
608 let entries: Vec<(String, ValueForm)> = selected
609 .iter()
610 .filter_map(|&i| {
611 let p = &profiles[i];
612 let mut distinct: Vec<DraftValue> = Vec::new();
613 for &m in members {
614 if let Some(v) = &p.values[m]
615 && !distinct.contains(v)
616 {
617 distinct.push(v.clone());
618 }
619 }
620 derive_form(&distinct, config).map(|f| (p.field().to_string(), f))
621 })
622 .collect();
623 let base = selection_slug(key);
624 let n = used_names.entry(base.clone()).or_insert(0);
625 *n += 1;
626 let name = if *n == 1 {
627 format!("selection_{base}")
628 } else {
629 format!("selection_{base}_{n}")
630 };
631 Selection { name, entries }
632 })
633 .collect();
634
635 Some(DetectionBlock {
636 selections,
637 condition: "1 of selection_*".to_string(),
638 })
639}
640
641fn selection_slug(value: &str) -> String {
645 let last_segment = value.rsplit(['\\', '/']).next().unwrap_or(value);
646 let stem = last_segment
647 .split_once('.')
648 .map(|(stem, _)| stem)
649 .unwrap_or(last_segment);
650 let first_token = stem
651 .split(|c: char| !c.is_ascii_alphanumeric())
652 .find(|t| !t.is_empty())
653 .unwrap_or("");
654 let out: String = first_token.to_ascii_lowercase();
655 if out.is_empty() {
656 "group".to_string()
657 } else {
658 out
659 }
660}
661
662#[derive(Debug, Clone, Default)]
667struct DraftLogsource {
668 category: Option<String>,
669 product: Option<String>,
670 service: Option<String>,
671 inferred: bool,
672}
673
674fn sysmon_category(event_id: i64) -> Option<&'static str> {
676 Some(match event_id {
677 1 => "process_creation",
678 3 => "network_connection",
679 6 => "driver_load",
680 7 => "image_load",
681 8 => "create_remote_thread",
682 10 => "process_access",
683 11 => "file_event",
684 22 => "dns_query",
685 23 => "file_delete",
686 _ => return None,
687 })
688}
689
690fn infer_logsource<E: Event>(
691 exemplars: &[E],
692 config: &DraftConfig,
693 warnings: &mut Vec<String>,
694) -> DraftLogsource {
695 let mut out = DraftLogsource::default();
696
697 let classifier = SchemaClassifier::builtin();
699 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
700 for e in exemplars {
701 if let Some(m) = classifier.classify(e) {
702 *counts.entry(m.name).or_insert(0) += 1;
703 }
704 }
705 let majority = counts
706 .iter()
707 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
708 .map(|(name, _)| name.as_str());
709
710 match majority {
711 Some("sysmon") => {
712 out.product = Some("windows".to_string());
713 let ids: BTreeSet<i64> = exemplars
716 .iter()
717 .filter_map(|e| e.get_field("EventID").and_then(|v| v.as_i64()))
718 .collect();
719 let category = if ids.len() == 1 {
720 ids.first().copied().and_then(sysmon_category)
721 } else {
722 None
723 };
724 match category {
725 Some(c) => out.category = Some(c.to_string()),
726 None => out.service = Some("sysmon".to_string()),
727 }
728 out.inferred = true;
729 }
730 Some("windows_eventlog") | Some("ecs_windows") => {
731 out.product = Some("windows".to_string());
732 out.inferred = true;
733 }
734 Some("ecs_linux") => {
735 out.product = Some("linux".to_string());
736 out.inferred = true;
737 }
738 _ => {}
739 }
740
741 if config.logsource_category.is_some() {
743 out.category = config.logsource_category.clone();
744 out.inferred = true;
745 }
746 if config.logsource_product.is_some() {
747 out.product = config.logsource_product.clone();
748 out.inferred = true;
749 }
750 if config.logsource_service.is_some() {
751 out.service = config.logsource_service.clone();
752 out.inferred = true;
753 }
754
755 if !out.inferred {
756 warnings.push(
757 "logsource could not be inferred from the exemplars; \
758 replace the 'todo' placeholder before committing"
759 .to_string(),
760 );
761 out.product = Some("todo".to_string());
762 }
763 out
764}
765
766fn title_marker(profiles: &[DraftFieldProfile], selected: &[usize]) -> Option<String> {
769 let first = selected.first().map(|&i| &profiles[i])?;
770 let form = first.form.as_ref()?;
771 let raw = match form {
772 ValueForm::Exact(v) => v.as_display(),
773 ValueForm::OneOf(vs) => vs.first().map(|v| v.as_display()).unwrap_or_default(),
774 ValueForm::EndsWith(s) | ValueForm::StartsWith(s) | ValueForm::Contains(s) => s.clone(),
775 ValueForm::ContainsAll(ts) => ts.first().cloned().unwrap_or_default(),
776 };
777 let trimmed = raw.trim_matches(|c: char| !c.is_ascii_alphanumeric());
778 if trimmed.is_empty() {
779 None
780 } else {
781 Some(format!("{trimmed} ({})", first.field()))
782 }
783}
784
785fn emit_rule_yaml(
786 profiles: &[DraftFieldProfile],
787 selected: &[usize],
788 detection: &DetectionBlock,
789 logsource: &DraftLogsource,
790 config: &DraftConfig,
791 name: Option<&str>,
792) -> String {
793 let title = config.title.clone().unwrap_or_else(|| {
794 title_marker(profiles, selected)
795 .map(|m| format!("Draft: {m}"))
796 .unwrap_or_else(|| "Draft rule".to_string())
797 });
798 let date = config
799 .date
800 .clone()
801 .unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%d").to_string());
802
803 let mut out = String::new();
804 out.push_str(&format!("title: {}\n", yaml_title_str(&title)));
805 if let Some(name) = name {
806 out.push_str(&format!("name: {}\n", yaml_str(name)));
807 }
808 if let Some(id) = &config.rule_id {
809 out.push_str(&format!("id: {id}\n"));
810 }
811 out.push_str("status: experimental\n");
812 out.push_str("description: 'TODO: describe what this rule detects and why it matters.'\n");
813 out.push_str("author: 'TODO: your name'\n");
814 out.push_str(&format!("date: {date}\n"));
815 out.push_str("logsource:\n");
816 if let Some(c) = &logsource.category {
817 out.push_str(&format!(" category: {}\n", yaml_str(c)));
818 }
819 if let Some(p) = &logsource.product {
820 out.push_str(&format!(" product: {}\n", yaml_str(p)));
821 }
822 if let Some(s) = &logsource.service {
823 out.push_str(&format!(" service: {}\n", yaml_str(s)));
824 }
825 out.push_str("detection:\n");
826 for sel in &detection.selections {
827 out.push_str(&format!(" {}:\n", sel.name));
828 for (field, form) in &sel.entries {
829 emit_form(&mut out, field, form, " ");
830 }
831 }
832 out.push_str(&format!(" condition: {}\n", detection.condition));
833 out.push_str("falsepositives:\n");
834 out.push_str(" - 'TODO: list known benign triggers.'\n");
835 out.push_str("level: medium\n");
836 out
837}
838
839fn compile_draft(yaml: &str) -> Result<Engine, DraftError> {
844 let collection = rsigma_parser::parse_sigma_yaml(yaml).map_err(|e| DraftError::Internal {
845 stage: "parse".to_string(),
846 message: e.to_string(),
847 })?;
848 let mut engine = Engine::new();
849 engine
850 .add_collection(&collection)
851 .map_err(|e| DraftError::Internal {
852 stage: "compile".to_string(),
853 message: e.to_string(),
854 })?;
855 Ok(engine)
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861 use crate::event::JsonEvent;
862 use serde_json::{Value, json};
863
864 fn events(values: &[Value]) -> Vec<JsonEvent<'_>> {
865 values.iter().map(JsonEvent::borrow).collect()
866 }
867
868 fn fixed_config() -> DraftConfig {
869 DraftConfig {
870 rule_id: Some("00000000-0000-4000-8000-000000000000".to_string()),
871 date: Some("2026-07-03".to_string()),
872 ..DraftConfig::default()
873 }
874 }
875
876 fn draft(
877 exemplars: &[Value],
878 baseline: &[Value],
879 config: &DraftConfig,
880 ) -> Result<DraftReport, DraftError> {
881 draft_rule(&events(exemplars), &events(baseline), config)
882 }
883
884 #[test]
887 fn timestamp_names_and_values_are_volatile() {
888 assert!(is_volatile_name("UtcTime"));
889 assert!(is_volatile_name("@timestamp"));
890 assert!(is_volatile_name("event.created_date"));
891 assert!(is_volatile_value(&DraftValue::Str(
892 "2026-07-03T12:00:00Z".into()
893 )));
894 assert!(is_volatile_value(&DraftValue::Str("2026-07-03".into())));
895 assert!(!is_volatile_value(&DraftValue::Str("whoami.exe".into())));
896 }
897
898 #[test]
899 fn uuid_values_and_guid_names_are_volatile() {
900 assert!(is_volatile_name("ProcessGuid"));
901 assert!(is_uuid_string("6bde842e-a2f4-441e-b027-3aa79b1b2fc2"));
902 assert!(is_uuid_string("{6bde842e-a2f4-441e-b027-3aa79b1b2fc2}"));
903 assert!(!is_uuid_string("not-a-uuid"));
904 }
905
906 #[test]
907 fn counter_names_and_epoch_values_are_volatile() {
908 assert!(is_volatile_name("ProcessId"));
909 assert!(is_volatile_name("Event.System.EventRecordID"));
910 assert!(is_volatile_name("logon_id"));
911 assert!(is_epoch_number(1_751_500_000.0)); assert!(is_epoch_number(1_751_500_000_000.0)); assert!(!is_epoch_number(4688.0)); }
915
916 #[test]
917 fn time_date_name_match_is_word_bounded() {
918 assert!(is_volatile_name("EventTime"));
920 assert!(is_volatile_name("event_date"));
921 assert!(is_volatile_name("datetime"));
922 assert!(!is_volatile_name("runtime"));
925 assert!(!is_volatile_name("update"));
926 assert!(!is_volatile_name("candidate"));
927 assert!(!is_volatile_name("CommandLine"));
928 assert!(!is_volatile_name("validate_action"));
929 }
930
931 #[test]
932 fn shared_affix_never_splits_a_multibyte_char() {
933 assert_eq!(
936 shared_prefix(&["abcé1", "abcè2"], 3).as_deref(),
937 Some("abc")
938 );
939 assert_eq!(shared_prefix(&["abcé1", "abcè2"], 4), None);
940 assert_eq!(shared_suffix(&["x\u{03a9}", "y\u{00e9}"], 1), None);
943 assert_eq!(
945 shared_suffix(&["1éabc", "2éabc"], 3).as_deref(),
946 Some("éabc")
947 );
948 }
949
950 #[test]
951 fn random_unique_values_are_volatile() {
952 let exemplars: Vec<Value> = (0..4)
953 .map(|i| {
954 json!({
955 "tool": "runner",
956 "task": "sync",
957 "token": format!("a9f{i}c2d4e6b8a0f1c3d5e7f9b1a3c5d{i}"),
958 })
959 })
960 .collect();
961 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
962 let token = report.fields.iter().find(|f| f.field == "token").unwrap();
963 assert_eq!(token.stability, Stability::Volatile);
964 assert!(!token.selected);
965 }
966
967 #[test]
970 fn baseline_contrast_prefers_rare_fields() {
971 let exemplars: Vec<Value> = (0..3)
972 .map(|_| json!({"action": "exfil", "proto": "tcp"}))
973 .collect();
974 let baseline: Vec<Value> = (0..20)
976 .map(|i| json!({"action": format!("browse{i}"), "proto": "tcp"}))
977 .collect();
978 let report = draft(&exemplars, &baseline, &fixed_config()).unwrap();
979 let action = report.fields.iter().find(|f| f.field == "action").unwrap();
980 let proto = report.fields.iter().find(|f| f.field == "proto").unwrap();
981 assert!(
982 action.score > proto.score,
983 "baseline-rare field must outrank the ubiquitous one"
984 );
985 assert_eq!(proto.baseline_prevalence, Some(1.0));
986 assert_eq!(action.baseline_prevalence, Some(0.0));
987 }
988
989 #[test]
990 fn structural_fields_are_demoted_without_baseline() {
991 let exemplars: Vec<Value> = (0..3)
992 .map(|_| json!({"hostname": "web-01", "action": "exfil"}))
993 .collect();
994 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
995 let host = report
996 .fields
997 .iter()
998 .find(|f| f.field == "hostname")
999 .unwrap();
1000 let action = report.fields.iter().find(|f| f.field == "action").unwrap();
1001 assert!(action.score > host.score);
1002 }
1003
1004 #[test]
1005 fn deterministic_output_across_runs() {
1006 let exemplars: Vec<Value> = (0..3)
1007 .map(|_| json!({"vendor": "acme", "action": "alert", "sig": "S-1001"}))
1008 .collect();
1009 let a = draft(&exemplars, &[], &fixed_config()).unwrap().rule_yaml;
1010 let b = draft(&exemplars, &[], &fixed_config()).unwrap().rule_yaml;
1011 assert_eq!(a, b, "draft output must be byte-identical across runs");
1012 }
1013
1014 #[test]
1015 fn structured_candidate_reemits_deterministically_after_drop() {
1016 let exemplars: Vec<Value> = (0..3)
1017 .map(|_| json!({"vendor": "acme", "action": "alert", "kind": "auth"}))
1018 .collect();
1019 let events = events(&exemplars);
1020 let mut candidate = DraftCandidate::build(&events, &[], &fixed_config()).unwrap();
1021 assert!(candidate.drop_lowest_eligible(None).is_some());
1022 let first = candidate.emit(&events, &fixed_config());
1023 let second = candidate.emit(&events, &fixed_config());
1024 assert_eq!(first, second);
1025 }
1026
1027 #[test]
1028 fn structured_candidate_never_drops_forced_fields() {
1029 let exemplars: Vec<Value> = (0..3)
1030 .map(|_| json!({"forced": "keep", "action": "alert"}))
1031 .collect();
1032 let events = events(&exemplars);
1033 let config = DraftConfig {
1034 include_fields: vec!["forced".to_string()],
1035 max_fields: 1,
1036 ..fixed_config()
1037 };
1038 let mut candidate = DraftCandidate::build(&events, &[], &config).unwrap();
1039 assert!(candidate.drop_lowest_eligible(None).is_none());
1040 assert_eq!(candidate.profiles[candidate.selected[0]].field(), "forced");
1041 }
1042
1043 #[test]
1044 fn structured_candidate_excludes_all_grouping_fields() {
1045 let exemplars: Vec<Value> = (0..3)
1046 .map(|_| json!({"tenant": "one", "user": "alice", "action": "alert"}))
1047 .collect();
1048 let events = events(&exemplars);
1049 let config = DraftConfig {
1050 exclude_fields: vec!["tenant".to_string(), "user".to_string()],
1051 ..fixed_config()
1052 };
1053 let candidate = DraftCandidate::build(&events, &[], &config).unwrap();
1054 assert!(
1055 candidate
1056 .profiles
1057 .iter()
1058 .all(|profile| !matches!(profile.field(), "tenant" | "user"))
1059 );
1060 }
1061
1062 #[test]
1065 fn shared_path_tail_becomes_endswith() {
1066 let exemplars = vec![
1067 json!({"Image": "C:\\Tools\\whoami.exe", "kind": "proc"}),
1068 json!({"Image": "C:\\Windows\\System32\\whoami.exe", "kind": "proc"}),
1069 json!({"Image": "D:\\stage\\whoami.exe", "kind": "proc"}),
1070 json!({"Image": "E:\\x\\whoami.exe", "kind": "proc"}),
1071 json!({"Image": "F:\\y\\whoami.exe", "kind": "proc"}),
1072 ];
1073 let cfg = DraftConfig {
1074 max_value_cardinality: 3,
1075 ..fixed_config()
1076 };
1077 let report = draft(&exemplars, &[], &cfg).unwrap();
1078 assert!(
1079 report.rule_yaml.contains("Image|endswith: '\\whoami.exe'"),
1080 "expected endswith derivation, got:\n{}",
1081 report.rule_yaml
1082 );
1083 }
1084
1085 #[test]
1086 fn shared_prefix_becomes_startswith() {
1087 let exemplars: Vec<Value> = (0..5)
1088 .map(|i| json!({"url": format!("https://evil.example/payload{i}"), "verb": "GET"}))
1089 .collect();
1090 let cfg = DraftConfig {
1091 max_value_cardinality: 3,
1092 ..fixed_config()
1093 };
1094 let report = draft(&exemplars, &[], &cfg).unwrap();
1095 assert!(
1096 report
1097 .rule_yaml
1098 .contains("url|startswith: 'https://evil.example/payload'"),
1099 "expected startswith derivation, got:\n{}",
1100 report.rule_yaml
1101 );
1102 }
1103
1104 #[test]
1105 fn short_generic_tokens_are_never_chosen() {
1106 let exemplars: Vec<Value> = (0..5)
1108 .map(|i| json!({"cmd": format!("{i}zz run q{i}"), "kind": "x"}))
1109 .collect();
1110 let cfg = DraftConfig {
1111 max_value_cardinality: 3,
1112 ..fixed_config()
1113 };
1114 let report = draft(&exemplars, &[], &cfg).unwrap();
1115 let cmd = report.fields.iter().find(|f| f.field == "cmd").unwrap();
1116 assert_eq!(cmd.stability, Stability::Volatile);
1117 assert!(!report.rule_yaml.contains("cmd|contains"));
1118 }
1119
1120 #[test]
1121 fn baseline_generic_token_is_rejected() {
1122 let exemplars: Vec<Value> = (0..5)
1124 .map(|i| json!({"proc": format!("powershell -x {i}q{i}w{i}"), "kind": "spawn"}))
1125 .collect();
1126 let baseline: Vec<Value> = (0..20)
1127 .map(|i| json!({"proc": format!("powershell -File login{i}.ps1"), "kind": "spawn"}))
1128 .collect();
1129 let cfg = DraftConfig {
1130 max_value_cardinality: 3,
1131 min_fields: 1,
1132 ..fixed_config()
1133 };
1134 let report = draft(&exemplars, &baseline, &cfg).unwrap();
1135 assert!(
1136 !report.rule_yaml.contains("proc|contains: powershell"),
1137 "generic baseline token must be rejected, got:\n{}",
1138 report.rule_yaml
1139 );
1140 }
1141
1142 #[test]
1143 fn wildcard_specials_in_values_are_escaped() {
1144 let exemplars: Vec<Value> = (0..3)
1145 .map(|_| json!({"query": "SELECT * FROM users?", "app": "dbd"}))
1146 .collect();
1147 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1148 assert!(
1149 report.rule_yaml.contains(r"SELECT \* FROM users\?"),
1150 "wildcards must be escaped, got:\n{}",
1151 report.rule_yaml
1152 );
1153 assert_eq!(report.exemplar_matched, 3);
1156 }
1157
1158 #[test]
1159 fn escape_sigma_value_handles_backslash_adjacency() {
1160 assert_eq!(escape_sigma_value(r"C:\Windows"), r"C:\Windows");
1161 assert_eq!(escape_sigma_value("a*b"), r"a\*b");
1162 assert_eq!(escape_sigma_value("a?b"), r"a\?b");
1163 assert_eq!(escape_sigma_value(r"a\*b"), r"a\\\*b");
1164 assert_eq!(escape_sigma_value(r"a\\b"), r"a\\\\b");
1165 assert_eq!(escape_sigma_value(r"trailing\"), r"trailing\\");
1166 }
1167
1168 #[test]
1171 fn distinct_value_groups_split_into_selections() {
1172 let exemplars = vec![
1173 json!({"Image": "C:\\W\\vssadmin.exe", "CommandLine": "vssadmin delete shadows", "k": "p"}),
1174 json!({"Image": "C:\\W\\vssadmin.exe", "CommandLine": "vssadmin delete shadows", "k": "p"}),
1175 json!({"Image": "C:\\W\\wmic.exe", "CommandLine": "wmic shadowcopy delete", "k": "p"}),
1176 json!({"Image": "C:\\W\\wmic.exe", "CommandLine": "wmic shadowcopy delete", "k": "p"}),
1177 ];
1178 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1179 assert!(
1180 report.rule_yaml.contains("condition: 1 of selection_*"),
1181 "expected a group split, got:\n{}",
1182 report.rule_yaml
1183 );
1184 assert!(report.rule_yaml.contains("selection_vssadmin:"));
1185 assert!(report.rule_yaml.contains("selection_wmic:"));
1186 assert_eq!(report.exemplar_matched, 4);
1187 }
1188
1189 #[test]
1190 fn no_split_when_values_do_not_partition() {
1191 let exemplars: Vec<Value> = (0..4)
1192 .map(|_| json!({"vendor": "acme", "action": "alert"}))
1193 .collect();
1194 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1195 assert!(report.rule_yaml.contains("condition: selection\n"));
1196 }
1197
1198 #[test]
1201 fn sysmon_event_id_maps_to_category() {
1202 let exemplars: Vec<Value> = (0..3)
1203 .map(|_| {
1204 json!({
1205 "Channel": "Microsoft-Windows-Sysmon/Operational",
1206 "EventID": 1,
1207 "Image": "C:\\W\\evil.exe",
1208 "CommandLine": "evil.exe --run",
1209 })
1210 })
1211 .collect();
1212 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1213 assert!(report.rule_yaml.contains("category: process_creation"));
1214 assert!(report.rule_yaml.contains("product: windows"));
1215 assert!(!report.rule_yaml.contains("service: sysmon"));
1216 }
1217
1218 #[test]
1219 fn sysmon_without_shared_event_id_keeps_service() {
1220 let exemplars = vec![
1221 json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 1, "Image": "C:\\W\\a.exe", "RuleName": "t"}),
1222 json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 3, "Image": "C:\\W\\a.exe", "RuleName": "t"}),
1223 ];
1224 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1225 assert!(report.rule_yaml.contains("service: sysmon"));
1226 assert!(report.rule_yaml.contains("product: windows"));
1227 }
1228
1229 #[test]
1230 fn logsource_overrides_win() {
1231 let exemplars: Vec<Value> = (0..3)
1232 .map(|_| json!({"vendor": "acme", "action": "alert"}))
1233 .collect();
1234 let cfg = DraftConfig {
1235 logsource_product: Some("acme_fw".to_string()),
1236 logsource_category: Some("firewall".to_string()),
1237 ..fixed_config()
1238 };
1239 let report = draft(&exemplars, &[], &cfg).unwrap();
1240 assert!(report.rule_yaml.contains("product: acme_fw"));
1241 assert!(report.rule_yaml.contains("category: firewall"));
1242 assert!(!report.rule_yaml.contains("todo"));
1243 }
1244
1245 #[test]
1246 fn unknown_schema_gets_todo_placeholder() {
1247 let exemplars: Vec<Value> = (0..3)
1248 .map(|_| json!({"vendor": "acme", "action": "alert"}))
1249 .collect();
1250 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1251 assert!(report.rule_yaml.contains("product: todo"));
1252 assert!(
1253 report
1254 .warnings
1255 .iter()
1256 .any(|w| w.contains("logsource could not be inferred"))
1257 );
1258 }
1259
1260 #[test]
1263 fn draft_round_trips_and_matches_exemplars() {
1264 let exemplars: Vec<Value> = (0..4)
1265 .map(|_| json!({"vendor": "acme", "action": "exfil", "dst_port": 443}))
1266 .collect();
1267 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1268 let collection =
1271 rsigma_parser::parse_sigma_yaml(&report.rule_yaml).expect("emitted draft must parse");
1272 let mut engine = Engine::new();
1273 engine.add_collection(&collection).unwrap();
1274 for e in &events(&exemplars) {
1275 assert!(!engine.evaluate(e).is_empty(), "exemplar must match");
1276 }
1277 assert_eq!(report.exemplar_matched, report.exemplar_total);
1278 assert!(
1279 report
1280 .rule_yaml
1281 .contains("id: 00000000-0000-4000-8000-000000000000")
1282 );
1283 assert!(report.rule_yaml.contains("status: experimental"));
1284 assert!(report.rule_yaml.contains("level: medium"));
1285 assert!(report.rule_yaml.contains("date: 2026-07-03"));
1286 }
1287
1288 #[test]
1289 fn typed_values_emit_as_numbers() {
1290 let exemplars: Vec<Value> = (0..3)
1291 .map(|_| json!({"vendor": "acme", "code": 4688}))
1292 .collect();
1293 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1294 assert!(
1295 report.rule_yaml.contains("code: 4688"),
1296 "integers must emit bare, got:\n{}",
1297 report.rule_yaml
1298 );
1299 }
1300
1301 #[test]
1302 fn baseline_hits_are_counted_with_rate() {
1303 let exemplars: Vec<Value> = (0..3)
1304 .map(|_| json!({"vendor": "acme", "action": "alert"}))
1305 .collect();
1306 let mut baseline: Vec<Value> = (0..8)
1307 .map(|i| json!({"vendor": "other", "action": format!("a{i}")}))
1308 .collect();
1309 baseline.push(json!({"vendor": "acme", "action": "alert"}));
1311 baseline.push(json!({"vendor": "acme", "action": "alert"}));
1312 let report = draft(&exemplars, &baseline, &fixed_config()).unwrap();
1313 assert_eq!(report.baseline_total, 10);
1314 assert_eq!(report.baseline_hits, Some(2));
1315 assert!((report.baseline_hit_rate.unwrap() - 0.2).abs() < 1e-9);
1316 assert!(report.warnings.iter().any(|w| w.contains("baseline")));
1317 }
1318
1319 #[test]
1320 fn skip_baseline_eval_keeps_scoring_but_not_hits() {
1321 let exemplars: Vec<Value> = (0..3)
1322 .map(|_| json!({"vendor": "acme", "action": "alert"}))
1323 .collect();
1324 let baseline: Vec<Value> = (0..5)
1325 .map(|i| json!({"vendor": "other", "action": format!("a{i}")}))
1326 .collect();
1327 let cfg = DraftConfig {
1328 evaluate_baseline: false,
1329 ..fixed_config()
1330 };
1331 let report = draft(&exemplars, &baseline, &cfg).unwrap();
1332 assert_eq!(report.baseline_hits, None);
1333 assert!(
1334 report
1335 .fields
1336 .iter()
1337 .any(|f| f.baseline_prevalence.is_some()),
1338 "contrastive scoring still uses the baseline"
1339 );
1340 }
1341
1342 #[test]
1345 fn relaxation_drops_partial_prevalence_fields() {
1346 let mut exemplars: Vec<Value> = (0..2)
1349 .map(|_| json!({"vendor": "acme", "action": "alert", "extra": "x"}))
1350 .collect();
1351 exemplars.extend((0..2).map(|_| json!({"vendor": "acme", "action": "alert"})));
1352 let cfg = DraftConfig {
1353 min_prevalence: 0.4,
1354 ..fixed_config()
1355 };
1356 let report = draft(&exemplars, &[], &cfg).unwrap();
1357 assert_eq!(report.exemplar_matched, 4);
1358 assert!(!report.rule_yaml.contains("extra"));
1359 assert!(report.warnings.iter().any(|w| w.contains("relaxed")));
1360 }
1361
1362 #[test]
1363 fn floor_errors_instead_of_emitting_overbroad_draft() {
1364 let mut exemplars: Vec<Value> = (0..2)
1367 .map(|_| json!({"alpha": "one", "beta": "x"}))
1368 .collect();
1369 exemplars.extend((0..2).map(|_| json!({"alpha": "two", "gamma": "y"})));
1370 let cfg = DraftConfig {
1371 min_prevalence: 0.4,
1372 min_fields: 2,
1373 max_value_cardinality: 1,
1374 ..fixed_config()
1375 };
1376 let err = draft(&exemplars, &[], &cfg).unwrap_err();
1377 assert!(
1378 matches!(err, DraftError::CannotMatchExemplars { floor: 2, .. }),
1379 "expected the floor error, got: {err}"
1380 );
1381 }
1382
1383 #[test]
1384 fn forced_field_absent_from_exemplars_errors_immediately() {
1385 let mut exemplars: Vec<Value> = (0..2)
1389 .map(|_| json!({"vendor": "acme", "action": "alert", "extra": "x"}))
1390 .collect();
1391 exemplars.extend((0..2).map(|_| json!({"vendor": "acme", "action": "alert"})));
1392 let cfg = DraftConfig {
1393 include_fields: vec!["extra".to_string()],
1394 min_prevalence: 0.4,
1395 ..fixed_config()
1396 };
1397 let err = draft(&exemplars, &[], &cfg).unwrap_err();
1398 match err {
1399 DraftError::ForcedFieldMismatch { fields, failing } => {
1400 assert_eq!(fields, vec!["extra".to_string()]);
1401 assert_eq!(failing, vec![2, 3]);
1402 }
1403 other => panic!("expected ForcedFieldMismatch, got: {other}"),
1404 }
1405 }
1406
1407 #[test]
1408 fn no_exemplars_is_an_error() {
1409 let err = draft(&[], &[], &fixed_config()).unwrap_err();
1410 assert!(matches!(err, DraftError::NoExemplars));
1411 }
1412
1413 #[test]
1414 fn all_volatile_fields_is_an_error() {
1415 let exemplars: Vec<Value> = (0..3)
1416 .map(|i| {
1417 json!({
1418 "UtcTime": format!("2026-07-03T12:00:0{i}Z"),
1419 "ProcessGuid": format!("6bde842e-a2f4-441e-b027-3aa79b1b2fc{i}"),
1420 })
1421 })
1422 .collect();
1423 let err = draft(&exemplars, &[], &fixed_config()).unwrap_err();
1424 assert!(matches!(err, DraftError::NoCandidateFields(3)));
1425 }
1426
1427 #[test]
1430 fn include_and_exclude_fields_are_honored() {
1431 let exemplars: Vec<Value> = (0..3)
1432 .map(|_| json!({"vendor": "acme", "action": "alert", "noise": "same"}))
1433 .collect();
1434 let cfg = DraftConfig {
1435 include_fields: vec!["noise".to_string()],
1436 exclude_fields: vec!["vendor".to_string()],
1437 max_fields: 2,
1438 ..fixed_config()
1439 };
1440 let report = draft(&exemplars, &[], &cfg).unwrap();
1441 assert!(report.rule_yaml.contains("noise: same"));
1442 assert!(!report.rule_yaml.contains("vendor"));
1443 }
1444
1445 #[test]
1446 fn title_override_and_derived_title() {
1447 let exemplars: Vec<Value> = (0..3)
1448 .map(|_| json!({"vendor": "acme", "action": "alert"}))
1449 .collect();
1450 let derived = draft(&exemplars, &[], &fixed_config()).unwrap();
1451 assert!(
1452 derived.rule_yaml.starts_with("title: 'Draft:")
1453 || derived.rule_yaml.starts_with("title: Draft"),
1454 "derived title expected, got:\n{}",
1455 derived.rule_yaml
1456 );
1457 let cfg = DraftConfig {
1458 title: Some("Acme Exfil Detection".to_string()),
1459 ..fixed_config()
1460 };
1461 let titled = draft(&exemplars, &[], &cfg).unwrap();
1462 assert!(titled.rule_yaml.starts_with("title: Acme Exfil Detection"));
1463 }
1464}