1use std::collections::{BTreeMap, BTreeSet};
30use std::fmt;
31
32use serde::Serialize;
33
34use crate::engine::Engine;
35use crate::event::{Event, EventValue};
36use crate::schema::SchemaClassifier;
37use crate::schema_discovery::FieldProfile;
38
39#[derive(Debug, Clone)]
46pub struct DraftConfig {
47 pub max_fields: usize,
49 pub min_fields: usize,
52 pub min_prevalence: f64,
55 pub max_value_cardinality: usize,
58 pub min_token_len: usize,
62 pub max_baseline_token_prevalence: f64,
65 pub include_fields: Vec<String>,
68 pub exclude_fields: Vec<String>,
70 pub title: Option<String>,
72 pub rule_id: Option<String>,
75 pub date: Option<String>,
78 pub logsource_category: Option<String>,
80 pub logsource_product: Option<String>,
81 pub logsource_service: Option<String>,
82 pub evaluate_baseline: bool,
85}
86
87impl Default for DraftConfig {
88 fn default() -> Self {
89 Self {
90 max_fields: 4,
91 min_fields: 2,
92 min_prevalence: 1.0,
93 max_value_cardinality: 4,
94 min_token_len: 4,
95 max_baseline_token_prevalence: 0.05,
96 include_fields: Vec::new(),
97 exclude_fields: Vec::new(),
98 title: None,
99 rule_id: None,
100 date: None,
101 logsource_category: None,
102 logsource_product: None,
103 logsource_service: None,
104 evaluate_baseline: true,
105 }
106 }
107}
108
109#[derive(Debug, thiserror::Error)]
115pub enum DraftError {
116 #[error("no exemplar events to draft from")]
118 NoExemplars,
119 #[error(
122 "no candidate fields: every field was volatile (timestamps, ids, unique values), \
123 excluded, or below the prevalence threshold ({0} exemplars profiled)"
124 )]
125 NoCandidateFields(usize),
126 #[error(
129 "draft cannot match all exemplars: {matched}/{total} match at the {floor}-field floor; \
130 exemplars may be too heterogeneous for one rule (failing exemplar indexes: {failing:?})"
131 )]
132 CannotMatchExemplars {
133 matched: usize,
134 total: usize,
135 floor: usize,
136 failing: Vec<usize>,
137 },
138 #[error(
142 "forced field(s) {fields:?} are absent from exemplar(s) {failing:?}; \
143 remove the --include-field or drop those exemplars"
144 )]
145 ForcedFieldMismatch {
146 fields: Vec<String>,
147 failing: Vec<usize>,
148 },
149 #[error("internal error: emitted draft failed to {stage}: {message}")]
151 Internal { stage: String, message: String },
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
160#[serde(rename_all = "snake_case")]
161pub enum Stability {
162 Constant,
164 Enumerable,
166 Patterned,
168 Volatile,
170}
171
172impl fmt::Display for Stability {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 let s = match self {
175 Stability::Constant => "constant",
176 Stability::Enumerable => "enumerable",
177 Stability::Patterned => "patterned",
178 Stability::Volatile => "volatile",
179 };
180 f.write_str(s)
181 }
182}
183
184#[derive(Debug, Clone, Serialize)]
186pub struct DraftFieldReport {
187 pub field: String,
189 pub score: f64,
191 pub stability: Stability,
193 pub modifier: String,
195 pub values: Vec<String>,
197 pub baseline_prevalence: Option<f64>,
200 pub selected: bool,
202}
203
204#[derive(Debug, Clone)]
206pub struct DraftReport {
207 pub rule_yaml: String,
209 pub fields: Vec<DraftFieldReport>,
211 pub exemplar_total: usize,
213 pub exemplar_matched: usize,
216 pub baseline_total: usize,
218 pub baseline_hits: Option<usize>,
221 pub baseline_hit_rate: Option<f64>,
223 pub warnings: Vec<String>,
225}
226
227#[derive(Debug, Clone, PartialEq)]
233enum DraftValue {
234 Str(String),
235 Int(i64),
236 Float(f64),
237 Bool(bool),
238}
239
240impl DraftValue {
241 fn from_event_value(v: &EventValue<'_>) -> Option<Self> {
242 match v {
243 EventValue::Str(s) => Some(DraftValue::Str(s.to_string())),
244 EventValue::Int(n) => Some(DraftValue::Int(*n)),
245 EventValue::Float(f) => Some(DraftValue::Float(*f)),
246 EventValue::Bool(b) => Some(DraftValue::Bool(*b)),
247 EventValue::Null | EventValue::Array(_) | EventValue::Map(_) => None,
248 }
249 }
250
251 fn as_display(&self) -> String {
252 match self {
253 DraftValue::Str(s) => s.clone(),
254 DraftValue::Int(n) => n.to_string(),
255 DraftValue::Float(f) => f.to_string(),
256 DraftValue::Bool(b) => b.to_string(),
257 }
258 }
259
260 fn as_match_str(&self) -> String {
261 self.as_display()
262 }
263}
264
265#[derive(Debug, Clone, PartialEq)]
267enum ValueForm {
268 Exact(DraftValue),
270 OneOf(Vec<DraftValue>),
272 EndsWith(String),
274 StartsWith(String),
276 Contains(String),
278 ContainsAll(Vec<String>),
280}
281
282impl ValueForm {
283 fn modifier(&self) -> &'static str {
284 match self {
285 ValueForm::Exact(_) | ValueForm::OneOf(_) => "",
286 ValueForm::EndsWith(_) => "|endswith",
287 ValueForm::StartsWith(_) => "|startswith",
288 ValueForm::Contains(_) => "|contains",
289 ValueForm::ContainsAll(_) => "|contains|all",
290 }
291 }
292
293 fn display_values(&self) -> Vec<String> {
294 match self {
295 ValueForm::Exact(v) => vec![v.as_display()],
296 ValueForm::OneOf(vs) => vs.iter().map(|v| v.as_display()).collect(),
297 ValueForm::EndsWith(s) => vec![format!("*{s}")],
298 ValueForm::StartsWith(s) => vec![format!("{s}*")],
299 ValueForm::Contains(s) => vec![format!("*{s}*")],
300 ValueForm::ContainsAll(ts) => ts.iter().map(|t| format!("*{t}*")).collect(),
301 }
302 }
303
304 fn matches_lower(&self, lv: &str) -> bool {
308 match self {
309 ValueForm::Exact(v) => lv == v.as_match_str().to_lowercase(),
310 ValueForm::OneOf(vs) => vs.iter().any(|v| lv == v.as_match_str().to_lowercase()),
311 ValueForm::EndsWith(s) => lv.ends_with(&s.to_lowercase()),
312 ValueForm::StartsWith(s) => lv.starts_with(&s.to_lowercase()),
313 ValueForm::Contains(t) => lv.contains(&t.to_lowercase()),
314 ValueForm::ContainsAll(ts) => ts.iter().all(|t| lv.contains(&t.to_lowercase())),
315 }
316 }
317}
318
319#[derive(Debug, Clone)]
322struct DraftFieldProfile {
323 stats: FieldProfile,
325 values: Vec<Option<DraftValue>>,
327 stability: Stability,
328 form: Option<ValueForm>,
330 score: f64,
331 baseline_prevalence: Option<f64>,
332 forced: bool,
333}
334
335impl DraftFieldProfile {
336 fn field(&self) -> &str {
337 &self.stats.field
338 }
339
340 fn distinct(&self) -> Vec<&DraftValue> {
341 let mut seen: Vec<&DraftValue> = Vec::new();
342 for v in self.values.iter().flatten() {
343 if !seen.contains(&v) {
344 seen.push(v);
345 }
346 }
347 seen
348 }
349}
350
351pub fn draft_rule<E: Event>(
361 exemplars: &[E],
362 baseline: &[E],
363 config: &DraftConfig,
364) -> Result<DraftReport, DraftError> {
365 if exemplars.is_empty() {
366 return Err(DraftError::NoExemplars);
367 }
368 let mut warnings: Vec<String> = Vec::new();
369
370 let mut profiles = profile_fields(exemplars, config, &mut warnings);
372 if profiles.is_empty() {
373 return Err(DraftError::NoCandidateFields(exemplars.len()));
374 }
375
376 for p in &mut profiles {
378 infer_form(p, config);
379 }
380
381 if !baseline.is_empty() {
383 for p in &mut profiles {
384 apply_baseline(p, baseline, config);
385 }
386 }
387
388 let has_baseline = !baseline.is_empty();
390 for p in &mut profiles {
391 p.score = score_field(p, has_baseline);
392 }
393 profiles.sort_by(|a, b| {
396 b.forced
397 .cmp(&a.forced)
398 .then_with(|| {
399 b.score
400 .partial_cmp(&a.score)
401 .unwrap_or(std::cmp::Ordering::Equal)
402 })
403 .then_with(|| a.field().cmp(b.field()))
404 });
405
406 let usable: Vec<usize> = profiles
408 .iter()
409 .enumerate()
410 .filter(|(_, p)| p.form.is_some() && p.stability != Stability::Volatile)
411 .map(|(i, _)| i)
412 .collect();
413 if usable.is_empty() {
414 return Err(DraftError::NoCandidateFields(exemplars.len()));
415 }
416 let mut selected: Vec<usize> = usable.iter().copied().take(config.max_fields).collect();
417 if selected.len() < config.min_fields {
418 warnings.push(format!(
419 "only {} usable field(s) found (floor is {}); the draft may be broad",
420 selected.len(),
421 config.min_fields
422 ));
423 }
424
425 let logsource = infer_logsource(exemplars, config, &mut warnings);
427
428 let floor = config.min_fields.min(selected.len()).max(1);
430 let (yaml, matched, failing) = loop {
431 let detection = build_detection(&profiles, &selected, exemplars, config);
432 let yaml = emit_rule_yaml(&profiles, &selected, &detection, &logsource, config);
433 let engine = compile_draft(&yaml)?;
434 let failing: Vec<usize> = exemplars
435 .iter()
436 .enumerate()
437 .filter(|(_, e)| engine.evaluate(e).is_empty())
438 .map(|(i, _)| i)
439 .collect();
440 if failing.is_empty() {
441 break (yaml, exemplars.len(), failing);
442 }
443
444 let absent_in_failing =
448 |i: usize| failing.iter().any(|&idx| profiles[i].values[idx].is_none());
449
450 let forced_culprits: Vec<String> = selected
454 .iter()
455 .filter(|&&i| profiles[i].forced && absent_in_failing(i))
456 .map(|&i| profiles[i].field().to_string())
457 .collect();
458 if !forced_culprits.is_empty() {
459 return Err(DraftError::ForcedFieldMismatch {
460 fields: forced_culprits,
461 failing,
462 });
463 }
464
465 if selected.len() <= floor {
466 return Err(DraftError::CannotMatchExemplars {
467 matched: exemplars.len() - failing.len(),
468 total: exemplars.len(),
469 floor,
470 failing,
471 });
472 }
473
474 let drop_pos = selected
477 .iter()
478 .rposition(|&i| !profiles[i].forced && absent_in_failing(i))
479 .or_else(|| selected.iter().rposition(|&i| !profiles[i].forced));
480 let Some(pos) = drop_pos else {
481 return Err(DraftError::CannotMatchExemplars {
482 matched: exemplars.len() - failing.len(),
483 total: exemplars.len(),
484 floor,
485 failing,
486 });
487 };
488 let dropped = selected.remove(pos);
489 warnings.push(format!(
490 "relaxed: dropped field '{}' because the draft did not match every exemplar with it",
491 profiles[dropped].field()
492 ));
493 };
494 debug_assert!(failing.is_empty());
495
496 let (baseline_hits, baseline_hit_rate) = if !baseline.is_empty() && config.evaluate_baseline {
498 let engine = compile_draft(&yaml)?;
499 let hits = baseline
500 .iter()
501 .filter(|e| !engine.evaluate(e).is_empty())
502 .count();
503 let rate = hits as f64 / baseline.len() as f64;
504 if hits > 0 {
505 warnings.push(format!(
506 "draft matches {hits}/{} baseline events ({:.1}%); consider a tighter field",
507 baseline.len(),
508 rate * 100.0
509 ));
510 }
511 (Some(hits), Some(rate))
512 } else {
513 (None, None)
514 };
515
516 for w in rsigma_parser::lint_yaml_str(&yaml) {
518 warnings.push(format!("lint {}: {}", w.rule, w.message));
519 }
520
521 let selected_set: BTreeSet<usize> = selected.iter().copied().collect();
523 let fields = profiles
524 .iter()
525 .enumerate()
526 .map(|(i, p)| DraftFieldReport {
527 field: p.field().to_string(),
528 score: p.score,
529 stability: p.stability,
530 modifier: p
531 .form
532 .as_ref()
533 .map(|f| f.modifier().trim_start_matches('|').to_string())
534 .unwrap_or_default(),
535 values: p
536 .form
537 .as_ref()
538 .map(|f| f.display_values())
539 .unwrap_or_else(|| {
540 p.distinct()
541 .into_iter()
542 .take(4)
543 .map(|v| v.as_display())
544 .collect()
545 }),
546 baseline_prevalence: p.baseline_prevalence,
547 selected: selected_set.contains(&i),
548 })
549 .collect();
550
551 Ok(DraftReport {
552 rule_yaml: yaml,
553 fields,
554 exemplar_total: exemplars.len(),
555 exemplar_matched: matched,
556 baseline_total: baseline.len(),
557 baseline_hits,
558 baseline_hit_rate,
559 warnings,
560 })
561}
562
563fn profile_fields<E: Event>(
568 exemplars: &[E],
569 config: &DraftConfig,
570 warnings: &mut Vec<String>,
571) -> Vec<DraftFieldProfile> {
572 let mut all_fields: BTreeSet<String> = BTreeSet::new();
574 for e in exemplars {
575 for k in e.field_keys() {
576 all_fields.insert(k.into_owned());
577 }
578 }
579
580 let excluded = |f: &str| {
581 config
582 .exclude_fields
583 .iter()
584 .any(|x| x.eq_ignore_ascii_case(f))
585 };
586 let forced = |f: &str| {
587 config
588 .include_fields
589 .iter()
590 .any(|x| x.eq_ignore_ascii_case(f))
591 };
592
593 for inc in &config.include_fields {
595 if !all_fields.iter().any(|f| f.eq_ignore_ascii_case(inc)) {
596 warnings.push(format!(
597 "--include-field '{inc}' does not appear in any exemplar; ignored"
598 ));
599 }
600 }
601
602 let total = exemplars.len();
603 let mut out = Vec::new();
604 for field in all_fields {
605 if excluded(&field) {
606 continue;
607 }
608 let values: Vec<Option<DraftValue>> = exemplars
609 .iter()
610 .map(|e| {
611 e.get_field(&field)
612 .and_then(|v| DraftValue::from_event_value(&v))
613 })
614 .collect();
615 let present = exemplars
616 .iter()
617 .filter(|e| e.get_field(&field).is_some())
618 .count();
619 let prevalence = present as f64 / total as f64;
620 let is_forced = forced(&field);
621 if prevalence < config.min_prevalence && !is_forced {
622 continue;
623 }
624 if is_forced && prevalence < 1.0 {
625 warnings.push(format!(
626 "--include-field '{field}' is absent from some exemplars \
627 ({present}/{total}); the draft may not match them"
628 ));
629 }
630
631 let mut distinct_values: Vec<String> = values
632 .iter()
633 .flatten()
634 .map(|v| v.as_display())
635 .collect::<BTreeSet<_>>()
636 .into_iter()
637 .collect();
638 distinct_values.sort();
639 let stats = FieldProfile {
640 field: field.clone(),
641 present: present as u64,
642 total: total as u64,
643 distinct_values,
644 value_overflow: false,
645 };
646
647 let stability = classify_stability(&field, &values, present, config);
648 out.push(DraftFieldProfile {
649 stats,
650 values,
651 stability,
652 form: None,
653 score: 0.0,
654 baseline_prevalence: None,
655 forced: is_forced,
656 });
657 }
658 out
659}
660
661fn classify_stability(
662 field: &str,
663 values: &[Option<DraftValue>],
664 present: usize,
665 config: &DraftConfig,
666) -> Stability {
667 let scalars: Vec<&DraftValue> = values.iter().flatten().collect();
668 if scalars.is_empty() || scalars.len() < present {
673 return Stability::Volatile;
674 }
675 if is_volatile_name(field) {
678 return Stability::Volatile;
679 }
680 if scalars.iter().any(|v| is_volatile_value(v)) {
681 return Stability::Volatile;
682 }
683
684 let mut distinct: Vec<&DraftValue> = Vec::new();
685 for v in &scalars {
686 if !distinct.contains(v) {
687 distinct.push(v);
688 }
689 }
690 if distinct.len() == 1 {
691 return Stability::Constant;
692 }
693 if distinct.len() <= config.max_value_cardinality && distinct.len() < scalars.len() {
694 return Stability::Enumerable;
695 }
696 let strings: Vec<&str> = distinct
698 .iter()
699 .filter_map(|v| match v {
700 DraftValue::Str(s) => Some(s.as_str()),
701 _ => None,
702 })
703 .collect();
704 if strings.len() == distinct.len() {
705 if distinct.len() == scalars.len() && strings.iter().all(|s| is_random_string(s)) {
708 return Stability::Volatile;
709 }
710 if shared_suffix(&strings, config.min_token_len).is_some()
711 || shared_prefix(&strings, config.min_token_len).is_some()
712 || !shared_tokens(&strings, config.min_token_len).is_empty()
713 {
714 return Stability::Patterned;
715 }
716 if distinct.len() <= config.max_value_cardinality {
720 return Stability::Enumerable;
721 }
722 } else if distinct.len() <= config.max_value_cardinality {
723 return Stability::Enumerable;
724 }
725 Stability::Volatile
726}
727
728fn is_volatile_name(field: &str) -> bool {
734 let segment = field.rsplit('.').next().unwrap_or(field);
735 let last = segment.to_lowercase();
736 let normalized: String = last.chars().filter(|c| *c != '_' && *c != '-').collect();
737 if last == "@timestamp" || normalized == "ts" {
738 return true;
739 }
740 if segment_words(segment)
744 .iter()
745 .any(|w| matches!(w.as_str(), "time" | "date" | "datetime" | "timestamp"))
746 {
747 return true;
748 }
749 if normalized.contains("timestamp")
750 || normalized.contains("guid")
751 || normalized.contains("uuid")
752 {
753 return true;
754 }
755 matches!(
756 normalized.as_str(),
757 "recordid"
758 | "recordnumber"
759 | "eventrecordid"
760 | "sequence"
761 | "seq"
762 | "seqno"
763 | "processid"
764 | "pid"
765 | "parentprocessid"
766 | "ppid"
767 | "threadid"
768 | "tid"
769 | "logonid"
770 | "sessionid"
771 | "executionprocessid"
772 | "executionthreadid"
773 )
774}
775
776fn segment_words(segment: &str) -> Vec<String> {
780 let mut words: Vec<String> = Vec::new();
781 let mut cur = String::new();
782 let mut prev: Option<char> = None;
783 for c in segment.chars() {
784 if !c.is_ascii_alphanumeric() {
785 if !cur.is_empty() {
786 words.push(std::mem::take(&mut cur));
787 }
788 prev = None;
789 continue;
790 }
791 if let Some(p) = prev
793 && c.is_ascii_uppercase()
794 && (p.is_ascii_lowercase() || p.is_ascii_digit())
795 && !cur.is_empty()
796 {
797 words.push(std::mem::take(&mut cur));
798 }
799 cur.push(c.to_ascii_lowercase());
800 prev = Some(c);
801 }
802 if !cur.is_empty() {
803 words.push(cur);
804 }
805 words
806}
807
808fn is_volatile_value(value: &DraftValue) -> bool {
810 match value {
811 DraftValue::Str(s) => is_timestamp_string(s) || is_uuid_string(s),
812 DraftValue::Int(n) => is_epoch_number(*n as f64),
813 DraftValue::Float(f) => is_epoch_number(*f),
814 DraftValue::Bool(_) => false,
815 }
816}
817
818fn is_timestamp_string(s: &str) -> bool {
820 let b = s.as_bytes();
821 if b.len() < 10 {
822 return false;
823 }
824 let date = b[0].is_ascii_digit()
825 && b[1].is_ascii_digit()
826 && b[2].is_ascii_digit()
827 && b[3].is_ascii_digit()
828 && b[4] == b'-'
829 && b[5].is_ascii_digit()
830 && b[6].is_ascii_digit()
831 && b[7] == b'-'
832 && b[8].is_ascii_digit()
833 && b[9].is_ascii_digit();
834 if !date {
835 return false;
836 }
837 b.len() == 10 || b[10] == b'T' || b[10] == b' '
839}
840
841fn is_uuid_string(s: &str) -> bool {
843 let s = s.strip_prefix('{').unwrap_or(s);
844 let s = s.strip_suffix('}').unwrap_or(s);
845 if s.len() != 36 {
846 return false;
847 }
848 s.char_indices().all(|(i, c)| match i {
849 8 | 13 | 18 | 23 => c == '-',
850 _ => c.is_ascii_hexdigit(),
851 })
852}
853
854fn is_epoch_number(n: f64) -> bool {
857 const RANGES: [(f64, f64); 4] = [
858 (1e9, 1e10), (1e12, 1e13), (1e15, 1e16), (1e18, 1e19), ];
863 RANGES.iter().any(|(lo, hi)| n >= *lo && n < *hi)
864}
865
866fn is_random_string(s: &str) -> bool {
869 s.len() >= 16
870 && s.chars().all(|c| c.is_ascii_alphanumeric())
871 && s.chars().any(|c| c.is_ascii_digit())
872 && s.chars().any(|c| c.is_ascii_alphabetic())
873}
874
875fn is_structural_name(field: &str) -> bool {
878 let last = field.rsplit('.').next().unwrap_or(field).to_lowercase();
879 matches!(
880 last.as_str(),
881 "host" | "hostname" | "computer" | "computername" | "domain" | "level" | "severity"
882 )
883}
884
885fn shared_prefix(values: &[&str], min_len: usize) -> Option<String> {
890 let first = values.first()?;
891 let mut len = first.len();
892 for v in &values[1..] {
893 len = len.min(common_prefix_len(first, v));
894 }
895 while len > 0 && !first.is_char_boundary(len) {
898 len -= 1;
899 }
900 if len >= min_len && values.iter().any(|v| v.len() > len) {
902 Some(first[..len].to_string())
903 } else {
904 None
905 }
906}
907
908fn shared_suffix(values: &[&str], min_len: usize) -> Option<String> {
909 let first = values.first()?;
910 let mut len = first.len();
911 for v in &values[1..] {
912 len = len.min(common_suffix_len(first, v));
913 }
914 let mut start = first.len() - len;
917 while start < first.len() && !first.is_char_boundary(start) {
918 start += 1;
919 }
920 let len = first.len() - start;
921 if len >= min_len && values.iter().any(|v| v.len() > len) {
922 Some(first[start..].to_string())
923 } else {
924 None
925 }
926}
927
928fn common_prefix_len(a: &str, b: &str) -> usize {
929 a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
930}
931
932fn common_suffix_len(a: &str, b: &str) -> usize {
933 a.bytes()
934 .rev()
935 .zip(b.bytes().rev())
936 .take_while(|(x, y)| x == y)
937 .count()
938}
939
940fn shared_tokens(values: &[&str], min_len: usize) -> Vec<String> {
943 let Some(first) = values.first() else {
944 return Vec::new();
945 };
946 let lowers: Vec<String> = values.iter().map(|v| v.to_lowercase()).collect();
947 let mut tokens: Vec<String> = tokenize(first, min_len)
948 .into_iter()
949 .filter(|t| {
950 let lt = t.to_lowercase();
951 lowers.iter().all(|v| v.contains(<))
952 })
953 .collect();
954 tokens.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
955 tokens.dedup();
956 tokens.truncate(3);
957 tokens
958}
959
960fn tokenize(s: &str, min_len: usize) -> Vec<String> {
961 let mut out: Vec<String> = Vec::new();
962 for token in s.split(|c: char| !c.is_ascii_alphanumeric()) {
963 if token.len() >= min_len && !out.iter().any(|t| t == token) {
964 out.push(token.to_string());
965 }
966 }
967 out
968}
969
970fn infer_form(profile: &mut DraftFieldProfile, config: &DraftConfig) {
975 if profile.stability == Stability::Volatile {
976 return;
977 }
978 let distinct: Vec<DraftValue> = profile.distinct().into_iter().cloned().collect();
979 if profile.stability == Stability::Patterned {
982 let strings: Vec<&str> = distinct
983 .iter()
984 .filter_map(|v| match v {
985 DraftValue::Str(s) => Some(s.as_str()),
986 _ => None,
987 })
988 .collect();
989 if strings.len() == distinct.len() {
990 profile.form = derive_pattern_form(&strings, config);
991 }
992 }
993 if profile.form.is_none() {
994 profile.form = derive_form(&distinct, config);
995 }
996 if profile.form.is_none() {
997 profile.stability = Stability::Volatile;
998 }
999}
1000
1001fn derive_form(distinct: &[DraftValue], config: &DraftConfig) -> Option<ValueForm> {
1002 match distinct {
1003 [] => None,
1004 [one] => Some(ValueForm::Exact(one.clone())),
1005 many if many.len() <= config.max_value_cardinality => Some(ValueForm::OneOf(many.to_vec())),
1006 many => {
1007 let strings: Vec<&str> = many
1008 .iter()
1009 .filter_map(|v| match v {
1010 DraftValue::Str(s) => Some(s.as_str()),
1011 _ => None,
1012 })
1013 .collect();
1014 if strings.len() != many.len() {
1015 return None;
1016 }
1017 derive_pattern_form(&strings, config)
1018 }
1019 }
1020}
1021
1022fn derive_pattern_form(strings: &[&str], config: &DraftConfig) -> Option<ValueForm> {
1023 if let Some(suffix) = shared_suffix(strings, config.min_token_len) {
1026 return Some(ValueForm::EndsWith(suffix));
1027 }
1028 if let Some(prefix) = shared_prefix(strings, config.min_token_len) {
1029 return Some(ValueForm::StartsWith(prefix));
1030 }
1031 let tokens = shared_tokens(strings, config.min_token_len);
1032 match tokens.len() {
1033 0 => None,
1034 1 => Some(ValueForm::Contains(tokens.into_iter().next().unwrap())),
1035 _ => Some(ValueForm::ContainsAll(tokens)),
1036 }
1037}
1038
1039fn apply_baseline<E: Event>(profile: &mut DraftFieldProfile, baseline: &[E], config: &DraftConfig) {
1044 let Some(form) = profile.form.clone() else {
1045 return;
1046 };
1047 let field = profile.field().to_string();
1051 let values: Vec<String> = baseline
1052 .iter()
1053 .filter_map(|e| {
1054 e.get_field(&field)
1055 .and_then(|v| v.as_str().map(|s| s.to_lowercase()))
1056 })
1057 .collect();
1058 let match_count = |f: &ValueForm| values.iter().filter(|lv| f.matches_lower(lv)).count();
1059 let token_is_generic = |t: &str| {
1060 let lt = t.to_lowercase();
1061 let hits = values.iter().filter(|lv| lv.contains(<)).count();
1062 hits as f64 / baseline.len() as f64 > config.max_baseline_token_prevalence
1063 };
1064
1065 let guarded = match form {
1067 ValueForm::Contains(ref t) => {
1068 if token_is_generic(t) {
1069 profile.form = None;
1070 profile.stability = Stability::Volatile;
1071 return;
1072 }
1073 form
1074 }
1075 ValueForm::ContainsAll(ref ts) => {
1076 let kept: Vec<String> = ts
1077 .iter()
1078 .filter(|t| !token_is_generic(t))
1079 .cloned()
1080 .collect();
1081 match kept.len() {
1082 0 => {
1083 profile.form = None;
1084 profile.stability = Stability::Volatile;
1085 return;
1086 }
1087 1 => ValueForm::Contains(kept.into_iter().next().unwrap()),
1088 _ => ValueForm::ContainsAll(kept),
1089 }
1090 }
1091 other => other,
1092 };
1093
1094 let hits = match_count(&guarded);
1095 profile.form = Some(guarded);
1096 profile.baseline_prevalence = Some(hits as f64 / baseline.len() as f64);
1097}
1098
1099fn score_field(profile: &DraftFieldProfile, has_baseline: bool) -> f64 {
1100 if profile.form.is_none() || profile.stability == Stability::Volatile {
1101 return f64::MIN;
1102 }
1103 let stability_base = match profile.stability {
1104 Stability::Constant => 3.0,
1105 Stability::Enumerable => 2.0,
1106 Stability::Patterned => 1.0,
1107 Stability::Volatile => 0.0,
1108 };
1109 let prevalence = profile.stats.prevalence();
1110 match profile.baseline_prevalence {
1111 Some(bp) => stability_base * prevalence * (1.0 - bp),
1112 None => {
1113 let demotion = if !has_baseline && is_structural_name(profile.field()) {
1114 0.5
1115 } else {
1116 0.0
1117 };
1118 stability_base * prevalence - demotion
1119 }
1120 }
1121}
1122
1123struct Selection {
1129 name: String,
1130 entries: Vec<(String, ValueForm)>,
1131}
1132
1133struct DetectionBlock {
1134 selections: Vec<Selection>,
1135 condition: String,
1136}
1137
1138fn build_detection<E: Event>(
1139 profiles: &[DraftFieldProfile],
1140 selected: &[usize],
1141 exemplars: &[E],
1142 config: &DraftConfig,
1143) -> DetectionBlock {
1144 if let Some(block) = try_group_split(profiles, selected, exemplars, config) {
1148 return block;
1149 }
1150 let entries: Vec<(String, ValueForm)> = selected
1151 .iter()
1152 .filter_map(|&i| {
1153 profiles[i]
1154 .form
1155 .clone()
1156 .map(|f| (profiles[i].field().to_string(), f))
1157 })
1158 .collect();
1159 DetectionBlock {
1160 selections: vec![Selection {
1161 name: "selection".to_string(),
1162 entries,
1163 }],
1164 condition: "selection".to_string(),
1165 }
1166}
1167
1168const MAX_VALUE_GROUPS: usize = 3;
1169
1170fn try_group_split<E: Event>(
1171 profiles: &[DraftFieldProfile],
1172 selected: &[usize],
1173 exemplars: &[E],
1174 config: &DraftConfig,
1175) -> Option<DetectionBlock> {
1176 if selected.len() < 2 || exemplars.len() < 2 {
1177 return None;
1178 }
1179 let (splitter_pos, splitter) = selected.iter().enumerate().find_map(|(pos, &i)| {
1182 let p = &profiles[i];
1183 let d = p.distinct();
1184 let all_str = d.iter().all(|v| matches!(v, DraftValue::Str(_)));
1185 if all_str && d.len() >= 2 && d.len() <= MAX_VALUE_GROUPS {
1186 Some((pos, i))
1187 } else {
1188 None
1189 }
1190 })?;
1191
1192 let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
1194 for (idx, v) in profiles[splitter].values.iter().enumerate() {
1195 let key = v.as_ref()?.as_display();
1196 match groups.iter_mut().find(|(k, _)| *k == key) {
1197 Some((_, members)) => members.push(idx),
1198 None => groups.push((key, vec![idx])),
1199 }
1200 }
1201 if groups.len() < 2 {
1202 return None;
1203 }
1204 if groups.iter().any(|(_, members)| members.len() < 2) {
1207 return None;
1208 }
1209
1210 let improves = selected.iter().enumerate().any(|(pos, &i)| {
1213 if pos == splitter_pos {
1214 return false;
1215 }
1216 let p = &profiles[i];
1217 if p.distinct().len() < 2 {
1218 return false;
1219 }
1220 groups.iter().all(|(_, members)| {
1221 let mut vals = members.iter().filter_map(|&m| p.values[m].as_ref());
1222 let first = vals.next();
1223 first.is_some() && vals.all(|v| Some(v) == first)
1224 })
1225 });
1226 if !improves {
1227 return None;
1228 }
1229
1230 let mut used_names: BTreeMap<String, u32> = BTreeMap::new();
1232 let selections: Vec<Selection> = groups
1233 .iter()
1234 .map(|(key, members)| {
1235 let entries: Vec<(String, ValueForm)> = selected
1236 .iter()
1237 .filter_map(|&i| {
1238 let p = &profiles[i];
1239 let mut distinct: Vec<DraftValue> = Vec::new();
1240 for &m in members {
1241 if let Some(v) = &p.values[m]
1242 && !distinct.contains(v)
1243 {
1244 distinct.push(v.clone());
1245 }
1246 }
1247 derive_form(&distinct, config).map(|f| (p.field().to_string(), f))
1248 })
1249 .collect();
1250 let base = selection_slug(key);
1251 let n = used_names.entry(base.clone()).or_insert(0);
1252 *n += 1;
1253 let name = if *n == 1 {
1254 format!("selection_{base}")
1255 } else {
1256 format!("selection_{base}_{n}")
1257 };
1258 Selection { name, entries }
1259 })
1260 .collect();
1261
1262 Some(DetectionBlock {
1263 selections,
1264 condition: "1 of selection_*".to_string(),
1265 })
1266}
1267
1268fn selection_slug(value: &str) -> String {
1272 let last_segment = value.rsplit(['\\', '/']).next().unwrap_or(value);
1273 let stem = last_segment
1274 .split_once('.')
1275 .map(|(stem, _)| stem)
1276 .unwrap_or(last_segment);
1277 let first_token = stem
1278 .split(|c: char| !c.is_ascii_alphanumeric())
1279 .find(|t| !t.is_empty())
1280 .unwrap_or("");
1281 let out: String = first_token.to_ascii_lowercase();
1282 if out.is_empty() {
1283 "group".to_string()
1284 } else {
1285 out
1286 }
1287}
1288
1289#[derive(Debug, Clone, Default)]
1294struct DraftLogsource {
1295 category: Option<String>,
1296 product: Option<String>,
1297 service: Option<String>,
1298 inferred: bool,
1299}
1300
1301fn sysmon_category(event_id: i64) -> Option<&'static str> {
1303 Some(match event_id {
1304 1 => "process_creation",
1305 3 => "network_connection",
1306 6 => "driver_load",
1307 7 => "image_load",
1308 8 => "create_remote_thread",
1309 10 => "process_access",
1310 11 => "file_event",
1311 22 => "dns_query",
1312 23 => "file_delete",
1313 _ => return None,
1314 })
1315}
1316
1317fn infer_logsource<E: Event>(
1318 exemplars: &[E],
1319 config: &DraftConfig,
1320 warnings: &mut Vec<String>,
1321) -> DraftLogsource {
1322 let mut out = DraftLogsource::default();
1323
1324 let classifier = SchemaClassifier::builtin();
1326 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
1327 for e in exemplars {
1328 if let Some(m) = classifier.classify(e) {
1329 *counts.entry(m.name).or_insert(0) += 1;
1330 }
1331 }
1332 let majority = counts
1333 .iter()
1334 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
1335 .map(|(name, _)| name.as_str());
1336
1337 match majority {
1338 Some("sysmon") => {
1339 out.product = Some("windows".to_string());
1340 let ids: BTreeSet<i64> = exemplars
1343 .iter()
1344 .filter_map(|e| e.get_field("EventID").and_then(|v| v.as_i64()))
1345 .collect();
1346 let category = if ids.len() == 1 {
1347 ids.first().copied().and_then(sysmon_category)
1348 } else {
1349 None
1350 };
1351 match category {
1352 Some(c) => out.category = Some(c.to_string()),
1353 None => out.service = Some("sysmon".to_string()),
1354 }
1355 out.inferred = true;
1356 }
1357 Some("windows_eventlog") | Some("ecs_windows") => {
1358 out.product = Some("windows".to_string());
1359 out.inferred = true;
1360 }
1361 Some("ecs_linux") => {
1362 out.product = Some("linux".to_string());
1363 out.inferred = true;
1364 }
1365 _ => {}
1366 }
1367
1368 if config.logsource_category.is_some() {
1370 out.category = config.logsource_category.clone();
1371 out.inferred = true;
1372 }
1373 if config.logsource_product.is_some() {
1374 out.product = config.logsource_product.clone();
1375 out.inferred = true;
1376 }
1377 if config.logsource_service.is_some() {
1378 out.service = config.logsource_service.clone();
1379 out.inferred = true;
1380 }
1381
1382 if !out.inferred {
1383 warnings.push(
1384 "logsource could not be inferred from the exemplars; \
1385 replace the 'todo' placeholder before committing"
1386 .to_string(),
1387 );
1388 out.product = Some("todo".to_string());
1389 }
1390 out
1391}
1392
1393fn escape_sigma_value(s: &str) -> String {
1404 let chars: Vec<char> = s.chars().collect();
1405 let mut out = String::with_capacity(s.len());
1406 let mut i = 0;
1407 while i < chars.len() {
1408 match chars[i] {
1409 '*' => out.push_str("\\*"),
1410 '?' => out.push_str("\\?"),
1411 '\\' => {
1412 let mut j = i;
1418 while j < chars.len() && chars[j] == '\\' {
1419 j += 1;
1420 }
1421 let run = j - i;
1422 let next = chars.get(j);
1423 let must_escape = run > 1 || matches!(next, Some('*') | Some('?') | None);
1424 for _ in 0..run {
1425 if must_escape {
1426 out.push_str("\\\\");
1427 } else {
1428 out.push('\\');
1429 }
1430 }
1431 i = j;
1432 continue;
1433 }
1434 c => out.push(c),
1435 }
1436 i += 1;
1437 }
1438 out
1439}
1440
1441fn yaml_str(s: &str) -> String {
1448 let bare_safe = !s.is_empty()
1449 && s.chars()
1450 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
1451 && !s.starts_with('-')
1452 && s.parse::<f64>().is_err()
1454 && !matches!(
1455 s.to_ascii_lowercase().as_str(),
1456 "true" | "false" | "null" | "yes" | "no" | "on" | "off"
1457 );
1458 if bare_safe {
1459 s.to_string()
1460 } else {
1461 format!("'{}'", s.replace('\'', "''"))
1462 }
1463}
1464
1465fn yaml_title_str(s: &str) -> String {
1469 let bare_safe = !s.is_empty()
1470 && s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
1471 && !s.ends_with(' ')
1472 && !s.contains(": ")
1473 && !s.contains(" #")
1474 && s.chars().all(|c| {
1475 c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.' | ',' | '(' | ')')
1476 });
1477 if bare_safe {
1478 s.to_string()
1479 } else {
1480 yaml_str(s)
1481 }
1482}
1483
1484fn emit_value(v: &DraftValue) -> String {
1485 match v {
1486 DraftValue::Str(s) => yaml_str(&escape_sigma_value(s)),
1487 DraftValue::Int(n) => n.to_string(),
1488 DraftValue::Float(f) => f.to_string(),
1489 DraftValue::Bool(b) => b.to_string(),
1490 }
1491}
1492
1493fn emit_form(out: &mut String, field: &str, form: &ValueForm, indent: &str) {
1494 let key = format!("{field}{}", form.modifier());
1495 match form {
1496 ValueForm::Exact(v) => {
1497 out.push_str(&format!("{indent}{key}: {}\n", emit_value(v)));
1498 }
1499 ValueForm::OneOf(vs) => {
1500 out.push_str(&format!("{indent}{key}:\n"));
1501 for v in vs {
1502 out.push_str(&format!("{indent} - {}\n", emit_value(v)));
1503 }
1504 }
1505 ValueForm::EndsWith(s) | ValueForm::StartsWith(s) | ValueForm::Contains(s) => {
1506 out.push_str(&format!(
1507 "{indent}{key}: {}\n",
1508 yaml_str(&escape_sigma_value(s))
1509 ));
1510 }
1511 ValueForm::ContainsAll(ts) => {
1512 out.push_str(&format!("{indent}{key}:\n"));
1513 for t in ts {
1514 out.push_str(&format!(
1515 "{indent} - {}\n",
1516 yaml_str(&escape_sigma_value(t))
1517 ));
1518 }
1519 }
1520 }
1521}
1522
1523fn title_marker(profiles: &[DraftFieldProfile], selected: &[usize]) -> Option<String> {
1526 let first = selected.first().map(|&i| &profiles[i])?;
1527 let form = first.form.as_ref()?;
1528 let raw = match form {
1529 ValueForm::Exact(v) => v.as_display(),
1530 ValueForm::OneOf(vs) => vs.first().map(|v| v.as_display()).unwrap_or_default(),
1531 ValueForm::EndsWith(s) | ValueForm::StartsWith(s) | ValueForm::Contains(s) => s.clone(),
1532 ValueForm::ContainsAll(ts) => ts.first().cloned().unwrap_or_default(),
1533 };
1534 let trimmed = raw.trim_matches(|c: char| !c.is_ascii_alphanumeric());
1535 if trimmed.is_empty() {
1536 None
1537 } else {
1538 Some(format!("{trimmed} ({})", first.field()))
1539 }
1540}
1541
1542fn emit_rule_yaml(
1543 profiles: &[DraftFieldProfile],
1544 selected: &[usize],
1545 detection: &DetectionBlock,
1546 logsource: &DraftLogsource,
1547 config: &DraftConfig,
1548) -> String {
1549 let title = config.title.clone().unwrap_or_else(|| {
1550 title_marker(profiles, selected)
1551 .map(|m| format!("Draft: {m}"))
1552 .unwrap_or_else(|| "Draft rule".to_string())
1553 });
1554 let date = config
1555 .date
1556 .clone()
1557 .unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%d").to_string());
1558
1559 let mut out = String::new();
1560 out.push_str(&format!("title: {}\n", yaml_title_str(&title)));
1561 if let Some(id) = &config.rule_id {
1562 out.push_str(&format!("id: {id}\n"));
1563 }
1564 out.push_str("status: experimental\n");
1565 out.push_str("description: 'TODO: describe what this rule detects and why it matters.'\n");
1566 out.push_str("author: 'TODO: your name'\n");
1567 out.push_str(&format!("date: {date}\n"));
1568 out.push_str("logsource:\n");
1569 if let Some(c) = &logsource.category {
1570 out.push_str(&format!(" category: {}\n", yaml_str(c)));
1571 }
1572 if let Some(p) = &logsource.product {
1573 out.push_str(&format!(" product: {}\n", yaml_str(p)));
1574 }
1575 if let Some(s) = &logsource.service {
1576 out.push_str(&format!(" service: {}\n", yaml_str(s)));
1577 }
1578 out.push_str("detection:\n");
1579 for sel in &detection.selections {
1580 out.push_str(&format!(" {}:\n", sel.name));
1581 for (field, form) in &sel.entries {
1582 emit_form(&mut out, field, form, " ");
1583 }
1584 }
1585 out.push_str(&format!(" condition: {}\n", detection.condition));
1586 out.push_str("falsepositives:\n");
1587 out.push_str(" - 'TODO: list known benign triggers.'\n");
1588 out.push_str("level: medium\n");
1589 out
1590}
1591
1592fn compile_draft(yaml: &str) -> Result<Engine, DraftError> {
1597 let collection = rsigma_parser::parse_sigma_yaml(yaml).map_err(|e| DraftError::Internal {
1598 stage: "parse".to_string(),
1599 message: e.to_string(),
1600 })?;
1601 let mut engine = Engine::new();
1602 engine
1603 .add_collection(&collection)
1604 .map_err(|e| DraftError::Internal {
1605 stage: "compile".to_string(),
1606 message: e.to_string(),
1607 })?;
1608 Ok(engine)
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613 use super::*;
1614 use crate::event::JsonEvent;
1615 use serde_json::{Value, json};
1616
1617 fn events(values: &[Value]) -> Vec<JsonEvent<'_>> {
1618 values.iter().map(JsonEvent::borrow).collect()
1619 }
1620
1621 fn fixed_config() -> DraftConfig {
1622 DraftConfig {
1623 rule_id: Some("00000000-0000-4000-8000-000000000000".to_string()),
1624 date: Some("2026-07-03".to_string()),
1625 ..DraftConfig::default()
1626 }
1627 }
1628
1629 fn draft(
1630 exemplars: &[Value],
1631 baseline: &[Value],
1632 config: &DraftConfig,
1633 ) -> Result<DraftReport, DraftError> {
1634 draft_rule(&events(exemplars), &events(baseline), config)
1635 }
1636
1637 #[test]
1640 fn timestamp_names_and_values_are_volatile() {
1641 assert!(is_volatile_name("UtcTime"));
1642 assert!(is_volatile_name("@timestamp"));
1643 assert!(is_volatile_name("event.created_date"));
1644 assert!(is_volatile_value(&DraftValue::Str(
1645 "2026-07-03T12:00:00Z".into()
1646 )));
1647 assert!(is_volatile_value(&DraftValue::Str("2026-07-03".into())));
1648 assert!(!is_volatile_value(&DraftValue::Str("whoami.exe".into())));
1649 }
1650
1651 #[test]
1652 fn uuid_values_and_guid_names_are_volatile() {
1653 assert!(is_volatile_name("ProcessGuid"));
1654 assert!(is_uuid_string("6bde842e-a2f4-441e-b027-3aa79b1b2fc2"));
1655 assert!(is_uuid_string("{6bde842e-a2f4-441e-b027-3aa79b1b2fc2}"));
1656 assert!(!is_uuid_string("not-a-uuid"));
1657 }
1658
1659 #[test]
1660 fn counter_names_and_epoch_values_are_volatile() {
1661 assert!(is_volatile_name("ProcessId"));
1662 assert!(is_volatile_name("Event.System.EventRecordID"));
1663 assert!(is_volatile_name("logon_id"));
1664 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)); }
1668
1669 #[test]
1670 fn time_date_name_match_is_word_bounded() {
1671 assert!(is_volatile_name("EventTime"));
1673 assert!(is_volatile_name("event_date"));
1674 assert!(is_volatile_name("datetime"));
1675 assert!(!is_volatile_name("runtime"));
1678 assert!(!is_volatile_name("update"));
1679 assert!(!is_volatile_name("candidate"));
1680 assert!(!is_volatile_name("CommandLine"));
1681 assert!(!is_volatile_name("validate_action"));
1682 }
1683
1684 #[test]
1685 fn shared_affix_never_splits_a_multibyte_char() {
1686 assert_eq!(
1689 shared_prefix(&["abcé1", "abcè2"], 3).as_deref(),
1690 Some("abc")
1691 );
1692 assert_eq!(shared_prefix(&["abcé1", "abcè2"], 4), None);
1693 assert_eq!(shared_suffix(&["x\u{03a9}", "y\u{00e9}"], 1), None);
1696 assert_eq!(
1698 shared_suffix(&["1éabc", "2éabc"], 3).as_deref(),
1699 Some("éabc")
1700 );
1701 }
1702
1703 #[test]
1704 fn random_unique_values_are_volatile() {
1705 let exemplars: Vec<Value> = (0..4)
1706 .map(|i| {
1707 json!({
1708 "tool": "runner",
1709 "task": "sync",
1710 "token": format!("a9f{i}c2d4e6b8a0f1c3d5e7f9b1a3c5d{i}"),
1711 })
1712 })
1713 .collect();
1714 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1715 let token = report.fields.iter().find(|f| f.field == "token").unwrap();
1716 assert_eq!(token.stability, Stability::Volatile);
1717 assert!(!token.selected);
1718 }
1719
1720 #[test]
1723 fn baseline_contrast_prefers_rare_fields() {
1724 let exemplars: Vec<Value> = (0..3)
1725 .map(|_| json!({"action": "exfil", "proto": "tcp"}))
1726 .collect();
1727 let baseline: Vec<Value> = (0..20)
1729 .map(|i| json!({"action": format!("browse{i}"), "proto": "tcp"}))
1730 .collect();
1731 let report = draft(&exemplars, &baseline, &fixed_config()).unwrap();
1732 let action = report.fields.iter().find(|f| f.field == "action").unwrap();
1733 let proto = report.fields.iter().find(|f| f.field == "proto").unwrap();
1734 assert!(
1735 action.score > proto.score,
1736 "baseline-rare field must outrank the ubiquitous one"
1737 );
1738 assert_eq!(proto.baseline_prevalence, Some(1.0));
1739 assert_eq!(action.baseline_prevalence, Some(0.0));
1740 }
1741
1742 #[test]
1743 fn structural_fields_are_demoted_without_baseline() {
1744 let exemplars: Vec<Value> = (0..3)
1745 .map(|_| json!({"hostname": "web-01", "action": "exfil"}))
1746 .collect();
1747 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1748 let host = report
1749 .fields
1750 .iter()
1751 .find(|f| f.field == "hostname")
1752 .unwrap();
1753 let action = report.fields.iter().find(|f| f.field == "action").unwrap();
1754 assert!(action.score > host.score);
1755 }
1756
1757 #[test]
1758 fn deterministic_output_across_runs() {
1759 let exemplars: Vec<Value> = (0..3)
1760 .map(|_| json!({"vendor": "acme", "action": "alert", "sig": "S-1001"}))
1761 .collect();
1762 let a = draft(&exemplars, &[], &fixed_config()).unwrap().rule_yaml;
1763 let b = draft(&exemplars, &[], &fixed_config()).unwrap().rule_yaml;
1764 assert_eq!(a, b, "draft output must be byte-identical across runs");
1765 }
1766
1767 #[test]
1770 fn shared_path_tail_becomes_endswith() {
1771 let exemplars = vec![
1772 json!({"Image": "C:\\Tools\\whoami.exe", "kind": "proc"}),
1773 json!({"Image": "C:\\Windows\\System32\\whoami.exe", "kind": "proc"}),
1774 json!({"Image": "D:\\stage\\whoami.exe", "kind": "proc"}),
1775 json!({"Image": "E:\\x\\whoami.exe", "kind": "proc"}),
1776 json!({"Image": "F:\\y\\whoami.exe", "kind": "proc"}),
1777 ];
1778 let cfg = DraftConfig {
1779 max_value_cardinality: 3,
1780 ..fixed_config()
1781 };
1782 let report = draft(&exemplars, &[], &cfg).unwrap();
1783 assert!(
1784 report.rule_yaml.contains("Image|endswith: '\\whoami.exe'"),
1785 "expected endswith derivation, got:\n{}",
1786 report.rule_yaml
1787 );
1788 }
1789
1790 #[test]
1791 fn shared_prefix_becomes_startswith() {
1792 let exemplars: Vec<Value> = (0..5)
1793 .map(|i| json!({"url": format!("https://evil.example/payload{i}"), "verb": "GET"}))
1794 .collect();
1795 let cfg = DraftConfig {
1796 max_value_cardinality: 3,
1797 ..fixed_config()
1798 };
1799 let report = draft(&exemplars, &[], &cfg).unwrap();
1800 assert!(
1801 report
1802 .rule_yaml
1803 .contains("url|startswith: 'https://evil.example/payload'"),
1804 "expected startswith derivation, got:\n{}",
1805 report.rule_yaml
1806 );
1807 }
1808
1809 #[test]
1810 fn short_generic_tokens_are_never_chosen() {
1811 let exemplars: Vec<Value> = (0..5)
1813 .map(|i| json!({"cmd": format!("{i}zz run q{i}"), "kind": "x"}))
1814 .collect();
1815 let cfg = DraftConfig {
1816 max_value_cardinality: 3,
1817 ..fixed_config()
1818 };
1819 let report = draft(&exemplars, &[], &cfg).unwrap();
1820 let cmd = report.fields.iter().find(|f| f.field == "cmd").unwrap();
1821 assert_eq!(cmd.stability, Stability::Volatile);
1822 assert!(!report.rule_yaml.contains("cmd|contains"));
1823 }
1824
1825 #[test]
1826 fn baseline_generic_token_is_rejected() {
1827 let exemplars: Vec<Value> = (0..5)
1829 .map(|i| json!({"proc": format!("powershell -x {i}q{i}w{i}"), "kind": "spawn"}))
1830 .collect();
1831 let baseline: Vec<Value> = (0..20)
1832 .map(|i| json!({"proc": format!("powershell -File login{i}.ps1"), "kind": "spawn"}))
1833 .collect();
1834 let cfg = DraftConfig {
1835 max_value_cardinality: 3,
1836 min_fields: 1,
1837 ..fixed_config()
1838 };
1839 let report = draft(&exemplars, &baseline, &cfg).unwrap();
1840 assert!(
1841 !report.rule_yaml.contains("proc|contains: powershell"),
1842 "generic baseline token must be rejected, got:\n{}",
1843 report.rule_yaml
1844 );
1845 }
1846
1847 #[test]
1848 fn wildcard_specials_in_values_are_escaped() {
1849 let exemplars: Vec<Value> = (0..3)
1850 .map(|_| json!({"query": "SELECT * FROM users?", "app": "dbd"}))
1851 .collect();
1852 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1853 assert!(
1854 report.rule_yaml.contains(r"SELECT \* FROM users\?"),
1855 "wildcards must be escaped, got:\n{}",
1856 report.rule_yaml
1857 );
1858 assert_eq!(report.exemplar_matched, 3);
1861 }
1862
1863 #[test]
1864 fn escape_sigma_value_handles_backslash_adjacency() {
1865 assert_eq!(escape_sigma_value(r"C:\Windows"), r"C:\Windows");
1866 assert_eq!(escape_sigma_value("a*b"), r"a\*b");
1867 assert_eq!(escape_sigma_value("a?b"), r"a\?b");
1868 assert_eq!(escape_sigma_value(r"a\*b"), r"a\\\*b");
1869 assert_eq!(escape_sigma_value(r"a\\b"), r"a\\\\b");
1870 assert_eq!(escape_sigma_value(r"trailing\"), r"trailing\\");
1871 }
1872
1873 #[test]
1876 fn distinct_value_groups_split_into_selections() {
1877 let exemplars = vec![
1878 json!({"Image": "C:\\W\\vssadmin.exe", "CommandLine": "vssadmin delete shadows", "k": "p"}),
1879 json!({"Image": "C:\\W\\vssadmin.exe", "CommandLine": "vssadmin delete shadows", "k": "p"}),
1880 json!({"Image": "C:\\W\\wmic.exe", "CommandLine": "wmic shadowcopy delete", "k": "p"}),
1881 json!({"Image": "C:\\W\\wmic.exe", "CommandLine": "wmic shadowcopy delete", "k": "p"}),
1882 ];
1883 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1884 assert!(
1885 report.rule_yaml.contains("condition: 1 of selection_*"),
1886 "expected a group split, got:\n{}",
1887 report.rule_yaml
1888 );
1889 assert!(report.rule_yaml.contains("selection_vssadmin:"));
1890 assert!(report.rule_yaml.contains("selection_wmic:"));
1891 assert_eq!(report.exemplar_matched, 4);
1892 }
1893
1894 #[test]
1895 fn no_split_when_values_do_not_partition() {
1896 let exemplars: Vec<Value> = (0..4)
1897 .map(|_| json!({"vendor": "acme", "action": "alert"}))
1898 .collect();
1899 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1900 assert!(report.rule_yaml.contains("condition: selection\n"));
1901 }
1902
1903 #[test]
1906 fn sysmon_event_id_maps_to_category() {
1907 let exemplars: Vec<Value> = (0..3)
1908 .map(|_| {
1909 json!({
1910 "Channel": "Microsoft-Windows-Sysmon/Operational",
1911 "EventID": 1,
1912 "Image": "C:\\W\\evil.exe",
1913 "CommandLine": "evil.exe --run",
1914 })
1915 })
1916 .collect();
1917 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1918 assert!(report.rule_yaml.contains("category: process_creation"));
1919 assert!(report.rule_yaml.contains("product: windows"));
1920 assert!(!report.rule_yaml.contains("service: sysmon"));
1921 }
1922
1923 #[test]
1924 fn sysmon_without_shared_event_id_keeps_service() {
1925 let exemplars = vec![
1926 json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 1, "Image": "C:\\W\\a.exe", "RuleName": "t"}),
1927 json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 3, "Image": "C:\\W\\a.exe", "RuleName": "t"}),
1928 ];
1929 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1930 assert!(report.rule_yaml.contains("service: sysmon"));
1931 assert!(report.rule_yaml.contains("product: windows"));
1932 }
1933
1934 #[test]
1935 fn logsource_overrides_win() {
1936 let exemplars: Vec<Value> = (0..3)
1937 .map(|_| json!({"vendor": "acme", "action": "alert"}))
1938 .collect();
1939 let cfg = DraftConfig {
1940 logsource_product: Some("acme_fw".to_string()),
1941 logsource_category: Some("firewall".to_string()),
1942 ..fixed_config()
1943 };
1944 let report = draft(&exemplars, &[], &cfg).unwrap();
1945 assert!(report.rule_yaml.contains("product: acme_fw"));
1946 assert!(report.rule_yaml.contains("category: firewall"));
1947 assert!(!report.rule_yaml.contains("todo"));
1948 }
1949
1950 #[test]
1951 fn unknown_schema_gets_todo_placeholder() {
1952 let exemplars: Vec<Value> = (0..3)
1953 .map(|_| json!({"vendor": "acme", "action": "alert"}))
1954 .collect();
1955 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1956 assert!(report.rule_yaml.contains("product: todo"));
1957 assert!(
1958 report
1959 .warnings
1960 .iter()
1961 .any(|w| w.contains("logsource could not be inferred"))
1962 );
1963 }
1964
1965 #[test]
1968 fn draft_round_trips_and_matches_exemplars() {
1969 let exemplars: Vec<Value> = (0..4)
1970 .map(|_| json!({"vendor": "acme", "action": "exfil", "dst_port": 443}))
1971 .collect();
1972 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1973 let collection =
1976 rsigma_parser::parse_sigma_yaml(&report.rule_yaml).expect("emitted draft must parse");
1977 let mut engine = Engine::new();
1978 engine.add_collection(&collection).unwrap();
1979 for e in &events(&exemplars) {
1980 assert!(!engine.evaluate(e).is_empty(), "exemplar must match");
1981 }
1982 assert_eq!(report.exemplar_matched, report.exemplar_total);
1983 assert!(
1984 report
1985 .rule_yaml
1986 .contains("id: 00000000-0000-4000-8000-000000000000")
1987 );
1988 assert!(report.rule_yaml.contains("status: experimental"));
1989 assert!(report.rule_yaml.contains("level: medium"));
1990 assert!(report.rule_yaml.contains("date: 2026-07-03"));
1991 }
1992
1993 #[test]
1994 fn typed_values_emit_as_numbers() {
1995 let exemplars: Vec<Value> = (0..3)
1996 .map(|_| json!({"vendor": "acme", "code": 4688}))
1997 .collect();
1998 let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1999 assert!(
2000 report.rule_yaml.contains("code: 4688"),
2001 "integers must emit bare, got:\n{}",
2002 report.rule_yaml
2003 );
2004 }
2005
2006 #[test]
2007 fn baseline_hits_are_counted_with_rate() {
2008 let exemplars: Vec<Value> = (0..3)
2009 .map(|_| json!({"vendor": "acme", "action": "alert"}))
2010 .collect();
2011 let mut baseline: Vec<Value> = (0..8)
2012 .map(|i| json!({"vendor": "other", "action": format!("a{i}")}))
2013 .collect();
2014 baseline.push(json!({"vendor": "acme", "action": "alert"}));
2016 baseline.push(json!({"vendor": "acme", "action": "alert"}));
2017 let report = draft(&exemplars, &baseline, &fixed_config()).unwrap();
2018 assert_eq!(report.baseline_total, 10);
2019 assert_eq!(report.baseline_hits, Some(2));
2020 assert!((report.baseline_hit_rate.unwrap() - 0.2).abs() < 1e-9);
2021 assert!(report.warnings.iter().any(|w| w.contains("baseline")));
2022 }
2023
2024 #[test]
2025 fn skip_baseline_eval_keeps_scoring_but_not_hits() {
2026 let exemplars: Vec<Value> = (0..3)
2027 .map(|_| json!({"vendor": "acme", "action": "alert"}))
2028 .collect();
2029 let baseline: Vec<Value> = (0..5)
2030 .map(|i| json!({"vendor": "other", "action": format!("a{i}")}))
2031 .collect();
2032 let cfg = DraftConfig {
2033 evaluate_baseline: false,
2034 ..fixed_config()
2035 };
2036 let report = draft(&exemplars, &baseline, &cfg).unwrap();
2037 assert_eq!(report.baseline_hits, None);
2038 assert!(
2039 report
2040 .fields
2041 .iter()
2042 .any(|f| f.baseline_prevalence.is_some()),
2043 "contrastive scoring still uses the baseline"
2044 );
2045 }
2046
2047 #[test]
2050 fn relaxation_drops_partial_prevalence_fields() {
2051 let mut exemplars: Vec<Value> = (0..2)
2054 .map(|_| json!({"vendor": "acme", "action": "alert", "extra": "x"}))
2055 .collect();
2056 exemplars.extend((0..2).map(|_| json!({"vendor": "acme", "action": "alert"})));
2057 let cfg = DraftConfig {
2058 min_prevalence: 0.4,
2059 ..fixed_config()
2060 };
2061 let report = draft(&exemplars, &[], &cfg).unwrap();
2062 assert_eq!(report.exemplar_matched, 4);
2063 assert!(!report.rule_yaml.contains("extra"));
2064 assert!(report.warnings.iter().any(|w| w.contains("relaxed")));
2065 }
2066
2067 #[test]
2068 fn floor_errors_instead_of_emitting_overbroad_draft() {
2069 let mut exemplars: Vec<Value> = (0..2)
2072 .map(|_| json!({"alpha": "one", "beta": "x"}))
2073 .collect();
2074 exemplars.extend((0..2).map(|_| json!({"alpha": "two", "gamma": "y"})));
2075 let cfg = DraftConfig {
2076 min_prevalence: 0.4,
2077 min_fields: 2,
2078 max_value_cardinality: 1,
2079 ..fixed_config()
2080 };
2081 let err = draft(&exemplars, &[], &cfg).unwrap_err();
2082 assert!(
2083 matches!(err, DraftError::CannotMatchExemplars { floor: 2, .. }),
2084 "expected the floor error, got: {err}"
2085 );
2086 }
2087
2088 #[test]
2089 fn forced_field_absent_from_exemplars_errors_immediately() {
2090 let mut exemplars: Vec<Value> = (0..2)
2094 .map(|_| json!({"vendor": "acme", "action": "alert", "extra": "x"}))
2095 .collect();
2096 exemplars.extend((0..2).map(|_| json!({"vendor": "acme", "action": "alert"})));
2097 let cfg = DraftConfig {
2098 include_fields: vec!["extra".to_string()],
2099 min_prevalence: 0.4,
2100 ..fixed_config()
2101 };
2102 let err = draft(&exemplars, &[], &cfg).unwrap_err();
2103 match err {
2104 DraftError::ForcedFieldMismatch { fields, failing } => {
2105 assert_eq!(fields, vec!["extra".to_string()]);
2106 assert_eq!(failing, vec![2, 3]);
2107 }
2108 other => panic!("expected ForcedFieldMismatch, got: {other}"),
2109 }
2110 }
2111
2112 #[test]
2113 fn no_exemplars_is_an_error() {
2114 let err = draft(&[], &[], &fixed_config()).unwrap_err();
2115 assert!(matches!(err, DraftError::NoExemplars));
2116 }
2117
2118 #[test]
2119 fn all_volatile_fields_is_an_error() {
2120 let exemplars: Vec<Value> = (0..3)
2121 .map(|i| {
2122 json!({
2123 "UtcTime": format!("2026-07-03T12:00:0{i}Z"),
2124 "ProcessGuid": format!("6bde842e-a2f4-441e-b027-3aa79b1b2fc{i}"),
2125 })
2126 })
2127 .collect();
2128 let err = draft(&exemplars, &[], &fixed_config()).unwrap_err();
2129 assert!(matches!(err, DraftError::NoCandidateFields(3)));
2130 }
2131
2132 #[test]
2135 fn include_and_exclude_fields_are_honored() {
2136 let exemplars: Vec<Value> = (0..3)
2137 .map(|_| json!({"vendor": "acme", "action": "alert", "noise": "same"}))
2138 .collect();
2139 let cfg = DraftConfig {
2140 include_fields: vec!["noise".to_string()],
2141 exclude_fields: vec!["vendor".to_string()],
2142 max_fields: 2,
2143 ..fixed_config()
2144 };
2145 let report = draft(&exemplars, &[], &cfg).unwrap();
2146 assert!(report.rule_yaml.contains("noise: same"));
2147 assert!(!report.rule_yaml.contains("vendor"));
2148 }
2149
2150 #[test]
2151 fn title_override_and_derived_title() {
2152 let exemplars: Vec<Value> = (0..3)
2153 .map(|_| json!({"vendor": "acme", "action": "alert"}))
2154 .collect();
2155 let derived = draft(&exemplars, &[], &fixed_config()).unwrap();
2156 assert!(
2157 derived.rule_yaml.starts_with("title: 'Draft:")
2158 || derived.rule_yaml.starts_with("title: Draft"),
2159 "derived title expected, got:\n{}",
2160 derived.rule_yaml
2161 );
2162 let cfg = DraftConfig {
2163 title: Some("Acme Exfil Detection".to_string()),
2164 ..fixed_config()
2165 };
2166 let titled = draft(&exemplars, &[], &cfg).unwrap();
2167 assert!(titled.rule_yaml.starts_with("title: Acme Exfil Detection"));
2168 }
2169}