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