Skip to main content

rsigma_eval/
rule_draft.rs

1//! Rule drafting: turn exemplar events into a draft Sigma detection rule.
2//!
3//! The operator feeds exemplar events (the malicious or noteworthy ones),
4//! optionally contrasted against a baseline corpus of normal traffic. This
5//! module profiles every field across the exemplars, drops volatile fields
6//! (timestamps, GUIDs, counters, high-entropy uniques), scores the rest by
7//! stability across exemplars times rarity in the baseline, infers a value
8//! form and a small Sigma modifier vocabulary per field, assembles a minimal
9//! selection, and emits a complete draft rule as standard Sigma YAML.
10//!
11//! The draft is verified end-to-end before it is returned: the emitted YAML is
12//! parsed via [`rsigma_parser::parse_sigma_yaml`] and compiled into the real
13//! [`Engine`], every exemplar must match (with a bounded
14//! predicate-drop relaxation and a minimum-field floor that errors instead of
15//! emitting an over-broad draft), and the baseline hit count and rate are
16//! recorded as the draft's estimated false-positive rate.
17//!
18//! The core is pure and deterministic: no randomness (the rule `id` is
19//! caller-supplied; the CLI generates a UUIDv4), and repeated runs over the
20//! same input yield byte-identical YAML. The draft uses the exemplars' native
21//! field names, so it must be evaluated without a mapping pipeline.
22//!
23//! This is the detection-authoring sibling of
24//! [`schema_discovery`](crate::schema_discovery): discovery mines unrecognized
25//! events into schema signatures, drafting mines exemplar events into a
26//! detection rule. Both follow the same contract: the tool proposes, a human
27//! reviews and commits.
28
29use 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// =============================================================================
43// Configuration
44// =============================================================================
45
46/// Tunables for a draft run. [`Default`] is a sensible starting point; the CLI
47/// exposes each as a flag.
48#[derive(Debug, Clone)]
49pub struct DraftConfig {
50    /// Maximum fields in a selection. Kept small so drafts stay readable.
51    pub max_fields: usize,
52    /// Relaxation floor: verification may drop failing fields down to this
53    /// count, below it drafting errors instead of emitting an over-broad rule.
54    pub min_fields: usize,
55    /// Fraction (0.0-1.0) of exemplars a field must appear in to be a
56    /// candidate. The default 1.0 keeps AND-selections sound.
57    pub min_prevalence: f64,
58    /// A field whose distinct exemplar values do not exceed this cap is
59    /// "enumerable" and emitted as an OR value list.
60    pub max_value_cardinality: usize,
61    /// Minimum length of a shared prefix/suffix/token before it becomes a
62    /// `startswith`/`endswith`/`contains` pattern, so short generic fragments
63    /// are never chosen.
64    pub min_token_len: usize,
65    /// A `contains` token matching more than this fraction of baseline events
66    /// is rejected as too generic.
67    pub max_baseline_token_prevalence: f64,
68    /// Force these fields into the selection (a warning is recorded when a
69    /// forced field is absent from some exemplars).
70    pub include_fields: Vec<String>,
71    /// Never consider these fields.
72    pub exclude_fields: Vec<String>,
73    /// Rule title override; derived from the dominant marker when unset.
74    pub title: Option<String>,
75    /// Rule `id`. The core is deterministic and never generates one; the CLI
76    /// passes a fresh UUIDv4. Unset omits the `id` key (lint reports it).
77    pub rule_id: Option<String>,
78    /// Rule `date` (YYYY-MM-DD). Defaults to today (UTC) when unset; tests
79    /// pass a fixed date for byte-identical output.
80    pub date: Option<String>,
81    /// Logsource overrides; each set dimension wins over inference.
82    pub logsource_category: Option<String>,
83    pub logsource_product: Option<String>,
84    pub logsource_service: Option<String>,
85    /// Evaluate the final draft against the baseline (the baseline is still
86    /// used for contrastive scoring when this is off).
87    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// =============================================================================
113// Errors
114// =============================================================================
115
116/// Why a draft could not be produced.
117#[derive(Debug, thiserror::Error)]
118pub enum DraftError {
119    /// No exemplar events were provided.
120    #[error("no exemplar events to draft from")]
121    NoExemplars,
122    /// No field survived profiling (all volatile, excluded, or below the
123    /// prevalence threshold).
124    #[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    /// Even after relaxing to the minimum-field floor the draft does not match
130    /// every exemplar, so an honest rule cannot be emitted.
131    #[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    /// A field forced via `include_fields` is absent from some exemplars, so
142    /// no draft containing it can match them. Forced fields are user intent
143    /// and are never dropped by relaxation.
144    #[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    /// The emitted YAML failed to parse or compile (a bug, surfaced honestly).
153    #[error("internal error: emitted draft failed to {stage}: {message}")]
154    Internal { stage: String, message: String },
155}
156
157// =============================================================================
158// Public report types
159// =============================================================================
160
161/// How a field's values behave across the exemplars.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
163#[serde(rename_all = "snake_case")]
164pub enum Stability {
165    /// The same value in every exemplar.
166    Constant,
167    /// A small distinct value set (an OR list).
168    Enumerable,
169    /// Differing values sharing a prefix, suffix, or token.
170    Patterned,
171    /// No usable structure; never selected.
172    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/// One profiled field in the report, ranked.
188#[derive(Debug, Clone, Serialize)]
189pub struct DraftFieldReport {
190    /// Dot-joined field path.
191    pub field: String,
192    /// Contrastive score used for ranking (higher is better).
193    pub score: f64,
194    /// Value-stability class across the exemplars.
195    pub stability: Stability,
196    /// Sigma modifier chain chosen for the field (empty for a plain match).
197    pub modifier: String,
198    /// Display values or derived pattern, capped.
199    pub values: Vec<String>,
200    /// Fraction of baseline events this field's value form matches, when a
201    /// baseline was provided.
202    pub baseline_prevalence: Option<f64>,
203    /// Whether the field made it into the final selection.
204    pub selected: bool,
205}
206
207/// The result of a draft run: the rule plus the evidence behind it.
208#[derive(Debug, Clone)]
209pub struct DraftReport {
210    /// The complete draft rule, standard Sigma YAML, parse- and lint-checked.
211    pub rule_yaml: String,
212    /// Profiled candidate fields, ranked (selected fields first).
213    pub fields: Vec<DraftFieldReport>,
214    /// Number of exemplar events.
215    pub exemplar_total: usize,
216    /// Exemplars the final draft matches (always equals `exemplar_total`; a
217    /// draft that cannot match every exemplar is an error, not a result).
218    pub exemplar_matched: usize,
219    /// Number of baseline events provided.
220    pub baseline_total: usize,
221    /// Baseline events the draft matches (its estimated false-positive count),
222    /// when the baseline evaluation ran.
223    pub baseline_hits: Option<usize>,
224    /// `baseline_hits / baseline_total` (0.0-1.0), when computed.
225    pub baseline_hit_rate: Option<f64>,
226    /// Advisory notes: lint findings, relaxation drops, inference caveats.
227    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
344// =============================================================================
345// Entry point
346// =============================================================================
347
348/// Draft a Sigma detection rule from exemplar events, optionally contrasted
349/// against a baseline corpus (pass an empty slice for no baseline).
350///
351/// The returned draft is guaranteed to parse, compile, and match every
352/// exemplar; drafting errors instead of emitting a rule that does not.
353pub 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    // ---- Emit + verify (bounded relaxation) --------------------------------
362    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        // A field is a provable culprit when it is absent from a failing
376        // exemplar (the common case: partial-prevalence fields admitted by
377        // `min_prevalence`).
378        let absent_in_failing = |i: usize| {
379            failing
380                .iter()
381                .any(|&idx| candidate.profiles[i].values[idx].is_none())
382        };
383
384        // A forced field that provably breaks the match is a user decision we
385        // refuse to override; dropping other fields could never fix it, so
386        // error out immediately with the culprit named.
387        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        // Drop the lowest-ranked non-forced culprit; when no field is provably
410        // at fault (a value-form edge case), shed the weakest non-forced field.
411        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    // ---- Baseline hits ------------------------------------------------------
427    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    // ---- Lint ----------------------------------------------------------------
447    for w in rsigma_parser::lint_yaml_str(&yaml) {
448        candidate
449            .warnings
450            .push(format!("lint {}: {}", w.rule, w.message));
451    }
452
453    // ---- Report ---------------------------------------------------------------
454    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
496// =============================================================================
497// Selection assembly and grouping
498// =============================================================================
499
500/// One named selection: field, modifier chain, and values in emission order.
501struct 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    // Try a value-group split: partition exemplars by the highest-ranked
518    // selected field with a small distinct value set, and split only when it
519    // makes another multi-valued field single-valued in every partition.
520    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    // Splitter: first selected field (rank order) with 2..=MAX_VALUE_GROUPS
553    // distinct string values.
554    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    // Partition exemplar indexes by the splitter value, in first-seen order.
566    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    // Every group needs real support; splitting one exemplar per selection
578    // would just memorize the input.
579    if groups.iter().any(|(_, members)| members.len() < 2) {
580        return None;
581    }
582
583    // The split must earn its keep: some other selected field is multi-valued
584    // globally but single-valued within every partition.
585    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    // Build one selection per group, deriving per-group forms.
604    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
641/// A short selection-name suffix from a splitter value: the first token of the
642/// last path segment's stem (`C:\W\vssadmin.exe` and `vssadmin delete shadows`
643/// both slug to `vssadmin`), lowercased.
644fn 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// =============================================================================
663// Logsource inference
664// =============================================================================
665
666#[derive(Debug, Clone, Default)]
667struct DraftLogsource {
668    category: Option<String>,
669    product: Option<String>,
670    service: Option<String>,
671    inferred: bool,
672}
673
674/// Sysmon EventID to Sigma category, for the unambiguous mappings only.
675fn 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    // Majority schema over the exemplars.
698    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            // One shared EventID across all exemplars maps to a category and
714            // drops the service (Sigma sysmon rules use category + product).
715            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    // Overrides win per dimension.
742    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
766/// A short human marker for the title, from the dominant (first selected)
767/// field's form.
768fn 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
839// =============================================================================
840// Verification
841// =============================================================================
842
843fn 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    // ---- Volatility heuristics ---------------------------------------------
885
886    #[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)); // seconds
912        assert!(is_epoch_number(1_751_500_000_000.0)); // milliseconds
913        assert!(!is_epoch_number(4688.0)); // an EventID is not an epoch
914    }
915
916    #[test]
917    fn time_date_name_match_is_word_bounded() {
918        // Real timestamp fields are volatile...
919        assert!(is_volatile_name("EventTime"));
920        assert!(is_volatile_name("event_date"));
921        assert!(is_volatile_name("datetime"));
922        // ...but content fields that merely contain "time"/"date" as a
923        // substring are not (regression: substring matching dropped these).
924        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        // The common byte run ends inside 'é' (both é and è start 0xC3), so the
934        // prefix must snap to a char boundary rather than panic on the slice.
935        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        // Suffix: 'Ω' and 'é' both end in the continuation byte 0xA9, so the
941        // overlap starts mid-character; snapping up yields no suffix and no panic.
942        assert_eq!(shared_suffix(&["x\u{03a9}", "y\u{00e9}"], 1), None);
943        // A fully-shared multibyte affix is preserved intact.
944        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    // ---- Scoring -------------------------------------------------------------
968
969    #[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        // proto: tcp is ubiquitous in the baseline; action: exfil never occurs.
975        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    // ---- Modifier inference ---------------------------------------------------
1063
1064    #[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        // The only shared token is 3 chars ("run"), below min_token_len 4.
1107        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        // "powershell" is a stable exemplar token but ubiquitous in baseline.
1123        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        // And the escaped rule still matches the exemplars end-to-end (the
1154        // verification loop enforces this; assert the report agrees).
1155        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    // ---- Grouping ----------------------------------------------------------------
1169
1170    #[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    // ---- Logsource -------------------------------------------------------------
1199
1200    #[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    // ---- Emission, round-trip, verification -----------------------------------
1261
1262    #[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        // Parses and compiles (draft_rule already enforced it; do it again
1269        // from the public surface).
1270        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        // Two baseline events the draft will also match.
1310        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    // ---- Relaxation and error paths ------------------------------------------
1343
1344    #[test]
1345    fn relaxation_drops_partial_prevalence_fields() {
1346        // "extra" appears in half the exemplars; selecting it breaks the AND
1347        // selection, so verification must drop it and still succeed.
1348        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        // Two disjoint half-prevalence fields and nothing else: no 2-field AND
1365        // can match every exemplar, and the floor forbids going below 2.
1366        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        // "extra" is forced but absent from half the exemplars: relaxation
1386        // must not strip the useful fields around it, it must name the
1387        // culprit and stop.
1388        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    // ---- Flags -------------------------------------------------------------------
1428
1429    #[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}