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, EventValue};
36use crate::schema::SchemaClassifier;
37use crate::schema_discovery::FieldProfile;
38
39// =============================================================================
40// Configuration
41// =============================================================================
42
43/// Tunables for a draft run. [`Default`] is a sensible starting point; the CLI
44/// exposes each as a flag.
45#[derive(Debug, Clone)]
46pub struct DraftConfig {
47    /// Maximum fields in a selection. Kept small so drafts stay readable.
48    pub max_fields: usize,
49    /// Relaxation floor: verification may drop failing fields down to this
50    /// count, below it drafting errors instead of emitting an over-broad rule.
51    pub min_fields: usize,
52    /// Fraction (0.0-1.0) of exemplars a field must appear in to be a
53    /// candidate. The default 1.0 keeps AND-selections sound.
54    pub min_prevalence: f64,
55    /// A field whose distinct exemplar values do not exceed this cap is
56    /// "enumerable" and emitted as an OR value list.
57    pub max_value_cardinality: usize,
58    /// Minimum length of a shared prefix/suffix/token before it becomes a
59    /// `startswith`/`endswith`/`contains` pattern, so short generic fragments
60    /// are never chosen.
61    pub min_token_len: usize,
62    /// A `contains` token matching more than this fraction of baseline events
63    /// is rejected as too generic.
64    pub max_baseline_token_prevalence: f64,
65    /// Force these fields into the selection (a warning is recorded when a
66    /// forced field is absent from some exemplars).
67    pub include_fields: Vec<String>,
68    /// Never consider these fields.
69    pub exclude_fields: Vec<String>,
70    /// Rule title override; derived from the dominant marker when unset.
71    pub title: Option<String>,
72    /// Rule `id`. The core is deterministic and never generates one; the CLI
73    /// passes a fresh UUIDv4. Unset omits the `id` key (lint reports it).
74    pub rule_id: Option<String>,
75    /// Rule `date` (YYYY-MM-DD). Defaults to today (UTC) when unset; tests
76    /// pass a fixed date for byte-identical output.
77    pub date: Option<String>,
78    /// Logsource overrides; each set dimension wins over inference.
79    pub logsource_category: Option<String>,
80    pub logsource_product: Option<String>,
81    pub logsource_service: Option<String>,
82    /// Evaluate the final draft against the baseline (the baseline is still
83    /// used for contrastive scoring when this is off).
84    pub evaluate_baseline: bool,
85}
86
87impl Default for DraftConfig {
88    fn default() -> Self {
89        Self {
90            max_fields: 4,
91            min_fields: 2,
92            min_prevalence: 1.0,
93            max_value_cardinality: 4,
94            min_token_len: 4,
95            max_baseline_token_prevalence: 0.05,
96            include_fields: Vec::new(),
97            exclude_fields: Vec::new(),
98            title: None,
99            rule_id: None,
100            date: None,
101            logsource_category: None,
102            logsource_product: None,
103            logsource_service: None,
104            evaluate_baseline: true,
105        }
106    }
107}
108
109// =============================================================================
110// Errors
111// =============================================================================
112
113/// Why a draft could not be produced.
114#[derive(Debug, thiserror::Error)]
115pub enum DraftError {
116    /// No exemplar events were provided.
117    #[error("no exemplar events to draft from")]
118    NoExemplars,
119    /// No field survived profiling (all volatile, excluded, or below the
120    /// prevalence threshold).
121    #[error(
122        "no candidate fields: every field was volatile (timestamps, ids, unique values), \
123         excluded, or below the prevalence threshold ({0} exemplars profiled)"
124    )]
125    NoCandidateFields(usize),
126    /// Even after relaxing to the minimum-field floor the draft does not match
127    /// every exemplar, so an honest rule cannot be emitted.
128    #[error(
129        "draft cannot match all exemplars: {matched}/{total} match at the {floor}-field floor; \
130         exemplars may be too heterogeneous for one rule (failing exemplar indexes: {failing:?})"
131    )]
132    CannotMatchExemplars {
133        matched: usize,
134        total: usize,
135        floor: usize,
136        failing: Vec<usize>,
137    },
138    /// A field forced via `include_fields` is absent from some exemplars, so
139    /// no draft containing it can match them. Forced fields are user intent
140    /// and are never dropped by relaxation.
141    #[error(
142        "forced field(s) {fields:?} are absent from exemplar(s) {failing:?}; \
143         remove the --include-field or drop those exemplars"
144    )]
145    ForcedFieldMismatch {
146        fields: Vec<String>,
147        failing: Vec<usize>,
148    },
149    /// The emitted YAML failed to parse or compile (a bug, surfaced honestly).
150    #[error("internal error: emitted draft failed to {stage}: {message}")]
151    Internal { stage: String, message: String },
152}
153
154// =============================================================================
155// Public report types
156// =============================================================================
157
158/// How a field's values behave across the exemplars.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
160#[serde(rename_all = "snake_case")]
161pub enum Stability {
162    /// The same value in every exemplar.
163    Constant,
164    /// A small distinct value set (an OR list).
165    Enumerable,
166    /// Differing values sharing a prefix, suffix, or token.
167    Patterned,
168    /// No usable structure; never selected.
169    Volatile,
170}
171
172impl fmt::Display for Stability {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        let s = match self {
175            Stability::Constant => "constant",
176            Stability::Enumerable => "enumerable",
177            Stability::Patterned => "patterned",
178            Stability::Volatile => "volatile",
179        };
180        f.write_str(s)
181    }
182}
183
184/// One profiled field in the report, ranked.
185#[derive(Debug, Clone, Serialize)]
186pub struct DraftFieldReport {
187    /// Dot-joined field path.
188    pub field: String,
189    /// Contrastive score used for ranking (higher is better).
190    pub score: f64,
191    /// Value-stability class across the exemplars.
192    pub stability: Stability,
193    /// Sigma modifier chain chosen for the field (empty for a plain match).
194    pub modifier: String,
195    /// Display values or derived pattern, capped.
196    pub values: Vec<String>,
197    /// Fraction of baseline events this field's value form matches, when a
198    /// baseline was provided.
199    pub baseline_prevalence: Option<f64>,
200    /// Whether the field made it into the final selection.
201    pub selected: bool,
202}
203
204/// The result of a draft run: the rule plus the evidence behind it.
205#[derive(Debug, Clone)]
206pub struct DraftReport {
207    /// The complete draft rule, standard Sigma YAML, parse- and lint-checked.
208    pub rule_yaml: String,
209    /// Profiled candidate fields, ranked (selected fields first).
210    pub fields: Vec<DraftFieldReport>,
211    /// Number of exemplar events.
212    pub exemplar_total: usize,
213    /// Exemplars the final draft matches (always equals `exemplar_total`; a
214    /// draft that cannot match every exemplar is an error, not a result).
215    pub exemplar_matched: usize,
216    /// Number of baseline events provided.
217    pub baseline_total: usize,
218    /// Baseline events the draft matches (its estimated false-positive count),
219    /// when the baseline evaluation ran.
220    pub baseline_hits: Option<usize>,
221    /// `baseline_hits / baseline_total` (0.0-1.0), when computed.
222    pub baseline_hit_rate: Option<f64>,
223    /// Advisory notes: lint findings, relaxation drops, inference caveats.
224    pub warnings: Vec<String>,
225}
226
227// =============================================================================
228// Internal value and form model
229// =============================================================================
230
231/// A scalar exemplar value, kept typed so numbers emit as numbers.
232#[derive(Debug, Clone, PartialEq)]
233enum DraftValue {
234    Str(String),
235    Int(i64),
236    Float(f64),
237    Bool(bool),
238}
239
240impl DraftValue {
241    fn from_event_value(v: &EventValue<'_>) -> Option<Self> {
242        match v {
243            EventValue::Str(s) => Some(DraftValue::Str(s.to_string())),
244            EventValue::Int(n) => Some(DraftValue::Int(*n)),
245            EventValue::Float(f) => Some(DraftValue::Float(*f)),
246            EventValue::Bool(b) => Some(DraftValue::Bool(*b)),
247            EventValue::Null | EventValue::Array(_) | EventValue::Map(_) => None,
248        }
249    }
250
251    fn as_display(&self) -> String {
252        match self {
253            DraftValue::Str(s) => s.clone(),
254            DraftValue::Int(n) => n.to_string(),
255            DraftValue::Float(f) => f.to_string(),
256            DraftValue::Bool(b) => b.to_string(),
257        }
258    }
259
260    fn as_match_str(&self) -> String {
261        self.as_display()
262    }
263}
264
265/// The value form chosen for one field, mapping to a Sigma modifier.
266#[derive(Debug, Clone, PartialEq)]
267enum ValueForm {
268    /// Single stable value; plain equals.
269    Exact(DraftValue),
270    /// Small distinct set; OR value list.
271    OneOf(Vec<DraftValue>),
272    /// Differing values sharing a suffix; `|endswith`.
273    EndsWith(String),
274    /// Differing values sharing a prefix; `|startswith`.
275    StartsWith(String),
276    /// Differing values sharing one stable token; `|contains`.
277    Contains(String),
278    /// Differing values sharing several stable tokens; `|contains|all`.
279    ContainsAll(Vec<String>),
280}
281
282impl ValueForm {
283    fn modifier(&self) -> &'static str {
284        match self {
285            ValueForm::Exact(_) | ValueForm::OneOf(_) => "",
286            ValueForm::EndsWith(_) => "|endswith",
287            ValueForm::StartsWith(_) => "|startswith",
288            ValueForm::Contains(_) => "|contains",
289            ValueForm::ContainsAll(_) => "|contains|all",
290        }
291    }
292
293    fn display_values(&self) -> Vec<String> {
294        match self {
295            ValueForm::Exact(v) => vec![v.as_display()],
296            ValueForm::OneOf(vs) => vs.iter().map(|v| v.as_display()).collect(),
297            ValueForm::EndsWith(s) => vec![format!("*{s}")],
298            ValueForm::StartsWith(s) => vec![format!("{s}*")],
299            ValueForm::Contains(s) => vec![format!("*{s}*")],
300            ValueForm::ContainsAll(ts) => ts.iter().map(|t| format!("*{t}*")).collect(),
301        }
302    }
303
304    /// Would this form match the given already-lowercased string value?
305    /// Mirrors Sigma's default case-insensitive matching; used only for
306    /// baseline prevalence scoring.
307    fn matches_lower(&self, lv: &str) -> bool {
308        match self {
309            ValueForm::Exact(v) => lv == v.as_match_str().to_lowercase(),
310            ValueForm::OneOf(vs) => vs.iter().any(|v| lv == v.as_match_str().to_lowercase()),
311            ValueForm::EndsWith(s) => lv.ends_with(&s.to_lowercase()),
312            ValueForm::StartsWith(s) => lv.starts_with(&s.to_lowercase()),
313            ValueForm::Contains(t) => lv.contains(&t.to_lowercase()),
314            ValueForm::ContainsAll(ts) => ts.iter().all(|t| lv.contains(&t.to_lowercase())),
315        }
316    }
317}
318
319/// One profiled field: the shared per-field statistics plus the aligned
320/// per-exemplar values that drafting needs on top of them.
321#[derive(Debug, Clone)]
322struct DraftFieldProfile {
323    /// The base statistics, shared with schema discovery's profile type.
324    stats: FieldProfile,
325    /// Value per exemplar index (`None` when absent or non-scalar).
326    values: Vec<Option<DraftValue>>,
327    stability: Stability,
328    /// The chosen value form (None for volatile fields).
329    form: Option<ValueForm>,
330    score: f64,
331    baseline_prevalence: Option<f64>,
332    forced: bool,
333}
334
335impl DraftFieldProfile {
336    fn field(&self) -> &str {
337        &self.stats.field
338    }
339
340    fn distinct(&self) -> Vec<&DraftValue> {
341        let mut seen: Vec<&DraftValue> = Vec::new();
342        for v in self.values.iter().flatten() {
343            if !seen.contains(&v) {
344                seen.push(v);
345            }
346        }
347        seen
348    }
349}
350
351// =============================================================================
352// Entry point
353// =============================================================================
354
355/// Draft a Sigma detection rule from exemplar events, optionally contrasted
356/// against a baseline corpus (pass an empty slice for no baseline).
357///
358/// The returned draft is guaranteed to parse, compile, and match every
359/// exemplar; drafting errors instead of emitting a rule that does not.
360pub fn draft_rule<E: Event>(
361    exemplars: &[E],
362    baseline: &[E],
363    config: &DraftConfig,
364) -> Result<DraftReport, DraftError> {
365    if exemplars.is_empty() {
366        return Err(DraftError::NoExemplars);
367    }
368    let mut warnings: Vec<String> = Vec::new();
369
370    // ---- Profile ----------------------------------------------------------
371    let mut profiles = profile_fields(exemplars, config, &mut warnings);
372    if profiles.is_empty() {
373        return Err(DraftError::NoCandidateFields(exemplars.len()));
374    }
375
376    // ---- Infer value forms -------------------------------------------------
377    for p in &mut profiles {
378        infer_form(p, config);
379    }
380
381    // ---- Baseline prevalence + token guard ---------------------------------
382    if !baseline.is_empty() {
383        for p in &mut profiles {
384            apply_baseline(p, baseline, config);
385        }
386    }
387
388    // ---- Score -------------------------------------------------------------
389    let has_baseline = !baseline.is_empty();
390    for p in &mut profiles {
391        p.score = score_field(p, has_baseline);
392    }
393    // Rank: forced first, then score descending, then field name ascending
394    // (the deterministic tie-break).
395    profiles.sort_by(|a, b| {
396        b.forced
397            .cmp(&a.forced)
398            .then_with(|| {
399                b.score
400                    .partial_cmp(&a.score)
401                    .unwrap_or(std::cmp::Ordering::Equal)
402            })
403            .then_with(|| a.field().cmp(b.field()))
404    });
405
406    // ---- Select ------------------------------------------------------------
407    let usable: Vec<usize> = profiles
408        .iter()
409        .enumerate()
410        .filter(|(_, p)| p.form.is_some() && p.stability != Stability::Volatile)
411        .map(|(i, _)| i)
412        .collect();
413    if usable.is_empty() {
414        return Err(DraftError::NoCandidateFields(exemplars.len()));
415    }
416    let mut selected: Vec<usize> = usable.iter().copied().take(config.max_fields).collect();
417    if selected.len() < config.min_fields {
418        warnings.push(format!(
419            "only {} usable field(s) found (floor is {}); the draft may be broad",
420            selected.len(),
421            config.min_fields
422        ));
423    }
424
425    // ---- Logsource ---------------------------------------------------------
426    let logsource = infer_logsource(exemplars, config, &mut warnings);
427
428    // ---- Emit + verify (bounded relaxation) --------------------------------
429    let floor = config.min_fields.min(selected.len()).max(1);
430    let (yaml, matched, failing) = loop {
431        let detection = build_detection(&profiles, &selected, exemplars, config);
432        let yaml = emit_rule_yaml(&profiles, &selected, &detection, &logsource, config);
433        let engine = compile_draft(&yaml)?;
434        let failing: Vec<usize> = exemplars
435            .iter()
436            .enumerate()
437            .filter(|(_, e)| engine.evaluate(e).is_empty())
438            .map(|(i, _)| i)
439            .collect();
440        if failing.is_empty() {
441            break (yaml, exemplars.len(), failing);
442        }
443
444        // A field is a provable culprit when it is absent from a failing
445        // exemplar (the common case: partial-prevalence fields admitted by
446        // `min_prevalence`).
447        let absent_in_failing =
448            |i: usize| failing.iter().any(|&idx| profiles[i].values[idx].is_none());
449
450        // A forced field that provably breaks the match is a user decision we
451        // refuse to override; dropping other fields could never fix it, so
452        // error out immediately with the culprit named.
453        let forced_culprits: Vec<String> = selected
454            .iter()
455            .filter(|&&i| profiles[i].forced && absent_in_failing(i))
456            .map(|&i| profiles[i].field().to_string())
457            .collect();
458        if !forced_culprits.is_empty() {
459            return Err(DraftError::ForcedFieldMismatch {
460                fields: forced_culprits,
461                failing,
462            });
463        }
464
465        if selected.len() <= floor {
466            return Err(DraftError::CannotMatchExemplars {
467                matched: exemplars.len() - failing.len(),
468                total: exemplars.len(),
469                floor,
470                failing,
471            });
472        }
473
474        // Drop the lowest-ranked non-forced culprit; when no field is provably
475        // at fault (a value-form edge case), shed the weakest non-forced field.
476        let drop_pos = selected
477            .iter()
478            .rposition(|&i| !profiles[i].forced && absent_in_failing(i))
479            .or_else(|| selected.iter().rposition(|&i| !profiles[i].forced));
480        let Some(pos) = drop_pos else {
481            return Err(DraftError::CannotMatchExemplars {
482                matched: exemplars.len() - failing.len(),
483                total: exemplars.len(),
484                floor,
485                failing,
486            });
487        };
488        let dropped = selected.remove(pos);
489        warnings.push(format!(
490            "relaxed: dropped field '{}' because the draft did not match every exemplar with it",
491            profiles[dropped].field()
492        ));
493    };
494    debug_assert!(failing.is_empty());
495
496    // ---- Baseline hits ------------------------------------------------------
497    let (baseline_hits, baseline_hit_rate) = if !baseline.is_empty() && config.evaluate_baseline {
498        let engine = compile_draft(&yaml)?;
499        let hits = baseline
500            .iter()
501            .filter(|e| !engine.evaluate(e).is_empty())
502            .count();
503        let rate = hits as f64 / baseline.len() as f64;
504        if hits > 0 {
505            warnings.push(format!(
506                "draft matches {hits}/{} baseline events ({:.1}%); consider a tighter field",
507                baseline.len(),
508                rate * 100.0
509            ));
510        }
511        (Some(hits), Some(rate))
512    } else {
513        (None, None)
514    };
515
516    // ---- Lint ----------------------------------------------------------------
517    for w in rsigma_parser::lint_yaml_str(&yaml) {
518        warnings.push(format!("lint {}: {}", w.rule, w.message));
519    }
520
521    // ---- Report ---------------------------------------------------------------
522    let selected_set: BTreeSet<usize> = selected.iter().copied().collect();
523    let fields = profiles
524        .iter()
525        .enumerate()
526        .map(|(i, p)| DraftFieldReport {
527            field: p.field().to_string(),
528            score: p.score,
529            stability: p.stability,
530            modifier: p
531                .form
532                .as_ref()
533                .map(|f| f.modifier().trim_start_matches('|').to_string())
534                .unwrap_or_default(),
535            values: p
536                .form
537                .as_ref()
538                .map(|f| f.display_values())
539                .unwrap_or_else(|| {
540                    p.distinct()
541                        .into_iter()
542                        .take(4)
543                        .map(|v| v.as_display())
544                        .collect()
545                }),
546            baseline_prevalence: p.baseline_prevalence,
547            selected: selected_set.contains(&i),
548        })
549        .collect();
550
551    Ok(DraftReport {
552        rule_yaml: yaml,
553        fields,
554        exemplar_total: exemplars.len(),
555        exemplar_matched: matched,
556        baseline_total: baseline.len(),
557        baseline_hits,
558        baseline_hit_rate,
559        warnings,
560    })
561}
562
563// =============================================================================
564// Profiling
565// =============================================================================
566
567fn profile_fields<E: Event>(
568    exemplars: &[E],
569    config: &DraftConfig,
570    warnings: &mut Vec<String>,
571) -> Vec<DraftFieldProfile> {
572    // Union of leaf field paths across all exemplars, sorted for determinism.
573    let mut all_fields: BTreeSet<String> = BTreeSet::new();
574    for e in exemplars {
575        for k in e.field_keys() {
576            all_fields.insert(k.into_owned());
577        }
578    }
579
580    let excluded = |f: &str| {
581        config
582            .exclude_fields
583            .iter()
584            .any(|x| x.eq_ignore_ascii_case(f))
585    };
586    let forced = |f: &str| {
587        config
588            .include_fields
589            .iter()
590            .any(|x| x.eq_ignore_ascii_case(f))
591    };
592
593    // Warn about forced fields that do not exist at all.
594    for inc in &config.include_fields {
595        if !all_fields.iter().any(|f| f.eq_ignore_ascii_case(inc)) {
596            warnings.push(format!(
597                "--include-field '{inc}' does not appear in any exemplar; ignored"
598            ));
599        }
600    }
601
602    let total = exemplars.len();
603    let mut out = Vec::new();
604    for field in all_fields {
605        if excluded(&field) {
606            continue;
607        }
608        let values: Vec<Option<DraftValue>> = exemplars
609            .iter()
610            .map(|e| {
611                e.get_field(&field)
612                    .and_then(|v| DraftValue::from_event_value(&v))
613            })
614            .collect();
615        let present = exemplars
616            .iter()
617            .filter(|e| e.get_field(&field).is_some())
618            .count();
619        let prevalence = present as f64 / total as f64;
620        let is_forced = forced(&field);
621        if prevalence < config.min_prevalence && !is_forced {
622            continue;
623        }
624        if is_forced && prevalence < 1.0 {
625            warnings.push(format!(
626                "--include-field '{field}' is absent from some exemplars \
627                 ({present}/{total}); the draft may not match them"
628            ));
629        }
630
631        let mut distinct_values: Vec<String> = values
632            .iter()
633            .flatten()
634            .map(|v| v.as_display())
635            .collect::<BTreeSet<_>>()
636            .into_iter()
637            .collect();
638        distinct_values.sort();
639        let stats = FieldProfile {
640            field: field.clone(),
641            present: present as u64,
642            total: total as u64,
643            distinct_values,
644            value_overflow: false,
645        };
646
647        let stability = classify_stability(&field, &values, present, config);
648        out.push(DraftFieldProfile {
649            stats,
650            values,
651            stability,
652            form: None,
653            score: 0.0,
654            baseline_prevalence: None,
655            forced: is_forced,
656        });
657    }
658    out
659}
660
661fn classify_stability(
662    field: &str,
663    values: &[Option<DraftValue>],
664    present: usize,
665    config: &DraftConfig,
666) -> Stability {
667    let scalars: Vec<&DraftValue> = values.iter().flatten().collect();
668    // A field that is present but non-scalar (array, map, null) in some
669    // exemplar cannot back a plain selection value. Absence alone is fine:
670    // partial-prevalence fields are admitted by `min_prevalence` and the
671    // verification loop drops them if they break the AND selection.
672    if scalars.is_empty() || scalars.len() < present {
673        return Stability::Volatile;
674    }
675    // Name- and shape-based volatility comes first: a timestamp constant
676    // across exemplars is still a timestamp.
677    if is_volatile_name(field) {
678        return Stability::Volatile;
679    }
680    if scalars.iter().any(|v| is_volatile_value(v)) {
681        return Stability::Volatile;
682    }
683
684    let mut distinct: Vec<&DraftValue> = Vec::new();
685    for v in &scalars {
686        if !distinct.contains(v) {
687            distinct.push(v);
688        }
689    }
690    if distinct.len() == 1 {
691        return Stability::Constant;
692    }
693    if distinct.len() <= config.max_value_cardinality && distinct.len() < scalars.len() {
694        return Stability::Enumerable;
695    }
696    // All-string values may still share a pattern.
697    let strings: Vec<&str> = distinct
698        .iter()
699        .filter_map(|v| match v {
700            DraftValue::Str(s) => Some(s.as_str()),
701            _ => None,
702        })
703        .collect();
704    if strings.len() == distinct.len() {
705        // Unique-per-exemplar random-looking values are volatile even when a
706        // short prefix happens to be shared.
707        if distinct.len() == scalars.len() && strings.iter().all(|s| is_random_string(s)) {
708            return Stability::Volatile;
709        }
710        if shared_suffix(&strings, config.min_token_len).is_some()
711            || shared_prefix(&strings, config.min_token_len).is_some()
712            || !shared_tokens(&strings, config.min_token_len).is_empty()
713        {
714            return Stability::Patterned;
715        }
716        // A small distinct set that repeats across exemplars was handled above;
717        // what is left is either enumerable-but-unique (each exemplar its own
718        // value, still a small set) or volatile.
719        if distinct.len() <= config.max_value_cardinality {
720            return Stability::Enumerable;
721        }
722    } else if distinct.len() <= config.max_value_cardinality {
723        return Stability::Enumerable;
724    }
725    Stability::Volatile
726}
727
728// =============================================================================
729// Volatility heuristics
730// =============================================================================
731
732/// Field names that denote per-event bookkeeping rather than content.
733fn is_volatile_name(field: &str) -> bool {
734    let segment = field.rsplit('.').next().unwrap_or(field);
735    let last = segment.to_lowercase();
736    let normalized: String = last.chars().filter(|c| *c != '_' && *c != '-').collect();
737    if last == "@timestamp" || normalized == "ts" {
738        return true;
739    }
740    // Match time/date at word granularity (camelCase and separators), so
741    // `UtcTime` and `created_date` are volatile while `runtime`, `update`, and
742    // `candidate` are not mistaken for timestamps.
743    if segment_words(segment)
744        .iter()
745        .any(|w| matches!(w.as_str(), "time" | "date" | "datetime" | "timestamp"))
746    {
747        return true;
748    }
749    if normalized.contains("timestamp")
750        || normalized.contains("guid")
751        || normalized.contains("uuid")
752    {
753        return true;
754    }
755    matches!(
756        normalized.as_str(),
757        "recordid"
758            | "recordnumber"
759            | "eventrecordid"
760            | "sequence"
761            | "seq"
762            | "seqno"
763            | "processid"
764            | "pid"
765            | "parentprocessid"
766            | "ppid"
767            | "threadid"
768            | "tid"
769            | "logonid"
770            | "sessionid"
771            | "executionprocessid"
772            | "executionthreadid"
773    )
774}
775
776/// Split a field segment into lowercase words on non-alphanumeric separators and
777/// camelCase boundaries, so `UtcTime` -> `[utc, time]` and `created_date` ->
778/// `[created, date]`, while `runtime` stays a single word.
779fn segment_words(segment: &str) -> Vec<String> {
780    let mut words: Vec<String> = Vec::new();
781    let mut cur = String::new();
782    let mut prev: Option<char> = None;
783    for c in segment.chars() {
784        if !c.is_ascii_alphanumeric() {
785            if !cur.is_empty() {
786                words.push(std::mem::take(&mut cur));
787            }
788            prev = None;
789            continue;
790        }
791        // A lower/digit to upper transition starts a new camelCase word.
792        if let Some(p) = prev
793            && c.is_ascii_uppercase()
794            && (p.is_ascii_lowercase() || p.is_ascii_digit())
795            && !cur.is_empty()
796        {
797            words.push(std::mem::take(&mut cur));
798        }
799        cur.push(c.to_ascii_lowercase());
800        prev = Some(c);
801    }
802    if !cur.is_empty() {
803        words.push(cur);
804    }
805    words
806}
807
808/// Values that look like timestamps, UUIDs, or epoch counters.
809fn is_volatile_value(value: &DraftValue) -> bool {
810    match value {
811        DraftValue::Str(s) => is_timestamp_string(s) || is_uuid_string(s),
812        DraftValue::Int(n) => is_epoch_number(*n as f64),
813        DraftValue::Float(f) => is_epoch_number(*f),
814        DraftValue::Bool(_) => false,
815    }
816}
817
818/// RFC3339-ish or `YYYY-MM-DD HH:MM` shaped strings.
819fn is_timestamp_string(s: &str) -> bool {
820    let b = s.as_bytes();
821    if b.len() < 10 {
822        return false;
823    }
824    let date = b[0].is_ascii_digit()
825        && b[1].is_ascii_digit()
826        && b[2].is_ascii_digit()
827        && b[3].is_ascii_digit()
828        && b[4] == b'-'
829        && b[5].is_ascii_digit()
830        && b[6].is_ascii_digit()
831        && b[7] == b'-'
832        && b[8].is_ascii_digit()
833        && b[9].is_ascii_digit();
834    if !date {
835        return false;
836    }
837    // A bare date, or a date followed by a time separator.
838    b.len() == 10 || b[10] == b'T' || b[10] == b' '
839}
840
841/// UUID/GUID shape: 8-4-4-4-12 hex, with or without braces.
842fn is_uuid_string(s: &str) -> bool {
843    let s = s.strip_prefix('{').unwrap_or(s);
844    let s = s.strip_suffix('}').unwrap_or(s);
845    if s.len() != 36 {
846        return false;
847    }
848    s.char_indices().all(|(i, c)| match i {
849        8 | 13 | 18 | 23 => c == '-',
850        _ => c.is_ascii_hexdigit(),
851    })
852}
853
854/// Plausible Unix epoch in seconds, milliseconds, microseconds, or nanoseconds
855/// (2001-2286 in seconds and the equivalent ranges for the finer units).
856fn is_epoch_number(n: f64) -> bool {
857    const RANGES: [(f64, f64); 4] = [
858        (1e9, 1e10),  // seconds
859        (1e12, 1e13), // milliseconds
860        (1e15, 1e16), // microseconds
861        (1e18, 1e19), // nanoseconds
862    ];
863    RANGES.iter().any(|(lo, hi)| n >= *lo && n < *hi)
864}
865
866/// Long, alphanumeric, digit-and-letter mixed values (hashes, tokens) that are
867/// unique per exemplar.
868fn is_random_string(s: &str) -> bool {
869    s.len() >= 16
870        && s.chars().all(|c| c.is_ascii_alphanumeric())
871        && s.chars().any(|c| c.is_ascii_digit())
872        && s.chars().any(|c| c.is_ascii_alphabetic())
873}
874
875/// Envelope fields demoted (not dropped) when there is no baseline to score
876/// against: nearly every event carries them, so they rarely discriminate.
877fn is_structural_name(field: &str) -> bool {
878    let last = field.rsplit('.').next().unwrap_or(field).to_lowercase();
879    matches!(
880        last.as_str(),
881        "host" | "hostname" | "computer" | "computername" | "domain" | "level" | "severity"
882    )
883}
884
885// =============================================================================
886// Pattern derivation
887// =============================================================================
888
889fn shared_prefix(values: &[&str], min_len: usize) -> Option<String> {
890    let first = values.first()?;
891    let mut len = first.len();
892    for v in &values[1..] {
893        len = len.min(common_prefix_len(first, v));
894    }
895    // The byte overlap may end inside a multibyte character; snap down to a
896    // char boundary so slicing never panics on non-ASCII values.
897    while len > 0 && !first.is_char_boundary(len) {
898        len -= 1;
899    }
900    // Don't call a full-equality overlap a "prefix".
901    if len >= min_len && values.iter().any(|v| v.len() > len) {
902        Some(first[..len].to_string())
903    } else {
904        None
905    }
906}
907
908fn shared_suffix(values: &[&str], min_len: usize) -> Option<String> {
909    let first = values.first()?;
910    let mut len = first.len();
911    for v in &values[1..] {
912        len = len.min(common_suffix_len(first, v));
913    }
914    // Snap the suffix start up to a char boundary so the byte overlap never
915    // splits a multibyte character (which would panic when sliced).
916    let mut start = first.len() - len;
917    while start < first.len() && !first.is_char_boundary(start) {
918        start += 1;
919    }
920    let len = first.len() - start;
921    if len >= min_len && values.iter().any(|v| v.len() > len) {
922        Some(first[start..].to_string())
923    } else {
924        None
925    }
926}
927
928fn common_prefix_len(a: &str, b: &str) -> usize {
929    a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
930}
931
932fn common_suffix_len(a: &str, b: &str) -> usize {
933    a.bytes()
934        .rev()
935        .zip(b.bytes().rev())
936        .take_while(|(x, y)| x == y)
937        .count()
938}
939
940/// Tokens (runs of alphanumerics, `min_len` or longer) present in every value,
941/// case-insensitively. Sorted longest first, then lexicographic, capped at 3.
942fn shared_tokens(values: &[&str], min_len: usize) -> Vec<String> {
943    let Some(first) = values.first() else {
944        return Vec::new();
945    };
946    let lowers: Vec<String> = values.iter().map(|v| v.to_lowercase()).collect();
947    let mut tokens: Vec<String> = tokenize(first, min_len)
948        .into_iter()
949        .filter(|t| {
950            let lt = t.to_lowercase();
951            lowers.iter().all(|v| v.contains(&lt))
952        })
953        .collect();
954    tokens.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
955    tokens.dedup();
956    tokens.truncate(3);
957    tokens
958}
959
960fn tokenize(s: &str, min_len: usize) -> Vec<String> {
961    let mut out: Vec<String> = Vec::new();
962    for token in s.split(|c: char| !c.is_ascii_alphanumeric()) {
963        if token.len() >= min_len && !out.iter().any(|t| t == token) {
964            out.push(token.to_string());
965        }
966    }
967    out
968}
969
970// =============================================================================
971// Value-form inference
972// =============================================================================
973
974fn infer_form(profile: &mut DraftFieldProfile, config: &DraftConfig) {
975    if profile.stability == Stability::Volatile {
976        return;
977    }
978    let distinct: Vec<DraftValue> = profile.distinct().into_iter().cloned().collect();
979    // A patterned field generalizes better as its shared pattern than as an
980    // exact OR list that would just memorize the exemplars.
981    if profile.stability == Stability::Patterned {
982        let strings: Vec<&str> = distinct
983            .iter()
984            .filter_map(|v| match v {
985                DraftValue::Str(s) => Some(s.as_str()),
986                _ => None,
987            })
988            .collect();
989        if strings.len() == distinct.len() {
990            profile.form = derive_pattern_form(&strings, config);
991        }
992    }
993    if profile.form.is_none() {
994        profile.form = derive_form(&distinct, config);
995    }
996    if profile.form.is_none() {
997        profile.stability = Stability::Volatile;
998    }
999}
1000
1001fn derive_form(distinct: &[DraftValue], config: &DraftConfig) -> Option<ValueForm> {
1002    match distinct {
1003        [] => None,
1004        [one] => Some(ValueForm::Exact(one.clone())),
1005        many if many.len() <= config.max_value_cardinality => Some(ValueForm::OneOf(many.to_vec())),
1006        many => {
1007            let strings: Vec<&str> = many
1008                .iter()
1009                .filter_map(|v| match v {
1010                    DraftValue::Str(s) => Some(s.as_str()),
1011                    _ => None,
1012                })
1013                .collect();
1014            if strings.len() != many.len() {
1015                return None;
1016            }
1017            derive_pattern_form(&strings, config)
1018        }
1019    }
1020}
1021
1022fn derive_pattern_form(strings: &[&str], config: &DraftConfig) -> Option<ValueForm> {
1023    // Suffix beats prefix beats tokens: path tails (`\whoami.exe`) are the
1024    // most discriminating shape in practice.
1025    if let Some(suffix) = shared_suffix(strings, config.min_token_len) {
1026        return Some(ValueForm::EndsWith(suffix));
1027    }
1028    if let Some(prefix) = shared_prefix(strings, config.min_token_len) {
1029        return Some(ValueForm::StartsWith(prefix));
1030    }
1031    let tokens = shared_tokens(strings, config.min_token_len);
1032    match tokens.len() {
1033        0 => None,
1034        1 => Some(ValueForm::Contains(tokens.into_iter().next().unwrap())),
1035        _ => Some(ValueForm::ContainsAll(tokens)),
1036    }
1037}
1038
1039// =============================================================================
1040// Baseline scoring
1041// =============================================================================
1042
1043fn apply_baseline<E: Event>(profile: &mut DraftFieldProfile, baseline: &[E], config: &DraftConfig) {
1044    let Some(form) = profile.form.clone() else {
1045        return;
1046    };
1047    // Extract the field once per baseline event; the guard and prevalence
1048    // counts below then run over the in-memory values instead of re-walking
1049    // the events per token.
1050    let field = profile.field().to_string();
1051    let values: Vec<String> = baseline
1052        .iter()
1053        .filter_map(|e| {
1054            e.get_field(&field)
1055                .and_then(|v| v.as_str().map(|s| s.to_lowercase()))
1056        })
1057        .collect();
1058    let match_count = |f: &ValueForm| values.iter().filter(|lv| f.matches_lower(lv)).count();
1059    let token_is_generic = |t: &str| {
1060        let lt = t.to_lowercase();
1061        let hits = values.iter().filter(|lv| lv.contains(&lt)).count();
1062        hits as f64 / baseline.len() as f64 > config.max_baseline_token_prevalence
1063    };
1064
1065    // Token guard: drop `contains` tokens that are generic in the baseline.
1066    let guarded = match form {
1067        ValueForm::Contains(ref t) => {
1068            if token_is_generic(t) {
1069                profile.form = None;
1070                profile.stability = Stability::Volatile;
1071                return;
1072            }
1073            form
1074        }
1075        ValueForm::ContainsAll(ref ts) => {
1076            let kept: Vec<String> = ts
1077                .iter()
1078                .filter(|t| !token_is_generic(t))
1079                .cloned()
1080                .collect();
1081            match kept.len() {
1082                0 => {
1083                    profile.form = None;
1084                    profile.stability = Stability::Volatile;
1085                    return;
1086                }
1087                1 => ValueForm::Contains(kept.into_iter().next().unwrap()),
1088                _ => ValueForm::ContainsAll(kept),
1089            }
1090        }
1091        other => other,
1092    };
1093
1094    let hits = match_count(&guarded);
1095    profile.form = Some(guarded);
1096    profile.baseline_prevalence = Some(hits as f64 / baseline.len() as f64);
1097}
1098
1099fn score_field(profile: &DraftFieldProfile, has_baseline: bool) -> f64 {
1100    if profile.form.is_none() || profile.stability == Stability::Volatile {
1101        return f64::MIN;
1102    }
1103    let stability_base = match profile.stability {
1104        Stability::Constant => 3.0,
1105        Stability::Enumerable => 2.0,
1106        Stability::Patterned => 1.0,
1107        Stability::Volatile => 0.0,
1108    };
1109    let prevalence = profile.stats.prevalence();
1110    match profile.baseline_prevalence {
1111        Some(bp) => stability_base * prevalence * (1.0 - bp),
1112        None => {
1113            let demotion = if !has_baseline && is_structural_name(profile.field()) {
1114                0.5
1115            } else {
1116                0.0
1117            };
1118            stability_base * prevalence - demotion
1119        }
1120    }
1121}
1122
1123// =============================================================================
1124// Selection assembly and grouping
1125// =============================================================================
1126
1127/// One named selection: field, modifier chain, and values in emission order.
1128struct Selection {
1129    name: String,
1130    entries: Vec<(String, ValueForm)>,
1131}
1132
1133struct DetectionBlock {
1134    selections: Vec<Selection>,
1135    condition: String,
1136}
1137
1138fn build_detection<E: Event>(
1139    profiles: &[DraftFieldProfile],
1140    selected: &[usize],
1141    exemplars: &[E],
1142    config: &DraftConfig,
1143) -> DetectionBlock {
1144    // Try a value-group split: partition exemplars by the highest-ranked
1145    // selected field with a small distinct value set, and split only when it
1146    // makes another multi-valued field single-valued in every partition.
1147    if let Some(block) = try_group_split(profiles, selected, exemplars, config) {
1148        return block;
1149    }
1150    let entries: Vec<(String, ValueForm)> = selected
1151        .iter()
1152        .filter_map(|&i| {
1153            profiles[i]
1154                .form
1155                .clone()
1156                .map(|f| (profiles[i].field().to_string(), f))
1157        })
1158        .collect();
1159    DetectionBlock {
1160        selections: vec![Selection {
1161            name: "selection".to_string(),
1162            entries,
1163        }],
1164        condition: "selection".to_string(),
1165    }
1166}
1167
1168const MAX_VALUE_GROUPS: usize = 3;
1169
1170fn try_group_split<E: Event>(
1171    profiles: &[DraftFieldProfile],
1172    selected: &[usize],
1173    exemplars: &[E],
1174    config: &DraftConfig,
1175) -> Option<DetectionBlock> {
1176    if selected.len() < 2 || exemplars.len() < 2 {
1177        return None;
1178    }
1179    // Splitter: first selected field (rank order) with 2..=MAX_VALUE_GROUPS
1180    // distinct string values.
1181    let (splitter_pos, splitter) = selected.iter().enumerate().find_map(|(pos, &i)| {
1182        let p = &profiles[i];
1183        let d = p.distinct();
1184        let all_str = d.iter().all(|v| matches!(v, DraftValue::Str(_)));
1185        if all_str && d.len() >= 2 && d.len() <= MAX_VALUE_GROUPS {
1186            Some((pos, i))
1187        } else {
1188            None
1189        }
1190    })?;
1191
1192    // Partition exemplar indexes by the splitter value, in first-seen order.
1193    let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
1194    for (idx, v) in profiles[splitter].values.iter().enumerate() {
1195        let key = v.as_ref()?.as_display();
1196        match groups.iter_mut().find(|(k, _)| *k == key) {
1197            Some((_, members)) => members.push(idx),
1198            None => groups.push((key, vec![idx])),
1199        }
1200    }
1201    if groups.len() < 2 {
1202        return None;
1203    }
1204    // Every group needs real support; splitting one exemplar per selection
1205    // would just memorize the input.
1206    if groups.iter().any(|(_, members)| members.len() < 2) {
1207        return None;
1208    }
1209
1210    // The split must earn its keep: some other selected field is multi-valued
1211    // globally but single-valued within every partition.
1212    let improves = selected.iter().enumerate().any(|(pos, &i)| {
1213        if pos == splitter_pos {
1214            return false;
1215        }
1216        let p = &profiles[i];
1217        if p.distinct().len() < 2 {
1218            return false;
1219        }
1220        groups.iter().all(|(_, members)| {
1221            let mut vals = members.iter().filter_map(|&m| p.values[m].as_ref());
1222            let first = vals.next();
1223            first.is_some() && vals.all(|v| Some(v) == first)
1224        })
1225    });
1226    if !improves {
1227        return None;
1228    }
1229
1230    // Build one selection per group, deriving per-group forms.
1231    let mut used_names: BTreeMap<String, u32> = BTreeMap::new();
1232    let selections: Vec<Selection> = groups
1233        .iter()
1234        .map(|(key, members)| {
1235            let entries: Vec<(String, ValueForm)> = selected
1236                .iter()
1237                .filter_map(|&i| {
1238                    let p = &profiles[i];
1239                    let mut distinct: Vec<DraftValue> = Vec::new();
1240                    for &m in members {
1241                        if let Some(v) = &p.values[m]
1242                            && !distinct.contains(v)
1243                        {
1244                            distinct.push(v.clone());
1245                        }
1246                    }
1247                    derive_form(&distinct, config).map(|f| (p.field().to_string(), f))
1248                })
1249                .collect();
1250            let base = selection_slug(key);
1251            let n = used_names.entry(base.clone()).or_insert(0);
1252            *n += 1;
1253            let name = if *n == 1 {
1254                format!("selection_{base}")
1255            } else {
1256                format!("selection_{base}_{n}")
1257            };
1258            Selection { name, entries }
1259        })
1260        .collect();
1261
1262    Some(DetectionBlock {
1263        selections,
1264        condition: "1 of selection_*".to_string(),
1265    })
1266}
1267
1268/// A short selection-name suffix from a splitter value: the first token of the
1269/// last path segment's stem (`C:\W\vssadmin.exe` and `vssadmin delete shadows`
1270/// both slug to `vssadmin`), lowercased.
1271fn selection_slug(value: &str) -> String {
1272    let last_segment = value.rsplit(['\\', '/']).next().unwrap_or(value);
1273    let stem = last_segment
1274        .split_once('.')
1275        .map(|(stem, _)| stem)
1276        .unwrap_or(last_segment);
1277    let first_token = stem
1278        .split(|c: char| !c.is_ascii_alphanumeric())
1279        .find(|t| !t.is_empty())
1280        .unwrap_or("");
1281    let out: String = first_token.to_ascii_lowercase();
1282    if out.is_empty() {
1283        "group".to_string()
1284    } else {
1285        out
1286    }
1287}
1288
1289// =============================================================================
1290// Logsource inference
1291// =============================================================================
1292
1293#[derive(Debug, Clone, Default)]
1294struct DraftLogsource {
1295    category: Option<String>,
1296    product: Option<String>,
1297    service: Option<String>,
1298    inferred: bool,
1299}
1300
1301/// Sysmon EventID to Sigma category, for the unambiguous mappings only.
1302fn sysmon_category(event_id: i64) -> Option<&'static str> {
1303    Some(match event_id {
1304        1 => "process_creation",
1305        3 => "network_connection",
1306        6 => "driver_load",
1307        7 => "image_load",
1308        8 => "create_remote_thread",
1309        10 => "process_access",
1310        11 => "file_event",
1311        22 => "dns_query",
1312        23 => "file_delete",
1313        _ => return None,
1314    })
1315}
1316
1317fn infer_logsource<E: Event>(
1318    exemplars: &[E],
1319    config: &DraftConfig,
1320    warnings: &mut Vec<String>,
1321) -> DraftLogsource {
1322    let mut out = DraftLogsource::default();
1323
1324    // Majority schema over the exemplars.
1325    let classifier = SchemaClassifier::builtin();
1326    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
1327    for e in exemplars {
1328        if let Some(m) = classifier.classify(e) {
1329            *counts.entry(m.name).or_insert(0) += 1;
1330        }
1331    }
1332    let majority = counts
1333        .iter()
1334        .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
1335        .map(|(name, _)| name.as_str());
1336
1337    match majority {
1338        Some("sysmon") => {
1339            out.product = Some("windows".to_string());
1340            // One shared EventID across all exemplars maps to a category and
1341            // drops the service (Sigma sysmon rules use category + product).
1342            let ids: BTreeSet<i64> = exemplars
1343                .iter()
1344                .filter_map(|e| e.get_field("EventID").and_then(|v| v.as_i64()))
1345                .collect();
1346            let category = if ids.len() == 1 {
1347                ids.first().copied().and_then(sysmon_category)
1348            } else {
1349                None
1350            };
1351            match category {
1352                Some(c) => out.category = Some(c.to_string()),
1353                None => out.service = Some("sysmon".to_string()),
1354            }
1355            out.inferred = true;
1356        }
1357        Some("windows_eventlog") | Some("ecs_windows") => {
1358            out.product = Some("windows".to_string());
1359            out.inferred = true;
1360        }
1361        Some("ecs_linux") => {
1362            out.product = Some("linux".to_string());
1363            out.inferred = true;
1364        }
1365        _ => {}
1366    }
1367
1368    // Overrides win per dimension.
1369    if config.logsource_category.is_some() {
1370        out.category = config.logsource_category.clone();
1371        out.inferred = true;
1372    }
1373    if config.logsource_product.is_some() {
1374        out.product = config.logsource_product.clone();
1375        out.inferred = true;
1376    }
1377    if config.logsource_service.is_some() {
1378        out.service = config.logsource_service.clone();
1379        out.inferred = true;
1380    }
1381
1382    if !out.inferred {
1383        warnings.push(
1384            "logsource could not be inferred from the exemplars; \
1385             replace the 'todo' placeholder before committing"
1386                .to_string(),
1387        );
1388        out.product = Some("todo".to_string());
1389    }
1390    out
1391}
1392
1393// =============================================================================
1394// Sigma value escaping
1395// =============================================================================
1396
1397/// Escape a literal value for use in a Sigma detection value, so an observed
1398/// `*`, `?`, or wildcard-adjacent backslash never silently becomes a wildcard.
1399///
1400/// Per the Sigma spec: `\*` and `\?` are literal wildcard characters, `\\` is a
1401/// literal backslash, and a backslash before a non-special character is kept
1402/// as-is (so plain Windows paths stay readable).
1403fn escape_sigma_value(s: &str) -> String {
1404    let chars: Vec<char> = s.chars().collect();
1405    let mut out = String::with_capacity(s.len());
1406    let mut i = 0;
1407    while i < chars.len() {
1408        match chars[i] {
1409            '*' => out.push_str("\\*"),
1410            '?' => out.push_str("\\?"),
1411            '\\' => {
1412                // Handle the whole run of consecutive backslashes at once: a
1413                // lone backslash before a normal character stays as-is (plain
1414                // Windows paths remain readable), while runs and backslashes
1415                // adjacent to a wildcard or the end of the value are escaped
1416                // so the parser cannot reinterpret them.
1417                let mut j = i;
1418                while j < chars.len() && chars[j] == '\\' {
1419                    j += 1;
1420                }
1421                let run = j - i;
1422                let next = chars.get(j);
1423                let must_escape = run > 1 || matches!(next, Some('*') | Some('?') | None);
1424                for _ in 0..run {
1425                    if must_escape {
1426                        out.push_str("\\\\");
1427                    } else {
1428                        out.push('\\');
1429                    }
1430                }
1431                i = j;
1432                continue;
1433            }
1434            c => out.push(c),
1435        }
1436        i += 1;
1437    }
1438    out
1439}
1440
1441// =============================================================================
1442// YAML emission
1443// =============================================================================
1444
1445/// Quote a YAML scalar in Sigma's single-quote convention when it is not a
1446/// plain-safe bare scalar. Numbers and booleans are emitted bare upstream.
1447fn yaml_str(s: &str) -> String {
1448    let bare_safe = !s.is_empty()
1449        && s.chars()
1450            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
1451        && !s.starts_with('-')
1452        // Bare scalars that YAML would type-coerce need quoting.
1453        && s.parse::<f64>().is_err()
1454        && !matches!(
1455            s.to_ascii_lowercase().as_str(),
1456            "true" | "false" | "null" | "yes" | "no" | "on" | "off"
1457        );
1458    if bare_safe {
1459        s.to_string()
1460    } else {
1461        format!("'{}'", s.replace('\'', "''"))
1462    }
1463}
1464
1465/// Looser quoting for prose scalars (the title): plain YAML allows internal
1466/// spaces, so common titles stay unquoted; anything risky falls back to
1467/// [`yaml_str`].
1468fn yaml_title_str(s: &str) -> String {
1469    let bare_safe = !s.is_empty()
1470        && s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
1471        && !s.ends_with(' ')
1472        && !s.contains(": ")
1473        && !s.contains(" #")
1474        && s.chars().all(|c| {
1475            c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.' | ',' | '(' | ')')
1476        });
1477    if bare_safe {
1478        s.to_string()
1479    } else {
1480        yaml_str(s)
1481    }
1482}
1483
1484fn emit_value(v: &DraftValue) -> String {
1485    match v {
1486        DraftValue::Str(s) => yaml_str(&escape_sigma_value(s)),
1487        DraftValue::Int(n) => n.to_string(),
1488        DraftValue::Float(f) => f.to_string(),
1489        DraftValue::Bool(b) => b.to_string(),
1490    }
1491}
1492
1493fn emit_form(out: &mut String, field: &str, form: &ValueForm, indent: &str) {
1494    let key = format!("{field}{}", form.modifier());
1495    match form {
1496        ValueForm::Exact(v) => {
1497            out.push_str(&format!("{indent}{key}: {}\n", emit_value(v)));
1498        }
1499        ValueForm::OneOf(vs) => {
1500            out.push_str(&format!("{indent}{key}:\n"));
1501            for v in vs {
1502                out.push_str(&format!("{indent}    - {}\n", emit_value(v)));
1503            }
1504        }
1505        ValueForm::EndsWith(s) | ValueForm::StartsWith(s) | ValueForm::Contains(s) => {
1506            out.push_str(&format!(
1507                "{indent}{key}: {}\n",
1508                yaml_str(&escape_sigma_value(s))
1509            ));
1510        }
1511        ValueForm::ContainsAll(ts) => {
1512            out.push_str(&format!("{indent}{key}:\n"));
1513            for t in ts {
1514                out.push_str(&format!(
1515                    "{indent}    - {}\n",
1516                    yaml_str(&escape_sigma_value(t))
1517                ));
1518            }
1519        }
1520    }
1521}
1522
1523/// A short human marker for the title, from the dominant (first selected)
1524/// field's form.
1525fn title_marker(profiles: &[DraftFieldProfile], selected: &[usize]) -> Option<String> {
1526    let first = selected.first().map(|&i| &profiles[i])?;
1527    let form = first.form.as_ref()?;
1528    let raw = match form {
1529        ValueForm::Exact(v) => v.as_display(),
1530        ValueForm::OneOf(vs) => vs.first().map(|v| v.as_display()).unwrap_or_default(),
1531        ValueForm::EndsWith(s) | ValueForm::StartsWith(s) | ValueForm::Contains(s) => s.clone(),
1532        ValueForm::ContainsAll(ts) => ts.first().cloned().unwrap_or_default(),
1533    };
1534    let trimmed = raw.trim_matches(|c: char| !c.is_ascii_alphanumeric());
1535    if trimmed.is_empty() {
1536        None
1537    } else {
1538        Some(format!("{trimmed} ({})", first.field()))
1539    }
1540}
1541
1542fn emit_rule_yaml(
1543    profiles: &[DraftFieldProfile],
1544    selected: &[usize],
1545    detection: &DetectionBlock,
1546    logsource: &DraftLogsource,
1547    config: &DraftConfig,
1548) -> String {
1549    let title = config.title.clone().unwrap_or_else(|| {
1550        title_marker(profiles, selected)
1551            .map(|m| format!("Draft: {m}"))
1552            .unwrap_or_else(|| "Draft rule".to_string())
1553    });
1554    let date = config
1555        .date
1556        .clone()
1557        .unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%d").to_string());
1558
1559    let mut out = String::new();
1560    out.push_str(&format!("title: {}\n", yaml_title_str(&title)));
1561    if let Some(id) = &config.rule_id {
1562        out.push_str(&format!("id: {id}\n"));
1563    }
1564    out.push_str("status: experimental\n");
1565    out.push_str("description: 'TODO: describe what this rule detects and why it matters.'\n");
1566    out.push_str("author: 'TODO: your name'\n");
1567    out.push_str(&format!("date: {date}\n"));
1568    out.push_str("logsource:\n");
1569    if let Some(c) = &logsource.category {
1570        out.push_str(&format!("    category: {}\n", yaml_str(c)));
1571    }
1572    if let Some(p) = &logsource.product {
1573        out.push_str(&format!("    product: {}\n", yaml_str(p)));
1574    }
1575    if let Some(s) = &logsource.service {
1576        out.push_str(&format!("    service: {}\n", yaml_str(s)));
1577    }
1578    out.push_str("detection:\n");
1579    for sel in &detection.selections {
1580        out.push_str(&format!("    {}:\n", sel.name));
1581        for (field, form) in &sel.entries {
1582            emit_form(&mut out, field, form, "        ");
1583        }
1584    }
1585    out.push_str(&format!("    condition: {}\n", detection.condition));
1586    out.push_str("falsepositives:\n");
1587    out.push_str("    - 'TODO: list known benign triggers.'\n");
1588    out.push_str("level: medium\n");
1589    out
1590}
1591
1592// =============================================================================
1593// Verification
1594// =============================================================================
1595
1596fn compile_draft(yaml: &str) -> Result<Engine, DraftError> {
1597    let collection = rsigma_parser::parse_sigma_yaml(yaml).map_err(|e| DraftError::Internal {
1598        stage: "parse".to_string(),
1599        message: e.to_string(),
1600    })?;
1601    let mut engine = Engine::new();
1602    engine
1603        .add_collection(&collection)
1604        .map_err(|e| DraftError::Internal {
1605            stage: "compile".to_string(),
1606            message: e.to_string(),
1607        })?;
1608    Ok(engine)
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613    use super::*;
1614    use crate::event::JsonEvent;
1615    use serde_json::{Value, json};
1616
1617    fn events(values: &[Value]) -> Vec<JsonEvent<'_>> {
1618        values.iter().map(JsonEvent::borrow).collect()
1619    }
1620
1621    fn fixed_config() -> DraftConfig {
1622        DraftConfig {
1623            rule_id: Some("00000000-0000-4000-8000-000000000000".to_string()),
1624            date: Some("2026-07-03".to_string()),
1625            ..DraftConfig::default()
1626        }
1627    }
1628
1629    fn draft(
1630        exemplars: &[Value],
1631        baseline: &[Value],
1632        config: &DraftConfig,
1633    ) -> Result<DraftReport, DraftError> {
1634        draft_rule(&events(exemplars), &events(baseline), config)
1635    }
1636
1637    // ---- Volatility heuristics ---------------------------------------------
1638
1639    #[test]
1640    fn timestamp_names_and_values_are_volatile() {
1641        assert!(is_volatile_name("UtcTime"));
1642        assert!(is_volatile_name("@timestamp"));
1643        assert!(is_volatile_name("event.created_date"));
1644        assert!(is_volatile_value(&DraftValue::Str(
1645            "2026-07-03T12:00:00Z".into()
1646        )));
1647        assert!(is_volatile_value(&DraftValue::Str("2026-07-03".into())));
1648        assert!(!is_volatile_value(&DraftValue::Str("whoami.exe".into())));
1649    }
1650
1651    #[test]
1652    fn uuid_values_and_guid_names_are_volatile() {
1653        assert!(is_volatile_name("ProcessGuid"));
1654        assert!(is_uuid_string("6bde842e-a2f4-441e-b027-3aa79b1b2fc2"));
1655        assert!(is_uuid_string("{6bde842e-a2f4-441e-b027-3aa79b1b2fc2}"));
1656        assert!(!is_uuid_string("not-a-uuid"));
1657    }
1658
1659    #[test]
1660    fn counter_names_and_epoch_values_are_volatile() {
1661        assert!(is_volatile_name("ProcessId"));
1662        assert!(is_volatile_name("Event.System.EventRecordID"));
1663        assert!(is_volatile_name("logon_id"));
1664        assert!(is_epoch_number(1_751_500_000.0)); // seconds
1665        assert!(is_epoch_number(1_751_500_000_000.0)); // milliseconds
1666        assert!(!is_epoch_number(4688.0)); // an EventID is not an epoch
1667    }
1668
1669    #[test]
1670    fn time_date_name_match_is_word_bounded() {
1671        // Real timestamp fields are volatile...
1672        assert!(is_volatile_name("EventTime"));
1673        assert!(is_volatile_name("event_date"));
1674        assert!(is_volatile_name("datetime"));
1675        // ...but content fields that merely contain "time"/"date" as a
1676        // substring are not (regression: substring matching dropped these).
1677        assert!(!is_volatile_name("runtime"));
1678        assert!(!is_volatile_name("update"));
1679        assert!(!is_volatile_name("candidate"));
1680        assert!(!is_volatile_name("CommandLine"));
1681        assert!(!is_volatile_name("validate_action"));
1682    }
1683
1684    #[test]
1685    fn shared_affix_never_splits_a_multibyte_char() {
1686        // The common byte run ends inside 'é' (both é and è start 0xC3), so the
1687        // prefix must snap to a char boundary rather than panic on the slice.
1688        assert_eq!(
1689            shared_prefix(&["abcé1", "abcè2"], 3).as_deref(),
1690            Some("abc")
1691        );
1692        assert_eq!(shared_prefix(&["abcé1", "abcè2"], 4), None);
1693        // Suffix: 'Ω' and 'é' both end in the continuation byte 0xA9, so the
1694        // overlap starts mid-character; snapping up yields no suffix and no panic.
1695        assert_eq!(shared_suffix(&["x\u{03a9}", "y\u{00e9}"], 1), None);
1696        // A fully-shared multibyte affix is preserved intact.
1697        assert_eq!(
1698            shared_suffix(&["1éabc", "2éabc"], 3).as_deref(),
1699            Some("éabc")
1700        );
1701    }
1702
1703    #[test]
1704    fn random_unique_values_are_volatile() {
1705        let exemplars: Vec<Value> = (0..4)
1706            .map(|i| {
1707                json!({
1708                    "tool": "runner",
1709                    "task": "sync",
1710                    "token": format!("a9f{i}c2d4e6b8a0f1c3d5e7f9b1a3c5d{i}"),
1711                })
1712            })
1713            .collect();
1714        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1715        let token = report.fields.iter().find(|f| f.field == "token").unwrap();
1716        assert_eq!(token.stability, Stability::Volatile);
1717        assert!(!token.selected);
1718    }
1719
1720    // ---- Scoring -------------------------------------------------------------
1721
1722    #[test]
1723    fn baseline_contrast_prefers_rare_fields() {
1724        let exemplars: Vec<Value> = (0..3)
1725            .map(|_| json!({"action": "exfil", "proto": "tcp"}))
1726            .collect();
1727        // proto: tcp is ubiquitous in the baseline; action: exfil never occurs.
1728        let baseline: Vec<Value> = (0..20)
1729            .map(|i| json!({"action": format!("browse{i}"), "proto": "tcp"}))
1730            .collect();
1731        let report = draft(&exemplars, &baseline, &fixed_config()).unwrap();
1732        let action = report.fields.iter().find(|f| f.field == "action").unwrap();
1733        let proto = report.fields.iter().find(|f| f.field == "proto").unwrap();
1734        assert!(
1735            action.score > proto.score,
1736            "baseline-rare field must outrank the ubiquitous one"
1737        );
1738        assert_eq!(proto.baseline_prevalence, Some(1.0));
1739        assert_eq!(action.baseline_prevalence, Some(0.0));
1740    }
1741
1742    #[test]
1743    fn structural_fields_are_demoted_without_baseline() {
1744        let exemplars: Vec<Value> = (0..3)
1745            .map(|_| json!({"hostname": "web-01", "action": "exfil"}))
1746            .collect();
1747        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1748        let host = report
1749            .fields
1750            .iter()
1751            .find(|f| f.field == "hostname")
1752            .unwrap();
1753        let action = report.fields.iter().find(|f| f.field == "action").unwrap();
1754        assert!(action.score > host.score);
1755    }
1756
1757    #[test]
1758    fn deterministic_output_across_runs() {
1759        let exemplars: Vec<Value> = (0..3)
1760            .map(|_| json!({"vendor": "acme", "action": "alert", "sig": "S-1001"}))
1761            .collect();
1762        let a = draft(&exemplars, &[], &fixed_config()).unwrap().rule_yaml;
1763        let b = draft(&exemplars, &[], &fixed_config()).unwrap().rule_yaml;
1764        assert_eq!(a, b, "draft output must be byte-identical across runs");
1765    }
1766
1767    // ---- Modifier inference ---------------------------------------------------
1768
1769    #[test]
1770    fn shared_path_tail_becomes_endswith() {
1771        let exemplars = vec![
1772            json!({"Image": "C:\\Tools\\whoami.exe", "kind": "proc"}),
1773            json!({"Image": "C:\\Windows\\System32\\whoami.exe", "kind": "proc"}),
1774            json!({"Image": "D:\\stage\\whoami.exe", "kind": "proc"}),
1775            json!({"Image": "E:\\x\\whoami.exe", "kind": "proc"}),
1776            json!({"Image": "F:\\y\\whoami.exe", "kind": "proc"}),
1777        ];
1778        let cfg = DraftConfig {
1779            max_value_cardinality: 3,
1780            ..fixed_config()
1781        };
1782        let report = draft(&exemplars, &[], &cfg).unwrap();
1783        assert!(
1784            report.rule_yaml.contains("Image|endswith: '\\whoami.exe'"),
1785            "expected endswith derivation, got:\n{}",
1786            report.rule_yaml
1787        );
1788    }
1789
1790    #[test]
1791    fn shared_prefix_becomes_startswith() {
1792        let exemplars: Vec<Value> = (0..5)
1793            .map(|i| json!({"url": format!("https://evil.example/payload{i}"), "verb": "GET"}))
1794            .collect();
1795        let cfg = DraftConfig {
1796            max_value_cardinality: 3,
1797            ..fixed_config()
1798        };
1799        let report = draft(&exemplars, &[], &cfg).unwrap();
1800        assert!(
1801            report
1802                .rule_yaml
1803                .contains("url|startswith: 'https://evil.example/payload'"),
1804            "expected startswith derivation, got:\n{}",
1805            report.rule_yaml
1806        );
1807    }
1808
1809    #[test]
1810    fn short_generic_tokens_are_never_chosen() {
1811        // The only shared token is 3 chars ("run"), below min_token_len 4.
1812        let exemplars: Vec<Value> = (0..5)
1813            .map(|i| json!({"cmd": format!("{i}zz run q{i}"), "kind": "x"}))
1814            .collect();
1815        let cfg = DraftConfig {
1816            max_value_cardinality: 3,
1817            ..fixed_config()
1818        };
1819        let report = draft(&exemplars, &[], &cfg).unwrap();
1820        let cmd = report.fields.iter().find(|f| f.field == "cmd").unwrap();
1821        assert_eq!(cmd.stability, Stability::Volatile);
1822        assert!(!report.rule_yaml.contains("cmd|contains"));
1823    }
1824
1825    #[test]
1826    fn baseline_generic_token_is_rejected() {
1827        // "powershell" is a stable exemplar token but ubiquitous in baseline.
1828        let exemplars: Vec<Value> = (0..5)
1829            .map(|i| json!({"proc": format!("powershell -x {i}q{i}w{i}"), "kind": "spawn"}))
1830            .collect();
1831        let baseline: Vec<Value> = (0..20)
1832            .map(|i| json!({"proc": format!("powershell -File login{i}.ps1"), "kind": "spawn"}))
1833            .collect();
1834        let cfg = DraftConfig {
1835            max_value_cardinality: 3,
1836            min_fields: 1,
1837            ..fixed_config()
1838        };
1839        let report = draft(&exemplars, &baseline, &cfg).unwrap();
1840        assert!(
1841            !report.rule_yaml.contains("proc|contains: powershell"),
1842            "generic baseline token must be rejected, got:\n{}",
1843            report.rule_yaml
1844        );
1845    }
1846
1847    #[test]
1848    fn wildcard_specials_in_values_are_escaped() {
1849        let exemplars: Vec<Value> = (0..3)
1850            .map(|_| json!({"query": "SELECT * FROM users?", "app": "dbd"}))
1851            .collect();
1852        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1853        assert!(
1854            report.rule_yaml.contains(r"SELECT \* FROM users\?"),
1855            "wildcards must be escaped, got:\n{}",
1856            report.rule_yaml
1857        );
1858        // And the escaped rule still matches the exemplars end-to-end (the
1859        // verification loop enforces this; assert the report agrees).
1860        assert_eq!(report.exemplar_matched, 3);
1861    }
1862
1863    #[test]
1864    fn escape_sigma_value_handles_backslash_adjacency() {
1865        assert_eq!(escape_sigma_value(r"C:\Windows"), r"C:\Windows");
1866        assert_eq!(escape_sigma_value("a*b"), r"a\*b");
1867        assert_eq!(escape_sigma_value("a?b"), r"a\?b");
1868        assert_eq!(escape_sigma_value(r"a\*b"), r"a\\\*b");
1869        assert_eq!(escape_sigma_value(r"a\\b"), r"a\\\\b");
1870        assert_eq!(escape_sigma_value(r"trailing\"), r"trailing\\");
1871    }
1872
1873    // ---- Grouping ----------------------------------------------------------------
1874
1875    #[test]
1876    fn distinct_value_groups_split_into_selections() {
1877        let exemplars = vec![
1878            json!({"Image": "C:\\W\\vssadmin.exe", "CommandLine": "vssadmin delete shadows", "k": "p"}),
1879            json!({"Image": "C:\\W\\vssadmin.exe", "CommandLine": "vssadmin delete shadows", "k": "p"}),
1880            json!({"Image": "C:\\W\\wmic.exe", "CommandLine": "wmic shadowcopy delete", "k": "p"}),
1881            json!({"Image": "C:\\W\\wmic.exe", "CommandLine": "wmic shadowcopy delete", "k": "p"}),
1882        ];
1883        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1884        assert!(
1885            report.rule_yaml.contains("condition: 1 of selection_*"),
1886            "expected a group split, got:\n{}",
1887            report.rule_yaml
1888        );
1889        assert!(report.rule_yaml.contains("selection_vssadmin:"));
1890        assert!(report.rule_yaml.contains("selection_wmic:"));
1891        assert_eq!(report.exemplar_matched, 4);
1892    }
1893
1894    #[test]
1895    fn no_split_when_values_do_not_partition() {
1896        let exemplars: Vec<Value> = (0..4)
1897            .map(|_| json!({"vendor": "acme", "action": "alert"}))
1898            .collect();
1899        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1900        assert!(report.rule_yaml.contains("condition: selection\n"));
1901    }
1902
1903    // ---- Logsource -------------------------------------------------------------
1904
1905    #[test]
1906    fn sysmon_event_id_maps_to_category() {
1907        let exemplars: Vec<Value> = (0..3)
1908            .map(|_| {
1909                json!({
1910                    "Channel": "Microsoft-Windows-Sysmon/Operational",
1911                    "EventID": 1,
1912                    "Image": "C:\\W\\evil.exe",
1913                    "CommandLine": "evil.exe --run",
1914                })
1915            })
1916            .collect();
1917        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1918        assert!(report.rule_yaml.contains("category: process_creation"));
1919        assert!(report.rule_yaml.contains("product: windows"));
1920        assert!(!report.rule_yaml.contains("service: sysmon"));
1921    }
1922
1923    #[test]
1924    fn sysmon_without_shared_event_id_keeps_service() {
1925        let exemplars = vec![
1926            json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 1, "Image": "C:\\W\\a.exe", "RuleName": "t"}),
1927            json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 3, "Image": "C:\\W\\a.exe", "RuleName": "t"}),
1928        ];
1929        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1930        assert!(report.rule_yaml.contains("service: sysmon"));
1931        assert!(report.rule_yaml.contains("product: windows"));
1932    }
1933
1934    #[test]
1935    fn logsource_overrides_win() {
1936        let exemplars: Vec<Value> = (0..3)
1937            .map(|_| json!({"vendor": "acme", "action": "alert"}))
1938            .collect();
1939        let cfg = DraftConfig {
1940            logsource_product: Some("acme_fw".to_string()),
1941            logsource_category: Some("firewall".to_string()),
1942            ..fixed_config()
1943        };
1944        let report = draft(&exemplars, &[], &cfg).unwrap();
1945        assert!(report.rule_yaml.contains("product: acme_fw"));
1946        assert!(report.rule_yaml.contains("category: firewall"));
1947        assert!(!report.rule_yaml.contains("todo"));
1948    }
1949
1950    #[test]
1951    fn unknown_schema_gets_todo_placeholder() {
1952        let exemplars: Vec<Value> = (0..3)
1953            .map(|_| json!({"vendor": "acme", "action": "alert"}))
1954            .collect();
1955        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1956        assert!(report.rule_yaml.contains("product: todo"));
1957        assert!(
1958            report
1959                .warnings
1960                .iter()
1961                .any(|w| w.contains("logsource could not be inferred"))
1962        );
1963    }
1964
1965    // ---- Emission, round-trip, verification -----------------------------------
1966
1967    #[test]
1968    fn draft_round_trips_and_matches_exemplars() {
1969        let exemplars: Vec<Value> = (0..4)
1970            .map(|_| json!({"vendor": "acme", "action": "exfil", "dst_port": 443}))
1971            .collect();
1972        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1973        // Parses and compiles (draft_rule already enforced it; do it again
1974        // from the public surface).
1975        let collection =
1976            rsigma_parser::parse_sigma_yaml(&report.rule_yaml).expect("emitted draft must parse");
1977        let mut engine = Engine::new();
1978        engine.add_collection(&collection).unwrap();
1979        for e in &events(&exemplars) {
1980            assert!(!engine.evaluate(e).is_empty(), "exemplar must match");
1981        }
1982        assert_eq!(report.exemplar_matched, report.exemplar_total);
1983        assert!(
1984            report
1985                .rule_yaml
1986                .contains("id: 00000000-0000-4000-8000-000000000000")
1987        );
1988        assert!(report.rule_yaml.contains("status: experimental"));
1989        assert!(report.rule_yaml.contains("level: medium"));
1990        assert!(report.rule_yaml.contains("date: 2026-07-03"));
1991    }
1992
1993    #[test]
1994    fn typed_values_emit_as_numbers() {
1995        let exemplars: Vec<Value> = (0..3)
1996            .map(|_| json!({"vendor": "acme", "code": 4688}))
1997            .collect();
1998        let report = draft(&exemplars, &[], &fixed_config()).unwrap();
1999        assert!(
2000            report.rule_yaml.contains("code: 4688"),
2001            "integers must emit bare, got:\n{}",
2002            report.rule_yaml
2003        );
2004    }
2005
2006    #[test]
2007    fn baseline_hits_are_counted_with_rate() {
2008        let exemplars: Vec<Value> = (0..3)
2009            .map(|_| json!({"vendor": "acme", "action": "alert"}))
2010            .collect();
2011        let mut baseline: Vec<Value> = (0..8)
2012            .map(|i| json!({"vendor": "other", "action": format!("a{i}")}))
2013            .collect();
2014        // Two baseline events the draft will also match.
2015        baseline.push(json!({"vendor": "acme", "action": "alert"}));
2016        baseline.push(json!({"vendor": "acme", "action": "alert"}));
2017        let report = draft(&exemplars, &baseline, &fixed_config()).unwrap();
2018        assert_eq!(report.baseline_total, 10);
2019        assert_eq!(report.baseline_hits, Some(2));
2020        assert!((report.baseline_hit_rate.unwrap() - 0.2).abs() < 1e-9);
2021        assert!(report.warnings.iter().any(|w| w.contains("baseline")));
2022    }
2023
2024    #[test]
2025    fn skip_baseline_eval_keeps_scoring_but_not_hits() {
2026        let exemplars: Vec<Value> = (0..3)
2027            .map(|_| json!({"vendor": "acme", "action": "alert"}))
2028            .collect();
2029        let baseline: Vec<Value> = (0..5)
2030            .map(|i| json!({"vendor": "other", "action": format!("a{i}")}))
2031            .collect();
2032        let cfg = DraftConfig {
2033            evaluate_baseline: false,
2034            ..fixed_config()
2035        };
2036        let report = draft(&exemplars, &baseline, &cfg).unwrap();
2037        assert_eq!(report.baseline_hits, None);
2038        assert!(
2039            report
2040                .fields
2041                .iter()
2042                .any(|f| f.baseline_prevalence.is_some()),
2043            "contrastive scoring still uses the baseline"
2044        );
2045    }
2046
2047    // ---- Relaxation and error paths ------------------------------------------
2048
2049    #[test]
2050    fn relaxation_drops_partial_prevalence_fields() {
2051        // "extra" appears in half the exemplars; selecting it breaks the AND
2052        // selection, so verification must drop it and still succeed.
2053        let mut exemplars: Vec<Value> = (0..2)
2054            .map(|_| json!({"vendor": "acme", "action": "alert", "extra": "x"}))
2055            .collect();
2056        exemplars.extend((0..2).map(|_| json!({"vendor": "acme", "action": "alert"})));
2057        let cfg = DraftConfig {
2058            min_prevalence: 0.4,
2059            ..fixed_config()
2060        };
2061        let report = draft(&exemplars, &[], &cfg).unwrap();
2062        assert_eq!(report.exemplar_matched, 4);
2063        assert!(!report.rule_yaml.contains("extra"));
2064        assert!(report.warnings.iter().any(|w| w.contains("relaxed")));
2065    }
2066
2067    #[test]
2068    fn floor_errors_instead_of_emitting_overbroad_draft() {
2069        // Two disjoint half-prevalence fields and nothing else: no 2-field AND
2070        // can match every exemplar, and the floor forbids going below 2.
2071        let mut exemplars: Vec<Value> = (0..2)
2072            .map(|_| json!({"alpha": "one", "beta": "x"}))
2073            .collect();
2074        exemplars.extend((0..2).map(|_| json!({"alpha": "two", "gamma": "y"})));
2075        let cfg = DraftConfig {
2076            min_prevalence: 0.4,
2077            min_fields: 2,
2078            max_value_cardinality: 1,
2079            ..fixed_config()
2080        };
2081        let err = draft(&exemplars, &[], &cfg).unwrap_err();
2082        assert!(
2083            matches!(err, DraftError::CannotMatchExemplars { floor: 2, .. }),
2084            "expected the floor error, got: {err}"
2085        );
2086    }
2087
2088    #[test]
2089    fn forced_field_absent_from_exemplars_errors_immediately() {
2090        // "extra" is forced but absent from half the exemplars: relaxation
2091        // must not strip the useful fields around it, it must name the
2092        // culprit and stop.
2093        let mut exemplars: Vec<Value> = (0..2)
2094            .map(|_| json!({"vendor": "acme", "action": "alert", "extra": "x"}))
2095            .collect();
2096        exemplars.extend((0..2).map(|_| json!({"vendor": "acme", "action": "alert"})));
2097        let cfg = DraftConfig {
2098            include_fields: vec!["extra".to_string()],
2099            min_prevalence: 0.4,
2100            ..fixed_config()
2101        };
2102        let err = draft(&exemplars, &[], &cfg).unwrap_err();
2103        match err {
2104            DraftError::ForcedFieldMismatch { fields, failing } => {
2105                assert_eq!(fields, vec!["extra".to_string()]);
2106                assert_eq!(failing, vec![2, 3]);
2107            }
2108            other => panic!("expected ForcedFieldMismatch, got: {other}"),
2109        }
2110    }
2111
2112    #[test]
2113    fn no_exemplars_is_an_error() {
2114        let err = draft(&[], &[], &fixed_config()).unwrap_err();
2115        assert!(matches!(err, DraftError::NoExemplars));
2116    }
2117
2118    #[test]
2119    fn all_volatile_fields_is_an_error() {
2120        let exemplars: Vec<Value> = (0..3)
2121            .map(|i| {
2122                json!({
2123                    "UtcTime": format!("2026-07-03T12:00:0{i}Z"),
2124                    "ProcessGuid": format!("6bde842e-a2f4-441e-b027-3aa79b1b2fc{i}"),
2125                })
2126            })
2127            .collect();
2128        let err = draft(&exemplars, &[], &fixed_config()).unwrap_err();
2129        assert!(matches!(err, DraftError::NoCandidateFields(3)));
2130    }
2131
2132    // ---- Flags -------------------------------------------------------------------
2133
2134    #[test]
2135    fn include_and_exclude_fields_are_honored() {
2136        let exemplars: Vec<Value> = (0..3)
2137            .map(|_| json!({"vendor": "acme", "action": "alert", "noise": "same"}))
2138            .collect();
2139        let cfg = DraftConfig {
2140            include_fields: vec!["noise".to_string()],
2141            exclude_fields: vec!["vendor".to_string()],
2142            max_fields: 2,
2143            ..fixed_config()
2144        };
2145        let report = draft(&exemplars, &[], &cfg).unwrap();
2146        assert!(report.rule_yaml.contains("noise: same"));
2147        assert!(!report.rule_yaml.contains("vendor"));
2148    }
2149
2150    #[test]
2151    fn title_override_and_derived_title() {
2152        let exemplars: Vec<Value> = (0..3)
2153            .map(|_| json!({"vendor": "acme", "action": "alert"}))
2154            .collect();
2155        let derived = draft(&exemplars, &[], &fixed_config()).unwrap();
2156        assert!(
2157            derived.rule_yaml.starts_with("title: 'Draft:")
2158                || derived.rule_yaml.starts_with("title: Draft"),
2159            "derived title expected, got:\n{}",
2160            derived.rule_yaml
2161        );
2162        let cfg = DraftConfig {
2163            title: Some("Acme Exfil Detection".to_string()),
2164            ..fixed_config()
2165        };
2166        let titled = draft(&exemplars, &[], &cfg).unwrap();
2167        assert!(titled.rule_yaml.starts_with("title: Acme Exfil Detection"));
2168    }
2169}