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(crate) mod draft_core;
39use draft_core::*;
40
41// =============================================================================
42// Configuration
43// =============================================================================
44
45/// Tunables for a draft run. [`Default`] is a sensible starting point; the CLI
46/// exposes each as a flag.
47#[derive(Debug, Clone)]
48pub struct DraftConfig {
49    /// Maximum fields in a selection. Kept small so drafts stay readable.
50    pub max_fields: usize,
51    /// Relaxation floor: verification may drop failing fields down to this
52    /// count, below it drafting errors instead of emitting an over-broad rule.
53    pub min_fields: usize,
54    /// Fraction (0.0-1.0) of exemplars a field must appear in to be a
55    /// candidate. The default 1.0 keeps AND-selections sound.
56    pub min_prevalence: f64,
57    /// A field whose distinct exemplar values do not exceed this cap is
58    /// "enumerable" and emitted as an OR value list.
59    pub max_value_cardinality: usize,
60    /// Minimum length of a shared prefix/suffix/token before it becomes a
61    /// `startswith`/`endswith`/`contains` pattern, so short generic fragments
62    /// are never chosen.
63    pub min_token_len: usize,
64    /// A `contains` token matching more than this fraction of baseline events
65    /// is rejected as too generic.
66    pub max_baseline_token_prevalence: f64,
67    /// Force these fields into the selection (a warning is recorded when a
68    /// forced field is absent from some exemplars).
69    pub include_fields: Vec<String>,
70    /// Never consider these fields.
71    pub exclude_fields: Vec<String>,
72    /// Rule title override; derived from the dominant marker when unset.
73    pub title: Option<String>,
74    /// Rule `id`. The core is deterministic and never generates one; the CLI
75    /// passes a fresh UUIDv4. Unset omits the `id` key (lint reports it).
76    pub rule_id: Option<String>,
77    /// Rule `date` (YYYY-MM-DD). Defaults to today (UTC) when unset; tests
78    /// pass a fixed date for byte-identical output.
79    pub date: Option<String>,
80    /// Logsource overrides; each set dimension wins over inference.
81    pub logsource_category: Option<String>,
82    pub logsource_product: Option<String>,
83    pub logsource_service: Option<String>,
84    /// Evaluate the final draft against the baseline (the baseline is still
85    /// used for contrastive scoring when this is off).
86    pub evaluate_baseline: bool,
87}
88
89impl Default for DraftConfig {
90    fn default() -> Self {
91        Self {
92            max_fields: 4,
93            min_fields: 2,
94            min_prevalence: 1.0,
95            max_value_cardinality: 4,
96            min_token_len: 4,
97            max_baseline_token_prevalence: 0.05,
98            include_fields: Vec::new(),
99            exclude_fields: Vec::new(),
100            title: None,
101            rule_id: None,
102            date: None,
103            logsource_category: None,
104            logsource_product: None,
105            logsource_service: None,
106            evaluate_baseline: true,
107        }
108    }
109}
110
111// =============================================================================
112// Errors
113// =============================================================================
114
115/// Why a draft could not be produced.
116#[derive(Debug, thiserror::Error)]
117pub enum DraftError {
118    /// No exemplar events were provided.
119    #[error("no exemplar events to draft from")]
120    NoExemplars,
121    /// No field survived profiling (all volatile, excluded, or below the
122    /// prevalence threshold).
123    #[error(
124        "no candidate fields: every field was volatile (timestamps, ids, unique values), \
125         excluded, or below the prevalence threshold ({0} exemplars profiled)"
126    )]
127    NoCandidateFields(usize),
128    /// Even after relaxing to the minimum-field floor the draft does not match
129    /// every exemplar, so an honest rule cannot be emitted.
130    #[error(
131        "draft cannot match all exemplars: {matched}/{total} match at the {floor}-field floor; \
132         exemplars may be too heterogeneous for one rule (failing exemplar indexes: {failing:?})"
133    )]
134    CannotMatchExemplars {
135        matched: usize,
136        total: usize,
137        floor: usize,
138        failing: Vec<usize>,
139    },
140    /// A field forced via `include_fields` is absent from some exemplars, so
141    /// no draft containing it can match them. Forced fields are user intent
142    /// and are never dropped by relaxation.
143    #[error(
144        "forced field(s) {fields:?} are absent from exemplar(s) {failing:?}; \
145         remove the --include-field or drop those exemplars"
146    )]
147    ForcedFieldMismatch {
148        fields: Vec<String>,
149        failing: Vec<usize>,
150    },
151    /// The emitted YAML failed to parse or compile (a bug, surfaced honestly).
152    #[error("internal error: emitted draft failed to {stage}: {message}")]
153    Internal { stage: String, message: String },
154}
155
156// =============================================================================
157// Public report types
158// =============================================================================
159
160/// How a field's values behave across the exemplars.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
162#[serde(rename_all = "snake_case")]
163pub enum Stability {
164    /// The same value in every exemplar.
165    Constant,
166    /// A small distinct value set (an OR list).
167    Enumerable,
168    /// Differing values sharing a prefix, suffix, or token.
169    Patterned,
170    /// No usable structure; never selected.
171    Volatile,
172}
173
174impl fmt::Display for Stability {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        let s = match self {
177            Stability::Constant => "constant",
178            Stability::Enumerable => "enumerable",
179            Stability::Patterned => "patterned",
180            Stability::Volatile => "volatile",
181        };
182        f.write_str(s)
183    }
184}
185
186/// One profiled field in the report, ranked.
187#[derive(Debug, Clone, Serialize)]
188pub struct DraftFieldReport {
189    /// Dot-joined field path.
190    pub field: String,
191    /// Contrastive score used for ranking (higher is better).
192    pub score: f64,
193    /// Value-stability class across the exemplars.
194    pub stability: Stability,
195    /// Sigma modifier chain chosen for the field (empty for a plain match).
196    pub modifier: String,
197    /// Display values or derived pattern, capped.
198    pub values: Vec<String>,
199    /// Fraction of baseline events this field's value form matches, when a
200    /// baseline was provided.
201    pub baseline_prevalence: Option<f64>,
202    /// Whether the field made it into the final selection.
203    pub selected: bool,
204}
205
206/// The result of a draft run: the rule plus the evidence behind it.
207#[derive(Debug, Clone)]
208pub struct DraftReport {
209    /// The complete draft rule, standard Sigma YAML, parse- and lint-checked.
210    pub rule_yaml: String,
211    /// Profiled candidate fields, ranked (selected fields first).
212    pub fields: Vec<DraftFieldReport>,
213    /// Number of exemplar events.
214    pub exemplar_total: usize,
215    /// Exemplars the final draft matches (always equals `exemplar_total`; a
216    /// draft that cannot match every exemplar is an error, not a result).
217    pub exemplar_matched: usize,
218    /// Number of baseline events provided.
219    pub baseline_total: usize,
220    /// Baseline events the draft matches (its estimated false-positive count),
221    /// when the baseline evaluation ran.
222    pub baseline_hits: Option<usize>,
223    /// `baseline_hits / baseline_total` (0.0-1.0), when computed.
224    pub baseline_hit_rate: Option<f64>,
225    /// Advisory notes: lint findings, relaxation drops, inference caveats.
226    pub warnings: Vec<String>,
227}
228
229// =============================================================================
230// Entry point
231// =============================================================================
232
233/// Draft a Sigma detection rule from exemplar events, optionally contrasted
234/// against a baseline corpus (pass an empty slice for no baseline).
235///
236/// The returned draft is guaranteed to parse, compile, and match every
237/// exemplar; drafting errors instead of emitting a rule that does not.
238pub fn draft_rule<E: Event>(
239    exemplars: &[E],
240    baseline: &[E],
241    config: &DraftConfig,
242) -> Result<DraftReport, DraftError> {
243    if exemplars.is_empty() {
244        return Err(DraftError::NoExemplars);
245    }
246    let mut warnings: Vec<String> = Vec::new();
247
248    // ---- Profile ----------------------------------------------------------
249    let mut profiles = profile_fields(exemplars, config, &mut warnings);
250    if profiles.is_empty() {
251        return Err(DraftError::NoCandidateFields(exemplars.len()));
252    }
253
254    // ---- Infer value forms -------------------------------------------------
255    for p in &mut profiles {
256        infer_form(p, config);
257    }
258
259    // ---- Baseline prevalence + token guard ---------------------------------
260    if !baseline.is_empty() {
261        for p in &mut profiles {
262            apply_baseline(p, baseline, config);
263        }
264    }
265
266    // ---- Score -------------------------------------------------------------
267    let has_baseline = !baseline.is_empty();
268    for p in &mut profiles {
269        p.score = score_field(p, has_baseline);
270    }
271    // Rank: forced first, then score descending, then field name ascending
272    // (the deterministic tie-break).
273    profiles.sort_by(|a, b| {
274        b.forced
275            .cmp(&a.forced)
276            .then_with(|| {
277                b.score
278                    .partial_cmp(&a.score)
279                    .unwrap_or(std::cmp::Ordering::Equal)
280            })
281            .then_with(|| a.field().cmp(b.field()))
282    });
283
284    // ---- Select ------------------------------------------------------------
285    let usable: Vec<usize> = profiles
286        .iter()
287        .enumerate()
288        .filter(|(_, p)| p.form.is_some() && p.stability != Stability::Volatile)
289        .map(|(i, _)| i)
290        .collect();
291    if usable.is_empty() {
292        return Err(DraftError::NoCandidateFields(exemplars.len()));
293    }
294    let mut selected: Vec<usize> = usable.iter().copied().take(config.max_fields).collect();
295    if selected.len() < config.min_fields {
296        warnings.push(format!(
297            "only {} usable field(s) found (floor is {}); the draft may be broad",
298            selected.len(),
299            config.min_fields
300        ));
301    }
302
303    // ---- Logsource ---------------------------------------------------------
304    let logsource = infer_logsource(exemplars, config, &mut warnings);
305
306    // ---- Emit + verify (bounded relaxation) --------------------------------
307    let floor = config.min_fields.min(selected.len()).max(1);
308    let (yaml, matched, failing) = loop {
309        let detection = build_detection(&profiles, &selected, exemplars, config);
310        let yaml = emit_rule_yaml(&profiles, &selected, &detection, &logsource, config);
311        let engine = compile_draft(&yaml)?;
312        let failing: Vec<usize> = exemplars
313            .iter()
314            .enumerate()
315            .filter(|(_, e)| engine.evaluate(e).is_empty())
316            .map(|(i, _)| i)
317            .collect();
318        if failing.is_empty() {
319            break (yaml, exemplars.len(), failing);
320        }
321
322        // A field is a provable culprit when it is absent from a failing
323        // exemplar (the common case: partial-prevalence fields admitted by
324        // `min_prevalence`).
325        let absent_in_failing =
326            |i: usize| failing.iter().any(|&idx| profiles[i].values[idx].is_none());
327
328        // A forced field that provably breaks the match is a user decision we
329        // refuse to override; dropping other fields could never fix it, so
330        // error out immediately with the culprit named.
331        let forced_culprits: Vec<String> = selected
332            .iter()
333            .filter(|&&i| profiles[i].forced && absent_in_failing(i))
334            .map(|&i| profiles[i].field().to_string())
335            .collect();
336        if !forced_culprits.is_empty() {
337            return Err(DraftError::ForcedFieldMismatch {
338                fields: forced_culprits,
339                failing,
340            });
341        }
342
343        if selected.len() <= floor {
344            return Err(DraftError::CannotMatchExemplars {
345                matched: exemplars.len() - failing.len(),
346                total: exemplars.len(),
347                floor,
348                failing,
349            });
350        }
351
352        // Drop the lowest-ranked non-forced culprit; when no field is provably
353        // at fault (a value-form edge case), shed the weakest non-forced field.
354        let drop_pos = selected
355            .iter()
356            .rposition(|&i| !profiles[i].forced && absent_in_failing(i))
357            .or_else(|| selected.iter().rposition(|&i| !profiles[i].forced));
358        let Some(pos) = drop_pos else {
359            return Err(DraftError::CannotMatchExemplars {
360                matched: exemplars.len() - failing.len(),
361                total: exemplars.len(),
362                floor,
363                failing,
364            });
365        };
366        let dropped = selected.remove(pos);
367        warnings.push(format!(
368            "relaxed: dropped field '{}' because the draft did not match every exemplar with it",
369            profiles[dropped].field()
370        ));
371    };
372    debug_assert!(failing.is_empty());
373
374    // ---- Baseline hits ------------------------------------------------------
375    let (baseline_hits, baseline_hit_rate) = if !baseline.is_empty() && config.evaluate_baseline {
376        let engine = compile_draft(&yaml)?;
377        let hits = baseline
378            .iter()
379            .filter(|e| !engine.evaluate(e).is_empty())
380            .count();
381        let rate = hits as f64 / baseline.len() as f64;
382        if hits > 0 {
383            warnings.push(format!(
384                "draft matches {hits}/{} baseline events ({:.1}%); consider a tighter field",
385                baseline.len(),
386                rate * 100.0
387            ));
388        }
389        (Some(hits), Some(rate))
390    } else {
391        (None, None)
392    };
393
394    // ---- Lint ----------------------------------------------------------------
395    for w in rsigma_parser::lint_yaml_str(&yaml) {
396        warnings.push(format!("lint {}: {}", w.rule, w.message));
397    }
398
399    // ---- Report ---------------------------------------------------------------
400    let selected_set: BTreeSet<usize> = selected.iter().copied().collect();
401    let fields = profiles
402        .iter()
403        .enumerate()
404        .map(|(i, p)| DraftFieldReport {
405            field: p.field().to_string(),
406            score: p.score,
407            stability: p.stability,
408            modifier: p
409                .form
410                .as_ref()
411                .map(|f| f.modifier().trim_start_matches('|').to_string())
412                .unwrap_or_default(),
413            values: p
414                .form
415                .as_ref()
416                .map(|f| f.display_values())
417                .unwrap_or_else(|| {
418                    p.distinct()
419                        .into_iter()
420                        .take(4)
421                        .map(|v| v.as_display())
422                        .collect()
423                }),
424            baseline_prevalence: p.baseline_prevalence,
425            selected: selected_set.contains(&i),
426        })
427        .collect();
428
429    Ok(DraftReport {
430        rule_yaml: yaml,
431        fields,
432        exemplar_total: exemplars.len(),
433        exemplar_matched: matched,
434        baseline_total: baseline.len(),
435        baseline_hits,
436        baseline_hit_rate,
437        warnings,
438    })
439}
440
441// =============================================================================
442// Selection assembly and grouping
443// =============================================================================
444
445/// One named selection: field, modifier chain, and values in emission order.
446struct Selection {
447    name: String,
448    entries: Vec<(String, ValueForm)>,
449}
450
451struct DetectionBlock {
452    selections: Vec<Selection>,
453    condition: String,
454}
455
456fn build_detection<E: Event>(
457    profiles: &[DraftFieldProfile],
458    selected: &[usize],
459    exemplars: &[E],
460    config: &DraftConfig,
461) -> DetectionBlock {
462    // Try a value-group split: partition exemplars by the highest-ranked
463    // selected field with a small distinct value set, and split only when it
464    // makes another multi-valued field single-valued in every partition.
465    if let Some(block) = try_group_split(profiles, selected, exemplars, config) {
466        return block;
467    }
468    let entries: Vec<(String, ValueForm)> = selected
469        .iter()
470        .filter_map(|&i| {
471            profiles[i]
472                .form
473                .clone()
474                .map(|f| (profiles[i].field().to_string(), f))
475        })
476        .collect();
477    DetectionBlock {
478        selections: vec![Selection {
479            name: "selection".to_string(),
480            entries,
481        }],
482        condition: "selection".to_string(),
483    }
484}
485
486const MAX_VALUE_GROUPS: usize = 3;
487
488fn try_group_split<E: Event>(
489    profiles: &[DraftFieldProfile],
490    selected: &[usize],
491    exemplars: &[E],
492    config: &DraftConfig,
493) -> Option<DetectionBlock> {
494    if selected.len() < 2 || exemplars.len() < 2 {
495        return None;
496    }
497    // Splitter: first selected field (rank order) with 2..=MAX_VALUE_GROUPS
498    // distinct string values.
499    let (splitter_pos, splitter) = selected.iter().enumerate().find_map(|(pos, &i)| {
500        let p = &profiles[i];
501        let d = p.distinct();
502        let all_str = d.iter().all(|v| matches!(v, DraftValue::Str(_)));
503        if all_str && d.len() >= 2 && d.len() <= MAX_VALUE_GROUPS {
504            Some((pos, i))
505        } else {
506            None
507        }
508    })?;
509
510    // Partition exemplar indexes by the splitter value, in first-seen order.
511    let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
512    for (idx, v) in profiles[splitter].values.iter().enumerate() {
513        let key = v.as_ref()?.as_display();
514        match groups.iter_mut().find(|(k, _)| *k == key) {
515            Some((_, members)) => members.push(idx),
516            None => groups.push((key, vec![idx])),
517        }
518    }
519    if groups.len() < 2 {
520        return None;
521    }
522    // Every group needs real support; splitting one exemplar per selection
523    // would just memorize the input.
524    if groups.iter().any(|(_, members)| members.len() < 2) {
525        return None;
526    }
527
528    // The split must earn its keep: some other selected field is multi-valued
529    // globally but single-valued within every partition.
530    let improves = selected.iter().enumerate().any(|(pos, &i)| {
531        if pos == splitter_pos {
532            return false;
533        }
534        let p = &profiles[i];
535        if p.distinct().len() < 2 {
536            return false;
537        }
538        groups.iter().all(|(_, members)| {
539            let mut vals = members.iter().filter_map(|&m| p.values[m].as_ref());
540            let first = vals.next();
541            first.is_some() && vals.all(|v| Some(v) == first)
542        })
543    });
544    if !improves {
545        return None;
546    }
547
548    // Build one selection per group, deriving per-group forms.
549    let mut used_names: BTreeMap<String, u32> = BTreeMap::new();
550    let selections: Vec<Selection> = groups
551        .iter()
552        .map(|(key, members)| {
553            let entries: Vec<(String, ValueForm)> = selected
554                .iter()
555                .filter_map(|&i| {
556                    let p = &profiles[i];
557                    let mut distinct: Vec<DraftValue> = Vec::new();
558                    for &m in members {
559                        if let Some(v) = &p.values[m]
560                            && !distinct.contains(v)
561                        {
562                            distinct.push(v.clone());
563                        }
564                    }
565                    derive_form(&distinct, config).map(|f| (p.field().to_string(), f))
566                })
567                .collect();
568            let base = selection_slug(key);
569            let n = used_names.entry(base.clone()).or_insert(0);
570            *n += 1;
571            let name = if *n == 1 {
572                format!("selection_{base}")
573            } else {
574                format!("selection_{base}_{n}")
575            };
576            Selection { name, entries }
577        })
578        .collect();
579
580    Some(DetectionBlock {
581        selections,
582        condition: "1 of selection_*".to_string(),
583    })
584}
585
586/// A short selection-name suffix from a splitter value: the first token of the
587/// last path segment's stem (`C:\W\vssadmin.exe` and `vssadmin delete shadows`
588/// both slug to `vssadmin`), lowercased.
589fn selection_slug(value: &str) -> String {
590    let last_segment = value.rsplit(['\\', '/']).next().unwrap_or(value);
591    let stem = last_segment
592        .split_once('.')
593        .map(|(stem, _)| stem)
594        .unwrap_or(last_segment);
595    let first_token = stem
596        .split(|c: char| !c.is_ascii_alphanumeric())
597        .find(|t| !t.is_empty())
598        .unwrap_or("");
599    let out: String = first_token.to_ascii_lowercase();
600    if out.is_empty() {
601        "group".to_string()
602    } else {
603        out
604    }
605}
606
607// =============================================================================
608// Logsource inference
609// =============================================================================
610
611#[derive(Debug, Clone, Default)]
612struct DraftLogsource {
613    category: Option<String>,
614    product: Option<String>,
615    service: Option<String>,
616    inferred: bool,
617}
618
619/// Sysmon EventID to Sigma category, for the unambiguous mappings only.
620fn sysmon_category(event_id: i64) -> Option<&'static str> {
621    Some(match event_id {
622        1 => "process_creation",
623        3 => "network_connection",
624        6 => "driver_load",
625        7 => "image_load",
626        8 => "create_remote_thread",
627        10 => "process_access",
628        11 => "file_event",
629        22 => "dns_query",
630        23 => "file_delete",
631        _ => return None,
632    })
633}
634
635fn infer_logsource<E: Event>(
636    exemplars: &[E],
637    config: &DraftConfig,
638    warnings: &mut Vec<String>,
639) -> DraftLogsource {
640    let mut out = DraftLogsource::default();
641
642    // Majority schema over the exemplars.
643    let classifier = SchemaClassifier::builtin();
644    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
645    for e in exemplars {
646        if let Some(m) = classifier.classify(e) {
647            *counts.entry(m.name).or_insert(0) += 1;
648        }
649    }
650    let majority = counts
651        .iter()
652        .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
653        .map(|(name, _)| name.as_str());
654
655    match majority {
656        Some("sysmon") => {
657            out.product = Some("windows".to_string());
658            // One shared EventID across all exemplars maps to a category and
659            // drops the service (Sigma sysmon rules use category + product).
660            let ids: BTreeSet<i64> = exemplars
661                .iter()
662                .filter_map(|e| e.get_field("EventID").and_then(|v| v.as_i64()))
663                .collect();
664            let category = if ids.len() == 1 {
665                ids.first().copied().and_then(sysmon_category)
666            } else {
667                None
668            };
669            match category {
670                Some(c) => out.category = Some(c.to_string()),
671                None => out.service = Some("sysmon".to_string()),
672            }
673            out.inferred = true;
674        }
675        Some("windows_eventlog") | Some("ecs_windows") => {
676            out.product = Some("windows".to_string());
677            out.inferred = true;
678        }
679        Some("ecs_linux") => {
680            out.product = Some("linux".to_string());
681            out.inferred = true;
682        }
683        _ => {}
684    }
685
686    // Overrides win per dimension.
687    if config.logsource_category.is_some() {
688        out.category = config.logsource_category.clone();
689        out.inferred = true;
690    }
691    if config.logsource_product.is_some() {
692        out.product = config.logsource_product.clone();
693        out.inferred = true;
694    }
695    if config.logsource_service.is_some() {
696        out.service = config.logsource_service.clone();
697        out.inferred = true;
698    }
699
700    if !out.inferred {
701        warnings.push(
702            "logsource could not be inferred from the exemplars; \
703             replace the 'todo' placeholder before committing"
704                .to_string(),
705        );
706        out.product = Some("todo".to_string());
707    }
708    out
709}
710
711/// A short human marker for the title, from the dominant (first selected)
712/// field's form.
713fn title_marker(profiles: &[DraftFieldProfile], selected: &[usize]) -> Option<String> {
714    let first = selected.first().map(|&i| &profiles[i])?;
715    let form = first.form.as_ref()?;
716    let raw = match form {
717        ValueForm::Exact(v) => v.as_display(),
718        ValueForm::OneOf(vs) => vs.first().map(|v| v.as_display()).unwrap_or_default(),
719        ValueForm::EndsWith(s) | ValueForm::StartsWith(s) | ValueForm::Contains(s) => s.clone(),
720        ValueForm::ContainsAll(ts) => ts.first().cloned().unwrap_or_default(),
721    };
722    let trimmed = raw.trim_matches(|c: char| !c.is_ascii_alphanumeric());
723    if trimmed.is_empty() {
724        None
725    } else {
726        Some(format!("{trimmed} ({})", first.field()))
727    }
728}
729
730fn emit_rule_yaml(
731    profiles: &[DraftFieldProfile],
732    selected: &[usize],
733    detection: &DetectionBlock,
734    logsource: &DraftLogsource,
735    config: &DraftConfig,
736) -> String {
737    let title = config.title.clone().unwrap_or_else(|| {
738        title_marker(profiles, selected)
739            .map(|m| format!("Draft: {m}"))
740            .unwrap_or_else(|| "Draft rule".to_string())
741    });
742    let date = config
743        .date
744        .clone()
745        .unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%d").to_string());
746
747    let mut out = String::new();
748    out.push_str(&format!("title: {}\n", yaml_title_str(&title)));
749    if let Some(id) = &config.rule_id {
750        out.push_str(&format!("id: {id}\n"));
751    }
752    out.push_str("status: experimental\n");
753    out.push_str("description: 'TODO: describe what this rule detects and why it matters.'\n");
754    out.push_str("author: 'TODO: your name'\n");
755    out.push_str(&format!("date: {date}\n"));
756    out.push_str("logsource:\n");
757    if let Some(c) = &logsource.category {
758        out.push_str(&format!("    category: {}\n", yaml_str(c)));
759    }
760    if let Some(p) = &logsource.product {
761        out.push_str(&format!("    product: {}\n", yaml_str(p)));
762    }
763    if let Some(s) = &logsource.service {
764        out.push_str(&format!("    service: {}\n", yaml_str(s)));
765    }
766    out.push_str("detection:\n");
767    for sel in &detection.selections {
768        out.push_str(&format!("    {}:\n", sel.name));
769        for (field, form) in &sel.entries {
770            emit_form(&mut out, field, form, "        ");
771        }
772    }
773    out.push_str(&format!("    condition: {}\n", detection.condition));
774    out.push_str("falsepositives:\n");
775    out.push_str("    - 'TODO: list known benign triggers.'\n");
776    out.push_str("level: medium\n");
777    out
778}
779
780// =============================================================================
781// Verification
782// =============================================================================
783
784fn compile_draft(yaml: &str) -> Result<Engine, DraftError> {
785    let collection = rsigma_parser::parse_sigma_yaml(yaml).map_err(|e| DraftError::Internal {
786        stage: "parse".to_string(),
787        message: e.to_string(),
788    })?;
789    let mut engine = Engine::new();
790    engine
791        .add_collection(&collection)
792        .map_err(|e| DraftError::Internal {
793            stage: "compile".to_string(),
794            message: e.to_string(),
795        })?;
796    Ok(engine)
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::event::JsonEvent;
803    use serde_json::{Value, json};
804
805    fn events(values: &[Value]) -> Vec<JsonEvent<'_>> {
806        values.iter().map(JsonEvent::borrow).collect()
807    }
808
809    fn fixed_config() -> DraftConfig {
810        DraftConfig {
811            rule_id: Some("00000000-0000-4000-8000-000000000000".to_string()),
812            date: Some("2026-07-03".to_string()),
813            ..DraftConfig::default()
814        }
815    }
816
817    fn draft(
818        exemplars: &[Value],
819        baseline: &[Value],
820        config: &DraftConfig,
821    ) -> Result<DraftReport, DraftError> {
822        draft_rule(&events(exemplars), &events(baseline), config)
823    }
824
825    // ---- Volatility heuristics ---------------------------------------------
826
827    #[test]
828    fn timestamp_names_and_values_are_volatile() {
829        assert!(is_volatile_name("UtcTime"));
830        assert!(is_volatile_name("@timestamp"));
831        assert!(is_volatile_name("event.created_date"));
832        assert!(is_volatile_value(&DraftValue::Str(
833            "2026-07-03T12:00:00Z".into()
834        )));
835        assert!(is_volatile_value(&DraftValue::Str("2026-07-03".into())));
836        assert!(!is_volatile_value(&DraftValue::Str("whoami.exe".into())));
837    }
838
839    #[test]
840    fn uuid_values_and_guid_names_are_volatile() {
841        assert!(is_volatile_name("ProcessGuid"));
842        assert!(is_uuid_string("6bde842e-a2f4-441e-b027-3aa79b1b2fc2"));
843        assert!(is_uuid_string("{6bde842e-a2f4-441e-b027-3aa79b1b2fc2}"));
844        assert!(!is_uuid_string("not-a-uuid"));
845    }
846
847    #[test]
848    fn counter_names_and_epoch_values_are_volatile() {
849        assert!(is_volatile_name("ProcessId"));
850        assert!(is_volatile_name("Event.System.EventRecordID"));
851        assert!(is_volatile_name("logon_id"));
852        assert!(is_epoch_number(1_751_500_000.0)); // seconds
853        assert!(is_epoch_number(1_751_500_000_000.0)); // milliseconds
854        assert!(!is_epoch_number(4688.0)); // an EventID is not an epoch
855    }
856
857    #[test]
858    fn time_date_name_match_is_word_bounded() {
859        // Real timestamp fields are volatile...
860        assert!(is_volatile_name("EventTime"));
861        assert!(is_volatile_name("event_date"));
862        assert!(is_volatile_name("datetime"));
863        // ...but content fields that merely contain "time"/"date" as a
864        // substring are not (regression: substring matching dropped these).
865        assert!(!is_volatile_name("runtime"));
866        assert!(!is_volatile_name("update"));
867        assert!(!is_volatile_name("candidate"));
868        assert!(!is_volatile_name("CommandLine"));
869        assert!(!is_volatile_name("validate_action"));
870    }
871
872    #[test]
873    fn shared_affix_never_splits_a_multibyte_char() {
874        // The common byte run ends inside 'é' (both é and è start 0xC3), so the
875        // prefix must snap to a char boundary rather than panic on the slice.
876        assert_eq!(
877            shared_prefix(&["abcé1", "abcè2"], 3).as_deref(),
878            Some("abc")
879        );
880        assert_eq!(shared_prefix(&["abcé1", "abcè2"], 4), None);
881        // Suffix: 'Ω' and 'é' both end in the continuation byte 0xA9, so the
882        // overlap starts mid-character; snapping up yields no suffix and no panic.
883        assert_eq!(shared_suffix(&["x\u{03a9}", "y\u{00e9}"], 1), None);
884        // A fully-shared multibyte affix is preserved intact.
885        assert_eq!(
886            shared_suffix(&["1éabc", "2éabc"], 3).as_deref(),
887            Some("éabc")
888        );
889    }
890
891    #[test]
892    fn random_unique_values_are_volatile() {
893        let exemplars: Vec<Value> = (0..4)
894            .map(|i| {
895                json!({
896                    "tool": "runner",
897                    "task": "sync",
898                    "token": format!("a9f{i}c2d4e6b8a0f1c3d5e7f9b1a3c5d{i}"),
899                })
900            })
901            .collect();
902        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
903        let token = report.fields.iter().find(|f| f.field == "token").unwrap();
904        assert_eq!(token.stability, Stability::Volatile);
905        assert!(!token.selected);
906    }
907
908    // ---- Scoring -------------------------------------------------------------
909
910    #[test]
911    fn baseline_contrast_prefers_rare_fields() {
912        let exemplars: Vec<Value> = (0..3)
913            .map(|_| json!({"action": "exfil", "proto": "tcp"}))
914            .collect();
915        // proto: tcp is ubiquitous in the baseline; action: exfil never occurs.
916        let baseline: Vec<Value> = (0..20)
917            .map(|i| json!({"action": format!("browse{i}"), "proto": "tcp"}))
918            .collect();
919        let report = draft(&exemplars, &baseline, &fixed_config()).unwrap();
920        let action = report.fields.iter().find(|f| f.field == "action").unwrap();
921        let proto = report.fields.iter().find(|f| f.field == "proto").unwrap();
922        assert!(
923            action.score > proto.score,
924            "baseline-rare field must outrank the ubiquitous one"
925        );
926        assert_eq!(proto.baseline_prevalence, Some(1.0));
927        assert_eq!(action.baseline_prevalence, Some(0.0));
928    }
929
930    #[test]
931    fn structural_fields_are_demoted_without_baseline() {
932        let exemplars: Vec<Value> = (0..3)
933            .map(|_| json!({"hostname": "web-01", "action": "exfil"}))
934            .collect();
935        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
936        let host = report
937            .fields
938            .iter()
939            .find(|f| f.field == "hostname")
940            .unwrap();
941        let action = report.fields.iter().find(|f| f.field == "action").unwrap();
942        assert!(action.score > host.score);
943    }
944
945    #[test]
946    fn deterministic_output_across_runs() {
947        let exemplars: Vec<Value> = (0..3)
948            .map(|_| json!({"vendor": "acme", "action": "alert", "sig": "S-1001"}))
949            .collect();
950        let a = draft(&exemplars, &[], &fixed_config()).unwrap().rule_yaml;
951        let b = draft(&exemplars, &[], &fixed_config()).unwrap().rule_yaml;
952        assert_eq!(a, b, "draft output must be byte-identical across runs");
953    }
954
955    // ---- Modifier inference ---------------------------------------------------
956
957    #[test]
958    fn shared_path_tail_becomes_endswith() {
959        let exemplars = vec![
960            json!({"Image": "C:\\Tools\\whoami.exe", "kind": "proc"}),
961            json!({"Image": "C:\\Windows\\System32\\whoami.exe", "kind": "proc"}),
962            json!({"Image": "D:\\stage\\whoami.exe", "kind": "proc"}),
963            json!({"Image": "E:\\x\\whoami.exe", "kind": "proc"}),
964            json!({"Image": "F:\\y\\whoami.exe", "kind": "proc"}),
965        ];
966        let cfg = DraftConfig {
967            max_value_cardinality: 3,
968            ..fixed_config()
969        };
970        let report = draft(&exemplars, &[], &cfg).unwrap();
971        assert!(
972            report.rule_yaml.contains("Image|endswith: '\\whoami.exe'"),
973            "expected endswith derivation, got:\n{}",
974            report.rule_yaml
975        );
976    }
977
978    #[test]
979    fn shared_prefix_becomes_startswith() {
980        let exemplars: Vec<Value> = (0..5)
981            .map(|i| json!({"url": format!("https://evil.example/payload{i}"), "verb": "GET"}))
982            .collect();
983        let cfg = DraftConfig {
984            max_value_cardinality: 3,
985            ..fixed_config()
986        };
987        let report = draft(&exemplars, &[], &cfg).unwrap();
988        assert!(
989            report
990                .rule_yaml
991                .contains("url|startswith: 'https://evil.example/payload'"),
992            "expected startswith derivation, got:\n{}",
993            report.rule_yaml
994        );
995    }
996
997    #[test]
998    fn short_generic_tokens_are_never_chosen() {
999        // The only shared token is 3 chars ("run"), below min_token_len 4.
1000        let exemplars: Vec<Value> = (0..5)
1001            .map(|i| json!({"cmd": format!("{i}zz run q{i}"), "kind": "x"}))
1002            .collect();
1003        let cfg = DraftConfig {
1004            max_value_cardinality: 3,
1005            ..fixed_config()
1006        };
1007        let report = draft(&exemplars, &[], &cfg).unwrap();
1008        let cmd = report.fields.iter().find(|f| f.field == "cmd").unwrap();
1009        assert_eq!(cmd.stability, Stability::Volatile);
1010        assert!(!report.rule_yaml.contains("cmd|contains"));
1011    }
1012
1013    #[test]
1014    fn baseline_generic_token_is_rejected() {
1015        // "powershell" is a stable exemplar token but ubiquitous in baseline.
1016        let exemplars: Vec<Value> = (0..5)
1017            .map(|i| json!({"proc": format!("powershell -x {i}q{i}w{i}"), "kind": "spawn"}))
1018            .collect();
1019        let baseline: Vec<Value> = (0..20)
1020            .map(|i| json!({"proc": format!("powershell -File login{i}.ps1"), "kind": "spawn"}))
1021            .collect();
1022        let cfg = DraftConfig {
1023            max_value_cardinality: 3,
1024            min_fields: 1,
1025            ..fixed_config()
1026        };
1027        let report = draft(&exemplars, &baseline, &cfg).unwrap();
1028        assert!(
1029            !report.rule_yaml.contains("proc|contains: powershell"),
1030            "generic baseline token must be rejected, got:\n{}",
1031            report.rule_yaml
1032        );
1033    }
1034
1035    #[test]
1036    fn wildcard_specials_in_values_are_escaped() {
1037        let exemplars: Vec<Value> = (0..3)
1038            .map(|_| json!({"query": "SELECT * FROM users?", "app": "dbd"}))
1039            .collect();
1040        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1041        assert!(
1042            report.rule_yaml.contains(r"SELECT \* FROM users\?"),
1043            "wildcards must be escaped, got:\n{}",
1044            report.rule_yaml
1045        );
1046        // And the escaped rule still matches the exemplars end-to-end (the
1047        // verification loop enforces this; assert the report agrees).
1048        assert_eq!(report.exemplar_matched, 3);
1049    }
1050
1051    #[test]
1052    fn escape_sigma_value_handles_backslash_adjacency() {
1053        assert_eq!(escape_sigma_value(r"C:\Windows"), r"C:\Windows");
1054        assert_eq!(escape_sigma_value("a*b"), r"a\*b");
1055        assert_eq!(escape_sigma_value("a?b"), r"a\?b");
1056        assert_eq!(escape_sigma_value(r"a\*b"), r"a\\\*b");
1057        assert_eq!(escape_sigma_value(r"a\\b"), r"a\\\\b");
1058        assert_eq!(escape_sigma_value(r"trailing\"), r"trailing\\");
1059    }
1060
1061    // ---- Grouping ----------------------------------------------------------------
1062
1063    #[test]
1064    fn distinct_value_groups_split_into_selections() {
1065        let exemplars = vec![
1066            json!({"Image": "C:\\W\\vssadmin.exe", "CommandLine": "vssadmin delete shadows", "k": "p"}),
1067            json!({"Image": "C:\\W\\vssadmin.exe", "CommandLine": "vssadmin delete shadows", "k": "p"}),
1068            json!({"Image": "C:\\W\\wmic.exe", "CommandLine": "wmic shadowcopy delete", "k": "p"}),
1069            json!({"Image": "C:\\W\\wmic.exe", "CommandLine": "wmic shadowcopy delete", "k": "p"}),
1070        ];
1071        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1072        assert!(
1073            report.rule_yaml.contains("condition: 1 of selection_*"),
1074            "expected a group split, got:\n{}",
1075            report.rule_yaml
1076        );
1077        assert!(report.rule_yaml.contains("selection_vssadmin:"));
1078        assert!(report.rule_yaml.contains("selection_wmic:"));
1079        assert_eq!(report.exemplar_matched, 4);
1080    }
1081
1082    #[test]
1083    fn no_split_when_values_do_not_partition() {
1084        let exemplars: Vec<Value> = (0..4)
1085            .map(|_| json!({"vendor": "acme", "action": "alert"}))
1086            .collect();
1087        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1088        assert!(report.rule_yaml.contains("condition: selection\n"));
1089    }
1090
1091    // ---- Logsource -------------------------------------------------------------
1092
1093    #[test]
1094    fn sysmon_event_id_maps_to_category() {
1095        let exemplars: Vec<Value> = (0..3)
1096            .map(|_| {
1097                json!({
1098                    "Channel": "Microsoft-Windows-Sysmon/Operational",
1099                    "EventID": 1,
1100                    "Image": "C:\\W\\evil.exe",
1101                    "CommandLine": "evil.exe --run",
1102                })
1103            })
1104            .collect();
1105        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1106        assert!(report.rule_yaml.contains("category: process_creation"));
1107        assert!(report.rule_yaml.contains("product: windows"));
1108        assert!(!report.rule_yaml.contains("service: sysmon"));
1109    }
1110
1111    #[test]
1112    fn sysmon_without_shared_event_id_keeps_service() {
1113        let exemplars = vec![
1114            json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 1, "Image": "C:\\W\\a.exe", "RuleName": "t"}),
1115            json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 3, "Image": "C:\\W\\a.exe", "RuleName": "t"}),
1116        ];
1117        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1118        assert!(report.rule_yaml.contains("service: sysmon"));
1119        assert!(report.rule_yaml.contains("product: windows"));
1120    }
1121
1122    #[test]
1123    fn logsource_overrides_win() {
1124        let exemplars: Vec<Value> = (0..3)
1125            .map(|_| json!({"vendor": "acme", "action": "alert"}))
1126            .collect();
1127        let cfg = DraftConfig {
1128            logsource_product: Some("acme_fw".to_string()),
1129            logsource_category: Some("firewall".to_string()),
1130            ..fixed_config()
1131        };
1132        let report = draft(&exemplars, &[], &cfg).unwrap();
1133        assert!(report.rule_yaml.contains("product: acme_fw"));
1134        assert!(report.rule_yaml.contains("category: firewall"));
1135        assert!(!report.rule_yaml.contains("todo"));
1136    }
1137
1138    #[test]
1139    fn unknown_schema_gets_todo_placeholder() {
1140        let exemplars: Vec<Value> = (0..3)
1141            .map(|_| json!({"vendor": "acme", "action": "alert"}))
1142            .collect();
1143        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1144        assert!(report.rule_yaml.contains("product: todo"));
1145        assert!(
1146            report
1147                .warnings
1148                .iter()
1149                .any(|w| w.contains("logsource could not be inferred"))
1150        );
1151    }
1152
1153    // ---- Emission, round-trip, verification -----------------------------------
1154
1155    #[test]
1156    fn draft_round_trips_and_matches_exemplars() {
1157        let exemplars: Vec<Value> = (0..4)
1158            .map(|_| json!({"vendor": "acme", "action": "exfil", "dst_port": 443}))
1159            .collect();
1160        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1161        // Parses and compiles (draft_rule already enforced it; do it again
1162        // from the public surface).
1163        let collection =
1164            rsigma_parser::parse_sigma_yaml(&report.rule_yaml).expect("emitted draft must parse");
1165        let mut engine = Engine::new();
1166        engine.add_collection(&collection).unwrap();
1167        for e in &events(&exemplars) {
1168            assert!(!engine.evaluate(e).is_empty(), "exemplar must match");
1169        }
1170        assert_eq!(report.exemplar_matched, report.exemplar_total);
1171        assert!(
1172            report
1173                .rule_yaml
1174                .contains("id: 00000000-0000-4000-8000-000000000000")
1175        );
1176        assert!(report.rule_yaml.contains("status: experimental"));
1177        assert!(report.rule_yaml.contains("level: medium"));
1178        assert!(report.rule_yaml.contains("date: 2026-07-03"));
1179    }
1180
1181    #[test]
1182    fn typed_values_emit_as_numbers() {
1183        let exemplars: Vec<Value> = (0..3)
1184            .map(|_| json!({"vendor": "acme", "code": 4688}))
1185            .collect();
1186        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1187        assert!(
1188            report.rule_yaml.contains("code: 4688"),
1189            "integers must emit bare, got:\n{}",
1190            report.rule_yaml
1191        );
1192    }
1193
1194    #[test]
1195    fn baseline_hits_are_counted_with_rate() {
1196        let exemplars: Vec<Value> = (0..3)
1197            .map(|_| json!({"vendor": "acme", "action": "alert"}))
1198            .collect();
1199        let mut baseline: Vec<Value> = (0..8)
1200            .map(|i| json!({"vendor": "other", "action": format!("a{i}")}))
1201            .collect();
1202        // Two baseline events the draft will also match.
1203        baseline.push(json!({"vendor": "acme", "action": "alert"}));
1204        baseline.push(json!({"vendor": "acme", "action": "alert"}));
1205        let report = draft(&exemplars, &baseline, &fixed_config()).unwrap();
1206        assert_eq!(report.baseline_total, 10);
1207        assert_eq!(report.baseline_hits, Some(2));
1208        assert!((report.baseline_hit_rate.unwrap() - 0.2).abs() < 1e-9);
1209        assert!(report.warnings.iter().any(|w| w.contains("baseline")));
1210    }
1211
1212    #[test]
1213    fn skip_baseline_eval_keeps_scoring_but_not_hits() {
1214        let exemplars: Vec<Value> = (0..3)
1215            .map(|_| json!({"vendor": "acme", "action": "alert"}))
1216            .collect();
1217        let baseline: Vec<Value> = (0..5)
1218            .map(|i| json!({"vendor": "other", "action": format!("a{i}")}))
1219            .collect();
1220        let cfg = DraftConfig {
1221            evaluate_baseline: false,
1222            ..fixed_config()
1223        };
1224        let report = draft(&exemplars, &baseline, &cfg).unwrap();
1225        assert_eq!(report.baseline_hits, None);
1226        assert!(
1227            report
1228                .fields
1229                .iter()
1230                .any(|f| f.baseline_prevalence.is_some()),
1231            "contrastive scoring still uses the baseline"
1232        );
1233    }
1234
1235    // ---- Relaxation and error paths ------------------------------------------
1236
1237    #[test]
1238    fn relaxation_drops_partial_prevalence_fields() {
1239        // "extra" appears in half the exemplars; selecting it breaks the AND
1240        // selection, so verification must drop it and still succeed.
1241        let mut exemplars: Vec<Value> = (0..2)
1242            .map(|_| json!({"vendor": "acme", "action": "alert", "extra": "x"}))
1243            .collect();
1244        exemplars.extend((0..2).map(|_| json!({"vendor": "acme", "action": "alert"})));
1245        let cfg = DraftConfig {
1246            min_prevalence: 0.4,
1247            ..fixed_config()
1248        };
1249        let report = draft(&exemplars, &[], &cfg).unwrap();
1250        assert_eq!(report.exemplar_matched, 4);
1251        assert!(!report.rule_yaml.contains("extra"));
1252        assert!(report.warnings.iter().any(|w| w.contains("relaxed")));
1253    }
1254
1255    #[test]
1256    fn floor_errors_instead_of_emitting_overbroad_draft() {
1257        // Two disjoint half-prevalence fields and nothing else: no 2-field AND
1258        // can match every exemplar, and the floor forbids going below 2.
1259        let mut exemplars: Vec<Value> = (0..2)
1260            .map(|_| json!({"alpha": "one", "beta": "x"}))
1261            .collect();
1262        exemplars.extend((0..2).map(|_| json!({"alpha": "two", "gamma": "y"})));
1263        let cfg = DraftConfig {
1264            min_prevalence: 0.4,
1265            min_fields: 2,
1266            max_value_cardinality: 1,
1267            ..fixed_config()
1268        };
1269        let err = draft(&exemplars, &[], &cfg).unwrap_err();
1270        assert!(
1271            matches!(err, DraftError::CannotMatchExemplars { floor: 2, .. }),
1272            "expected the floor error, got: {err}"
1273        );
1274    }
1275
1276    #[test]
1277    fn forced_field_absent_from_exemplars_errors_immediately() {
1278        // "extra" is forced but absent from half the exemplars: relaxation
1279        // must not strip the useful fields around it, it must name the
1280        // culprit and stop.
1281        let mut exemplars: Vec<Value> = (0..2)
1282            .map(|_| json!({"vendor": "acme", "action": "alert", "extra": "x"}))
1283            .collect();
1284        exemplars.extend((0..2).map(|_| json!({"vendor": "acme", "action": "alert"})));
1285        let cfg = DraftConfig {
1286            include_fields: vec!["extra".to_string()],
1287            min_prevalence: 0.4,
1288            ..fixed_config()
1289        };
1290        let err = draft(&exemplars, &[], &cfg).unwrap_err();
1291        match err {
1292            DraftError::ForcedFieldMismatch { fields, failing } => {
1293                assert_eq!(fields, vec!["extra".to_string()]);
1294                assert_eq!(failing, vec![2, 3]);
1295            }
1296            other => panic!("expected ForcedFieldMismatch, got: {other}"),
1297        }
1298    }
1299
1300    #[test]
1301    fn no_exemplars_is_an_error() {
1302        let err = draft(&[], &[], &fixed_config()).unwrap_err();
1303        assert!(matches!(err, DraftError::NoExemplars));
1304    }
1305
1306    #[test]
1307    fn all_volatile_fields_is_an_error() {
1308        let exemplars: Vec<Value> = (0..3)
1309            .map(|i| {
1310                json!({
1311                    "UtcTime": format!("2026-07-03T12:00:0{i}Z"),
1312                    "ProcessGuid": format!("6bde842e-a2f4-441e-b027-3aa79b1b2fc{i}"),
1313                })
1314            })
1315            .collect();
1316        let err = draft(&exemplars, &[], &fixed_config()).unwrap_err();
1317        assert!(matches!(err, DraftError::NoCandidateFields(3)));
1318    }
1319
1320    // ---- Flags -------------------------------------------------------------------
1321
1322    #[test]
1323    fn include_and_exclude_fields_are_honored() {
1324        let exemplars: Vec<Value> = (0..3)
1325            .map(|_| json!({"vendor": "acme", "action": "alert", "noise": "same"}))
1326            .collect();
1327        let cfg = DraftConfig {
1328            include_fields: vec!["noise".to_string()],
1329            exclude_fields: vec!["vendor".to_string()],
1330            max_fields: 2,
1331            ..fixed_config()
1332        };
1333        let report = draft(&exemplars, &[], &cfg).unwrap();
1334        assert!(report.rule_yaml.contains("noise: same"));
1335        assert!(!report.rule_yaml.contains("vendor"));
1336    }
1337
1338    #[test]
1339    fn title_override_and_derived_title() {
1340        let exemplars: Vec<Value> = (0..3)
1341            .map(|_| json!({"vendor": "acme", "action": "alert"}))
1342            .collect();
1343        let derived = draft(&exemplars, &[], &fixed_config()).unwrap();
1344        assert!(
1345            derived.rule_yaml.starts_with("title: 'Draft:")
1346                || derived.rule_yaml.starts_with("title: Draft"),
1347            "derived title expected, got:\n{}",
1348            derived.rule_yaml
1349        );
1350        let cfg = DraftConfig {
1351            title: Some("Acme Exfil Detection".to_string()),
1352            ..fixed_config()
1353        };
1354        let titled = draft(&exemplars, &[], &cfg).unwrap();
1355        assert!(titled.rule_yaml.starts_with("title: Acme Exfil Detection"));
1356    }
1357}