use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use serde::Serialize;
use crate::engine::Engine;
use crate::event::{Event, EventValue};
use crate::schema::SchemaClassifier;
use crate::schema_discovery::FieldProfile;
#[derive(Debug, Clone)]
pub struct DraftConfig {
pub max_fields: usize,
pub min_fields: usize,
pub min_prevalence: f64,
pub max_value_cardinality: usize,
pub min_token_len: usize,
pub max_baseline_token_prevalence: f64,
pub include_fields: Vec<String>,
pub exclude_fields: Vec<String>,
pub title: Option<String>,
pub rule_id: Option<String>,
pub date: Option<String>,
pub logsource_category: Option<String>,
pub logsource_product: Option<String>,
pub logsource_service: Option<String>,
pub evaluate_baseline: bool,
}
impl Default for DraftConfig {
fn default() -> Self {
Self {
max_fields: 4,
min_fields: 2,
min_prevalence: 1.0,
max_value_cardinality: 4,
min_token_len: 4,
max_baseline_token_prevalence: 0.05,
include_fields: Vec::new(),
exclude_fields: Vec::new(),
title: None,
rule_id: None,
date: None,
logsource_category: None,
logsource_product: None,
logsource_service: None,
evaluate_baseline: true,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum DraftError {
#[error("no exemplar events to draft from")]
NoExemplars,
#[error(
"no candidate fields: every field was volatile (timestamps, ids, unique values), \
excluded, or below the prevalence threshold ({0} exemplars profiled)"
)]
NoCandidateFields(usize),
#[error(
"draft cannot match all exemplars: {matched}/{total} match at the {floor}-field floor; \
exemplars may be too heterogeneous for one rule (failing exemplar indexes: {failing:?})"
)]
CannotMatchExemplars {
matched: usize,
total: usize,
floor: usize,
failing: Vec<usize>,
},
#[error(
"forced field(s) {fields:?} are absent from exemplar(s) {failing:?}; \
remove the --include-field or drop those exemplars"
)]
ForcedFieldMismatch {
fields: Vec<String>,
failing: Vec<usize>,
},
#[error("internal error: emitted draft failed to {stage}: {message}")]
Internal { stage: String, message: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Stability {
Constant,
Enumerable,
Patterned,
Volatile,
}
impl fmt::Display for Stability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Stability::Constant => "constant",
Stability::Enumerable => "enumerable",
Stability::Patterned => "patterned",
Stability::Volatile => "volatile",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DraftFieldReport {
pub field: String,
pub score: f64,
pub stability: Stability,
pub modifier: String,
pub values: Vec<String>,
pub baseline_prevalence: Option<f64>,
pub selected: bool,
}
#[derive(Debug, Clone)]
pub struct DraftReport {
pub rule_yaml: String,
pub fields: Vec<DraftFieldReport>,
pub exemplar_total: usize,
pub exemplar_matched: usize,
pub baseline_total: usize,
pub baseline_hits: Option<usize>,
pub baseline_hit_rate: Option<f64>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
enum DraftValue {
Str(String),
Int(i64),
Float(f64),
Bool(bool),
}
impl DraftValue {
fn from_event_value(v: &EventValue<'_>) -> Option<Self> {
match v {
EventValue::Str(s) => Some(DraftValue::Str(s.to_string())),
EventValue::Int(n) => Some(DraftValue::Int(*n)),
EventValue::Float(f) => Some(DraftValue::Float(*f)),
EventValue::Bool(b) => Some(DraftValue::Bool(*b)),
EventValue::Null | EventValue::Array(_) | EventValue::Map(_) => None,
}
}
fn as_display(&self) -> String {
match self {
DraftValue::Str(s) => s.clone(),
DraftValue::Int(n) => n.to_string(),
DraftValue::Float(f) => f.to_string(),
DraftValue::Bool(b) => b.to_string(),
}
}
fn as_match_str(&self) -> String {
self.as_display()
}
}
#[derive(Debug, Clone, PartialEq)]
enum ValueForm {
Exact(DraftValue),
OneOf(Vec<DraftValue>),
EndsWith(String),
StartsWith(String),
Contains(String),
ContainsAll(Vec<String>),
}
impl ValueForm {
fn modifier(&self) -> &'static str {
match self {
ValueForm::Exact(_) | ValueForm::OneOf(_) => "",
ValueForm::EndsWith(_) => "|endswith",
ValueForm::StartsWith(_) => "|startswith",
ValueForm::Contains(_) => "|contains",
ValueForm::ContainsAll(_) => "|contains|all",
}
}
fn display_values(&self) -> Vec<String> {
match self {
ValueForm::Exact(v) => vec![v.as_display()],
ValueForm::OneOf(vs) => vs.iter().map(|v| v.as_display()).collect(),
ValueForm::EndsWith(s) => vec![format!("*{s}")],
ValueForm::StartsWith(s) => vec![format!("{s}*")],
ValueForm::Contains(s) => vec![format!("*{s}*")],
ValueForm::ContainsAll(ts) => ts.iter().map(|t| format!("*{t}*")).collect(),
}
}
fn matches_lower(&self, lv: &str) -> bool {
match self {
ValueForm::Exact(v) => lv == v.as_match_str().to_lowercase(),
ValueForm::OneOf(vs) => vs.iter().any(|v| lv == v.as_match_str().to_lowercase()),
ValueForm::EndsWith(s) => lv.ends_with(&s.to_lowercase()),
ValueForm::StartsWith(s) => lv.starts_with(&s.to_lowercase()),
ValueForm::Contains(t) => lv.contains(&t.to_lowercase()),
ValueForm::ContainsAll(ts) => ts.iter().all(|t| lv.contains(&t.to_lowercase())),
}
}
}
#[derive(Debug, Clone)]
struct DraftFieldProfile {
stats: FieldProfile,
values: Vec<Option<DraftValue>>,
stability: Stability,
form: Option<ValueForm>,
score: f64,
baseline_prevalence: Option<f64>,
forced: bool,
}
impl DraftFieldProfile {
fn field(&self) -> &str {
&self.stats.field
}
fn distinct(&self) -> Vec<&DraftValue> {
let mut seen: Vec<&DraftValue> = Vec::new();
for v in self.values.iter().flatten() {
if !seen.contains(&v) {
seen.push(v);
}
}
seen
}
}
pub fn draft_rule<E: Event>(
exemplars: &[E],
baseline: &[E],
config: &DraftConfig,
) -> Result<DraftReport, DraftError> {
if exemplars.is_empty() {
return Err(DraftError::NoExemplars);
}
let mut warnings: Vec<String> = Vec::new();
let mut profiles = profile_fields(exemplars, config, &mut warnings);
if profiles.is_empty() {
return Err(DraftError::NoCandidateFields(exemplars.len()));
}
for p in &mut profiles {
infer_form(p, config);
}
if !baseline.is_empty() {
for p in &mut profiles {
apply_baseline(p, baseline, config);
}
}
let has_baseline = !baseline.is_empty();
for p in &mut profiles {
p.score = score_field(p, has_baseline);
}
profiles.sort_by(|a, b| {
b.forced
.cmp(&a.forced)
.then_with(|| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| a.field().cmp(b.field()))
});
let usable: Vec<usize> = profiles
.iter()
.enumerate()
.filter(|(_, p)| p.form.is_some() && p.stability != Stability::Volatile)
.map(|(i, _)| i)
.collect();
if usable.is_empty() {
return Err(DraftError::NoCandidateFields(exemplars.len()));
}
let mut selected: Vec<usize> = usable.iter().copied().take(config.max_fields).collect();
if selected.len() < config.min_fields {
warnings.push(format!(
"only {} usable field(s) found (floor is {}); the draft may be broad",
selected.len(),
config.min_fields
));
}
let logsource = infer_logsource(exemplars, config, &mut warnings);
let floor = config.min_fields.min(selected.len()).max(1);
let (yaml, matched, failing) = loop {
let detection = build_detection(&profiles, &selected, exemplars, config);
let yaml = emit_rule_yaml(&profiles, &selected, &detection, &logsource, config);
let engine = compile_draft(&yaml)?;
let failing: Vec<usize> = exemplars
.iter()
.enumerate()
.filter(|(_, e)| engine.evaluate(e).is_empty())
.map(|(i, _)| i)
.collect();
if failing.is_empty() {
break (yaml, exemplars.len(), failing);
}
let absent_in_failing =
|i: usize| failing.iter().any(|&idx| profiles[i].values[idx].is_none());
let forced_culprits: Vec<String> = selected
.iter()
.filter(|&&i| profiles[i].forced && absent_in_failing(i))
.map(|&i| profiles[i].field().to_string())
.collect();
if !forced_culprits.is_empty() {
return Err(DraftError::ForcedFieldMismatch {
fields: forced_culprits,
failing,
});
}
if selected.len() <= floor {
return Err(DraftError::CannotMatchExemplars {
matched: exemplars.len() - failing.len(),
total: exemplars.len(),
floor,
failing,
});
}
let drop_pos = selected
.iter()
.rposition(|&i| !profiles[i].forced && absent_in_failing(i))
.or_else(|| selected.iter().rposition(|&i| !profiles[i].forced));
let Some(pos) = drop_pos else {
return Err(DraftError::CannotMatchExemplars {
matched: exemplars.len() - failing.len(),
total: exemplars.len(),
floor,
failing,
});
};
let dropped = selected.remove(pos);
warnings.push(format!(
"relaxed: dropped field '{}' because the draft did not match every exemplar with it",
profiles[dropped].field()
));
};
debug_assert!(failing.is_empty());
let (baseline_hits, baseline_hit_rate) = if !baseline.is_empty() && config.evaluate_baseline {
let engine = compile_draft(&yaml)?;
let hits = baseline
.iter()
.filter(|e| !engine.evaluate(e).is_empty())
.count();
let rate = hits as f64 / baseline.len() as f64;
if hits > 0 {
warnings.push(format!(
"draft matches {hits}/{} baseline events ({:.1}%); consider a tighter field",
baseline.len(),
rate * 100.0
));
}
(Some(hits), Some(rate))
} else {
(None, None)
};
for w in rsigma_parser::lint_yaml_str(&yaml) {
warnings.push(format!("lint {}: {}", w.rule, w.message));
}
let selected_set: BTreeSet<usize> = selected.iter().copied().collect();
let fields = profiles
.iter()
.enumerate()
.map(|(i, p)| DraftFieldReport {
field: p.field().to_string(),
score: p.score,
stability: p.stability,
modifier: p
.form
.as_ref()
.map(|f| f.modifier().trim_start_matches('|').to_string())
.unwrap_or_default(),
values: p
.form
.as_ref()
.map(|f| f.display_values())
.unwrap_or_else(|| {
p.distinct()
.into_iter()
.take(4)
.map(|v| v.as_display())
.collect()
}),
baseline_prevalence: p.baseline_prevalence,
selected: selected_set.contains(&i),
})
.collect();
Ok(DraftReport {
rule_yaml: yaml,
fields,
exemplar_total: exemplars.len(),
exemplar_matched: matched,
baseline_total: baseline.len(),
baseline_hits,
baseline_hit_rate,
warnings,
})
}
fn profile_fields<E: Event>(
exemplars: &[E],
config: &DraftConfig,
warnings: &mut Vec<String>,
) -> Vec<DraftFieldProfile> {
let mut all_fields: BTreeSet<String> = BTreeSet::new();
for e in exemplars {
for k in e.field_keys() {
all_fields.insert(k.into_owned());
}
}
let excluded = |f: &str| {
config
.exclude_fields
.iter()
.any(|x| x.eq_ignore_ascii_case(f))
};
let forced = |f: &str| {
config
.include_fields
.iter()
.any(|x| x.eq_ignore_ascii_case(f))
};
for inc in &config.include_fields {
if !all_fields.iter().any(|f| f.eq_ignore_ascii_case(inc)) {
warnings.push(format!(
"--include-field '{inc}' does not appear in any exemplar; ignored"
));
}
}
let total = exemplars.len();
let mut out = Vec::new();
for field in all_fields {
if excluded(&field) {
continue;
}
let values: Vec<Option<DraftValue>> = exemplars
.iter()
.map(|e| {
e.get_field(&field)
.and_then(|v| DraftValue::from_event_value(&v))
})
.collect();
let present = exemplars
.iter()
.filter(|e| e.get_field(&field).is_some())
.count();
let prevalence = present as f64 / total as f64;
let is_forced = forced(&field);
if prevalence < config.min_prevalence && !is_forced {
continue;
}
if is_forced && prevalence < 1.0 {
warnings.push(format!(
"--include-field '{field}' is absent from some exemplars \
({present}/{total}); the draft may not match them"
));
}
let mut distinct_values: Vec<String> = values
.iter()
.flatten()
.map(|v| v.as_display())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
distinct_values.sort();
let stats = FieldProfile {
field: field.clone(),
present: present as u64,
total: total as u64,
distinct_values,
value_overflow: false,
};
let stability = classify_stability(&field, &values, present, config);
out.push(DraftFieldProfile {
stats,
values,
stability,
form: None,
score: 0.0,
baseline_prevalence: None,
forced: is_forced,
});
}
out
}
fn classify_stability(
field: &str,
values: &[Option<DraftValue>],
present: usize,
config: &DraftConfig,
) -> Stability {
let scalars: Vec<&DraftValue> = values.iter().flatten().collect();
if scalars.is_empty() || scalars.len() < present {
return Stability::Volatile;
}
if is_volatile_name(field) {
return Stability::Volatile;
}
if scalars.iter().any(|v| is_volatile_value(v)) {
return Stability::Volatile;
}
let mut distinct: Vec<&DraftValue> = Vec::new();
for v in &scalars {
if !distinct.contains(v) {
distinct.push(v);
}
}
if distinct.len() == 1 {
return Stability::Constant;
}
if distinct.len() <= config.max_value_cardinality && distinct.len() < scalars.len() {
return Stability::Enumerable;
}
let strings: Vec<&str> = distinct
.iter()
.filter_map(|v| match v {
DraftValue::Str(s) => Some(s.as_str()),
_ => None,
})
.collect();
if strings.len() == distinct.len() {
if distinct.len() == scalars.len() && strings.iter().all(|s| is_random_string(s)) {
return Stability::Volatile;
}
if shared_suffix(&strings, config.min_token_len).is_some()
|| shared_prefix(&strings, config.min_token_len).is_some()
|| !shared_tokens(&strings, config.min_token_len).is_empty()
{
return Stability::Patterned;
}
if distinct.len() <= config.max_value_cardinality {
return Stability::Enumerable;
}
} else if distinct.len() <= config.max_value_cardinality {
return Stability::Enumerable;
}
Stability::Volatile
}
fn is_volatile_name(field: &str) -> bool {
let segment = field.rsplit('.').next().unwrap_or(field);
let last = segment.to_lowercase();
let normalized: String = last.chars().filter(|c| *c != '_' && *c != '-').collect();
if last == "@timestamp" || normalized == "ts" {
return true;
}
if segment_words(segment)
.iter()
.any(|w| matches!(w.as_str(), "time" | "date" | "datetime" | "timestamp"))
{
return true;
}
if normalized.contains("timestamp")
|| normalized.contains("guid")
|| normalized.contains("uuid")
{
return true;
}
matches!(
normalized.as_str(),
"recordid"
| "recordnumber"
| "eventrecordid"
| "sequence"
| "seq"
| "seqno"
| "processid"
| "pid"
| "parentprocessid"
| "ppid"
| "threadid"
| "tid"
| "logonid"
| "sessionid"
| "executionprocessid"
| "executionthreadid"
)
}
fn segment_words(segment: &str) -> Vec<String> {
let mut words: Vec<String> = Vec::new();
let mut cur = String::new();
let mut prev: Option<char> = None;
for c in segment.chars() {
if !c.is_ascii_alphanumeric() {
if !cur.is_empty() {
words.push(std::mem::take(&mut cur));
}
prev = None;
continue;
}
if let Some(p) = prev
&& c.is_ascii_uppercase()
&& (p.is_ascii_lowercase() || p.is_ascii_digit())
&& !cur.is_empty()
{
words.push(std::mem::take(&mut cur));
}
cur.push(c.to_ascii_lowercase());
prev = Some(c);
}
if !cur.is_empty() {
words.push(cur);
}
words
}
fn is_volatile_value(value: &DraftValue) -> bool {
match value {
DraftValue::Str(s) => is_timestamp_string(s) || is_uuid_string(s),
DraftValue::Int(n) => is_epoch_number(*n as f64),
DraftValue::Float(f) => is_epoch_number(*f),
DraftValue::Bool(_) => false,
}
}
fn is_timestamp_string(s: &str) -> bool {
let b = s.as_bytes();
if b.len() < 10 {
return false;
}
let date = b[0].is_ascii_digit()
&& b[1].is_ascii_digit()
&& b[2].is_ascii_digit()
&& b[3].is_ascii_digit()
&& b[4] == b'-'
&& b[5].is_ascii_digit()
&& b[6].is_ascii_digit()
&& b[7] == b'-'
&& b[8].is_ascii_digit()
&& b[9].is_ascii_digit();
if !date {
return false;
}
b.len() == 10 || b[10] == b'T' || b[10] == b' '
}
fn is_uuid_string(s: &str) -> bool {
let s = s.strip_prefix('{').unwrap_or(s);
let s = s.strip_suffix('}').unwrap_or(s);
if s.len() != 36 {
return false;
}
s.char_indices().all(|(i, c)| match i {
8 | 13 | 18 | 23 => c == '-',
_ => c.is_ascii_hexdigit(),
})
}
fn is_epoch_number(n: f64) -> bool {
const RANGES: [(f64, f64); 4] = [
(1e9, 1e10), (1e12, 1e13), (1e15, 1e16), (1e18, 1e19), ];
RANGES.iter().any(|(lo, hi)| n >= *lo && n < *hi)
}
fn is_random_string(s: &str) -> bool {
s.len() >= 16
&& s.chars().all(|c| c.is_ascii_alphanumeric())
&& s.chars().any(|c| c.is_ascii_digit())
&& s.chars().any(|c| c.is_ascii_alphabetic())
}
fn is_structural_name(field: &str) -> bool {
let last = field.rsplit('.').next().unwrap_or(field).to_lowercase();
matches!(
last.as_str(),
"host" | "hostname" | "computer" | "computername" | "domain" | "level" | "severity"
)
}
fn shared_prefix(values: &[&str], min_len: usize) -> Option<String> {
let first = values.first()?;
let mut len = first.len();
for v in &values[1..] {
len = len.min(common_prefix_len(first, v));
}
while len > 0 && !first.is_char_boundary(len) {
len -= 1;
}
if len >= min_len && values.iter().any(|v| v.len() > len) {
Some(first[..len].to_string())
} else {
None
}
}
fn shared_suffix(values: &[&str], min_len: usize) -> Option<String> {
let first = values.first()?;
let mut len = first.len();
for v in &values[1..] {
len = len.min(common_suffix_len(first, v));
}
let mut start = first.len() - len;
while start < first.len() && !first.is_char_boundary(start) {
start += 1;
}
let len = first.len() - start;
if len >= min_len && values.iter().any(|v| v.len() > len) {
Some(first[start..].to_string())
} else {
None
}
}
fn common_prefix_len(a: &str, b: &str) -> usize {
a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
}
fn common_suffix_len(a: &str, b: &str) -> usize {
a.bytes()
.rev()
.zip(b.bytes().rev())
.take_while(|(x, y)| x == y)
.count()
}
fn shared_tokens(values: &[&str], min_len: usize) -> Vec<String> {
let Some(first) = values.first() else {
return Vec::new();
};
let lowers: Vec<String> = values.iter().map(|v| v.to_lowercase()).collect();
let mut tokens: Vec<String> = tokenize(first, min_len)
.into_iter()
.filter(|t| {
let lt = t.to_lowercase();
lowers.iter().all(|v| v.contains(<))
})
.collect();
tokens.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
tokens.dedup();
tokens.truncate(3);
tokens
}
fn tokenize(s: &str, min_len: usize) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for token in s.split(|c: char| !c.is_ascii_alphanumeric()) {
if token.len() >= min_len && !out.iter().any(|t| t == token) {
out.push(token.to_string());
}
}
out
}
fn infer_form(profile: &mut DraftFieldProfile, config: &DraftConfig) {
if profile.stability == Stability::Volatile {
return;
}
let distinct: Vec<DraftValue> = profile.distinct().into_iter().cloned().collect();
if profile.stability == Stability::Patterned {
let strings: Vec<&str> = distinct
.iter()
.filter_map(|v| match v {
DraftValue::Str(s) => Some(s.as_str()),
_ => None,
})
.collect();
if strings.len() == distinct.len() {
profile.form = derive_pattern_form(&strings, config);
}
}
if profile.form.is_none() {
profile.form = derive_form(&distinct, config);
}
if profile.form.is_none() {
profile.stability = Stability::Volatile;
}
}
fn derive_form(distinct: &[DraftValue], config: &DraftConfig) -> Option<ValueForm> {
match distinct {
[] => None,
[one] => Some(ValueForm::Exact(one.clone())),
many if many.len() <= config.max_value_cardinality => Some(ValueForm::OneOf(many.to_vec())),
many => {
let strings: Vec<&str> = many
.iter()
.filter_map(|v| match v {
DraftValue::Str(s) => Some(s.as_str()),
_ => None,
})
.collect();
if strings.len() != many.len() {
return None;
}
derive_pattern_form(&strings, config)
}
}
}
fn derive_pattern_form(strings: &[&str], config: &DraftConfig) -> Option<ValueForm> {
if let Some(suffix) = shared_suffix(strings, config.min_token_len) {
return Some(ValueForm::EndsWith(suffix));
}
if let Some(prefix) = shared_prefix(strings, config.min_token_len) {
return Some(ValueForm::StartsWith(prefix));
}
let tokens = shared_tokens(strings, config.min_token_len);
match tokens.len() {
0 => None,
1 => Some(ValueForm::Contains(tokens.into_iter().next().unwrap())),
_ => Some(ValueForm::ContainsAll(tokens)),
}
}
fn apply_baseline<E: Event>(profile: &mut DraftFieldProfile, baseline: &[E], config: &DraftConfig) {
let Some(form) = profile.form.clone() else {
return;
};
let field = profile.field().to_string();
let values: Vec<String> = baseline
.iter()
.filter_map(|e| {
e.get_field(&field)
.and_then(|v| v.as_str().map(|s| s.to_lowercase()))
})
.collect();
let match_count = |f: &ValueForm| values.iter().filter(|lv| f.matches_lower(lv)).count();
let token_is_generic = |t: &str| {
let lt = t.to_lowercase();
let hits = values.iter().filter(|lv| lv.contains(<)).count();
hits as f64 / baseline.len() as f64 > config.max_baseline_token_prevalence
};
let guarded = match form {
ValueForm::Contains(ref t) => {
if token_is_generic(t) {
profile.form = None;
profile.stability = Stability::Volatile;
return;
}
form
}
ValueForm::ContainsAll(ref ts) => {
let kept: Vec<String> = ts
.iter()
.filter(|t| !token_is_generic(t))
.cloned()
.collect();
match kept.len() {
0 => {
profile.form = None;
profile.stability = Stability::Volatile;
return;
}
1 => ValueForm::Contains(kept.into_iter().next().unwrap()),
_ => ValueForm::ContainsAll(kept),
}
}
other => other,
};
let hits = match_count(&guarded);
profile.form = Some(guarded);
profile.baseline_prevalence = Some(hits as f64 / baseline.len() as f64);
}
fn score_field(profile: &DraftFieldProfile, has_baseline: bool) -> f64 {
if profile.form.is_none() || profile.stability == Stability::Volatile {
return f64::MIN;
}
let stability_base = match profile.stability {
Stability::Constant => 3.0,
Stability::Enumerable => 2.0,
Stability::Patterned => 1.0,
Stability::Volatile => 0.0,
};
let prevalence = profile.stats.prevalence();
match profile.baseline_prevalence {
Some(bp) => stability_base * prevalence * (1.0 - bp),
None => {
let demotion = if !has_baseline && is_structural_name(profile.field()) {
0.5
} else {
0.0
};
stability_base * prevalence - demotion
}
}
}
struct Selection {
name: String,
entries: Vec<(String, ValueForm)>,
}
struct DetectionBlock {
selections: Vec<Selection>,
condition: String,
}
fn build_detection<E: Event>(
profiles: &[DraftFieldProfile],
selected: &[usize],
exemplars: &[E],
config: &DraftConfig,
) -> DetectionBlock {
if let Some(block) = try_group_split(profiles, selected, exemplars, config) {
return block;
}
let entries: Vec<(String, ValueForm)> = selected
.iter()
.filter_map(|&i| {
profiles[i]
.form
.clone()
.map(|f| (profiles[i].field().to_string(), f))
})
.collect();
DetectionBlock {
selections: vec![Selection {
name: "selection".to_string(),
entries,
}],
condition: "selection".to_string(),
}
}
const MAX_VALUE_GROUPS: usize = 3;
fn try_group_split<E: Event>(
profiles: &[DraftFieldProfile],
selected: &[usize],
exemplars: &[E],
config: &DraftConfig,
) -> Option<DetectionBlock> {
if selected.len() < 2 || exemplars.len() < 2 {
return None;
}
let (splitter_pos, splitter) = selected.iter().enumerate().find_map(|(pos, &i)| {
let p = &profiles[i];
let d = p.distinct();
let all_str = d.iter().all(|v| matches!(v, DraftValue::Str(_)));
if all_str && d.len() >= 2 && d.len() <= MAX_VALUE_GROUPS {
Some((pos, i))
} else {
None
}
})?;
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
for (idx, v) in profiles[splitter].values.iter().enumerate() {
let key = v.as_ref()?.as_display();
match groups.iter_mut().find(|(k, _)| *k == key) {
Some((_, members)) => members.push(idx),
None => groups.push((key, vec![idx])),
}
}
if groups.len() < 2 {
return None;
}
if groups.iter().any(|(_, members)| members.len() < 2) {
return None;
}
let improves = selected.iter().enumerate().any(|(pos, &i)| {
if pos == splitter_pos {
return false;
}
let p = &profiles[i];
if p.distinct().len() < 2 {
return false;
}
groups.iter().all(|(_, members)| {
let mut vals = members.iter().filter_map(|&m| p.values[m].as_ref());
let first = vals.next();
first.is_some() && vals.all(|v| Some(v) == first)
})
});
if !improves {
return None;
}
let mut used_names: BTreeMap<String, u32> = BTreeMap::new();
let selections: Vec<Selection> = groups
.iter()
.map(|(key, members)| {
let entries: Vec<(String, ValueForm)> = selected
.iter()
.filter_map(|&i| {
let p = &profiles[i];
let mut distinct: Vec<DraftValue> = Vec::new();
for &m in members {
if let Some(v) = &p.values[m]
&& !distinct.contains(v)
{
distinct.push(v.clone());
}
}
derive_form(&distinct, config).map(|f| (p.field().to_string(), f))
})
.collect();
let base = selection_slug(key);
let n = used_names.entry(base.clone()).or_insert(0);
*n += 1;
let name = if *n == 1 {
format!("selection_{base}")
} else {
format!("selection_{base}_{n}")
};
Selection { name, entries }
})
.collect();
Some(DetectionBlock {
selections,
condition: "1 of selection_*".to_string(),
})
}
fn selection_slug(value: &str) -> String {
let last_segment = value.rsplit(['\\', '/']).next().unwrap_or(value);
let stem = last_segment
.split_once('.')
.map(|(stem, _)| stem)
.unwrap_or(last_segment);
let first_token = stem
.split(|c: char| !c.is_ascii_alphanumeric())
.find(|t| !t.is_empty())
.unwrap_or("");
let out: String = first_token.to_ascii_lowercase();
if out.is_empty() {
"group".to_string()
} else {
out
}
}
#[derive(Debug, Clone, Default)]
struct DraftLogsource {
category: Option<String>,
product: Option<String>,
service: Option<String>,
inferred: bool,
}
fn sysmon_category(event_id: i64) -> Option<&'static str> {
Some(match event_id {
1 => "process_creation",
3 => "network_connection",
6 => "driver_load",
7 => "image_load",
8 => "create_remote_thread",
10 => "process_access",
11 => "file_event",
22 => "dns_query",
23 => "file_delete",
_ => return None,
})
}
fn infer_logsource<E: Event>(
exemplars: &[E],
config: &DraftConfig,
warnings: &mut Vec<String>,
) -> DraftLogsource {
let mut out = DraftLogsource::default();
let classifier = SchemaClassifier::builtin();
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for e in exemplars {
if let Some(m) = classifier.classify(e) {
*counts.entry(m.name).or_insert(0) += 1;
}
}
let majority = counts
.iter()
.max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
.map(|(name, _)| name.as_str());
match majority {
Some("sysmon") => {
out.product = Some("windows".to_string());
let ids: BTreeSet<i64> = exemplars
.iter()
.filter_map(|e| e.get_field("EventID").and_then(|v| v.as_i64()))
.collect();
let category = if ids.len() == 1 {
ids.first().copied().and_then(sysmon_category)
} else {
None
};
match category {
Some(c) => out.category = Some(c.to_string()),
None => out.service = Some("sysmon".to_string()),
}
out.inferred = true;
}
Some("windows_eventlog") | Some("ecs_windows") => {
out.product = Some("windows".to_string());
out.inferred = true;
}
Some("ecs_linux") => {
out.product = Some("linux".to_string());
out.inferred = true;
}
_ => {}
}
if config.logsource_category.is_some() {
out.category = config.logsource_category.clone();
out.inferred = true;
}
if config.logsource_product.is_some() {
out.product = config.logsource_product.clone();
out.inferred = true;
}
if config.logsource_service.is_some() {
out.service = config.logsource_service.clone();
out.inferred = true;
}
if !out.inferred {
warnings.push(
"logsource could not be inferred from the exemplars; \
replace the 'todo' placeholder before committing"
.to_string(),
);
out.product = Some("todo".to_string());
}
out
}
fn escape_sigma_value(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < chars.len() {
match chars[i] {
'*' => out.push_str("\\*"),
'?' => out.push_str("\\?"),
'\\' => {
let mut j = i;
while j < chars.len() && chars[j] == '\\' {
j += 1;
}
let run = j - i;
let next = chars.get(j);
let must_escape = run > 1 || matches!(next, Some('*') | Some('?') | None);
for _ in 0..run {
if must_escape {
out.push_str("\\\\");
} else {
out.push('\\');
}
}
i = j;
continue;
}
c => out.push(c),
}
i += 1;
}
out
}
fn yaml_str(s: &str) -> String {
let bare_safe = !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
&& !s.starts_with('-')
&& s.parse::<f64>().is_err()
&& !matches!(
s.to_ascii_lowercase().as_str(),
"true" | "false" | "null" | "yes" | "no" | "on" | "off"
);
if bare_safe {
s.to_string()
} else {
format!("'{}'", s.replace('\'', "''"))
}
}
fn yaml_title_str(s: &str) -> String {
let bare_safe = !s.is_empty()
&& s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
&& !s.ends_with(' ')
&& !s.contains(": ")
&& !s.contains(" #")
&& s.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.' | ',' | '(' | ')')
});
if bare_safe {
s.to_string()
} else {
yaml_str(s)
}
}
fn emit_value(v: &DraftValue) -> String {
match v {
DraftValue::Str(s) => yaml_str(&escape_sigma_value(s)),
DraftValue::Int(n) => n.to_string(),
DraftValue::Float(f) => f.to_string(),
DraftValue::Bool(b) => b.to_string(),
}
}
fn emit_form(out: &mut String, field: &str, form: &ValueForm, indent: &str) {
let key = format!("{field}{}", form.modifier());
match form {
ValueForm::Exact(v) => {
out.push_str(&format!("{indent}{key}: {}\n", emit_value(v)));
}
ValueForm::OneOf(vs) => {
out.push_str(&format!("{indent}{key}:\n"));
for v in vs {
out.push_str(&format!("{indent} - {}\n", emit_value(v)));
}
}
ValueForm::EndsWith(s) | ValueForm::StartsWith(s) | ValueForm::Contains(s) => {
out.push_str(&format!(
"{indent}{key}: {}\n",
yaml_str(&escape_sigma_value(s))
));
}
ValueForm::ContainsAll(ts) => {
out.push_str(&format!("{indent}{key}:\n"));
for t in ts {
out.push_str(&format!(
"{indent} - {}\n",
yaml_str(&escape_sigma_value(t))
));
}
}
}
}
fn title_marker(profiles: &[DraftFieldProfile], selected: &[usize]) -> Option<String> {
let first = selected.first().map(|&i| &profiles[i])?;
let form = first.form.as_ref()?;
let raw = match form {
ValueForm::Exact(v) => v.as_display(),
ValueForm::OneOf(vs) => vs.first().map(|v| v.as_display()).unwrap_or_default(),
ValueForm::EndsWith(s) | ValueForm::StartsWith(s) | ValueForm::Contains(s) => s.clone(),
ValueForm::ContainsAll(ts) => ts.first().cloned().unwrap_or_default(),
};
let trimmed = raw.trim_matches(|c: char| !c.is_ascii_alphanumeric());
if trimmed.is_empty() {
None
} else {
Some(format!("{trimmed} ({})", first.field()))
}
}
fn emit_rule_yaml(
profiles: &[DraftFieldProfile],
selected: &[usize],
detection: &DetectionBlock,
logsource: &DraftLogsource,
config: &DraftConfig,
) -> String {
let title = config.title.clone().unwrap_or_else(|| {
title_marker(profiles, selected)
.map(|m| format!("Draft: {m}"))
.unwrap_or_else(|| "Draft rule".to_string())
});
let date = config
.date
.clone()
.unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%d").to_string());
let mut out = String::new();
out.push_str(&format!("title: {}\n", yaml_title_str(&title)));
if let Some(id) = &config.rule_id {
out.push_str(&format!("id: {id}\n"));
}
out.push_str("status: experimental\n");
out.push_str("description: 'TODO: describe what this rule detects and why it matters.'\n");
out.push_str("author: 'TODO: your name'\n");
out.push_str(&format!("date: {date}\n"));
out.push_str("logsource:\n");
if let Some(c) = &logsource.category {
out.push_str(&format!(" category: {}\n", yaml_str(c)));
}
if let Some(p) = &logsource.product {
out.push_str(&format!(" product: {}\n", yaml_str(p)));
}
if let Some(s) = &logsource.service {
out.push_str(&format!(" service: {}\n", yaml_str(s)));
}
out.push_str("detection:\n");
for sel in &detection.selections {
out.push_str(&format!(" {}:\n", sel.name));
for (field, form) in &sel.entries {
emit_form(&mut out, field, form, " ");
}
}
out.push_str(&format!(" condition: {}\n", detection.condition));
out.push_str("falsepositives:\n");
out.push_str(" - 'TODO: list known benign triggers.'\n");
out.push_str("level: medium\n");
out
}
fn compile_draft(yaml: &str) -> Result<Engine, DraftError> {
let collection = rsigma_parser::parse_sigma_yaml(yaml).map_err(|e| DraftError::Internal {
stage: "parse".to_string(),
message: e.to_string(),
})?;
let mut engine = Engine::new();
engine
.add_collection(&collection)
.map_err(|e| DraftError::Internal {
stage: "compile".to_string(),
message: e.to_string(),
})?;
Ok(engine)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::JsonEvent;
use serde_json::{Value, json};
fn events(values: &[Value]) -> Vec<JsonEvent<'_>> {
values.iter().map(JsonEvent::borrow).collect()
}
fn fixed_config() -> DraftConfig {
DraftConfig {
rule_id: Some("00000000-0000-4000-8000-000000000000".to_string()),
date: Some("2026-07-03".to_string()),
..DraftConfig::default()
}
}
fn draft(
exemplars: &[Value],
baseline: &[Value],
config: &DraftConfig,
) -> Result<DraftReport, DraftError> {
draft_rule(&events(exemplars), &events(baseline), config)
}
#[test]
fn timestamp_names_and_values_are_volatile() {
assert!(is_volatile_name("UtcTime"));
assert!(is_volatile_name("@timestamp"));
assert!(is_volatile_name("event.created_date"));
assert!(is_volatile_value(&DraftValue::Str(
"2026-07-03T12:00:00Z".into()
)));
assert!(is_volatile_value(&DraftValue::Str("2026-07-03".into())));
assert!(!is_volatile_value(&DraftValue::Str("whoami.exe".into())));
}
#[test]
fn uuid_values_and_guid_names_are_volatile() {
assert!(is_volatile_name("ProcessGuid"));
assert!(is_uuid_string("6bde842e-a2f4-441e-b027-3aa79b1b2fc2"));
assert!(is_uuid_string("{6bde842e-a2f4-441e-b027-3aa79b1b2fc2}"));
assert!(!is_uuid_string("not-a-uuid"));
}
#[test]
fn counter_names_and_epoch_values_are_volatile() {
assert!(is_volatile_name("ProcessId"));
assert!(is_volatile_name("Event.System.EventRecordID"));
assert!(is_volatile_name("logon_id"));
assert!(is_epoch_number(1_751_500_000.0)); assert!(is_epoch_number(1_751_500_000_000.0)); assert!(!is_epoch_number(4688.0)); }
#[test]
fn time_date_name_match_is_word_bounded() {
assert!(is_volatile_name("EventTime"));
assert!(is_volatile_name("event_date"));
assert!(is_volatile_name("datetime"));
assert!(!is_volatile_name("runtime"));
assert!(!is_volatile_name("update"));
assert!(!is_volatile_name("candidate"));
assert!(!is_volatile_name("CommandLine"));
assert!(!is_volatile_name("validate_action"));
}
#[test]
fn shared_affix_never_splits_a_multibyte_char() {
assert_eq!(
shared_prefix(&["abcé1", "abcè2"], 3).as_deref(),
Some("abc")
);
assert_eq!(shared_prefix(&["abcé1", "abcè2"], 4), None);
assert_eq!(shared_suffix(&["x\u{03a9}", "y\u{00e9}"], 1), None);
assert_eq!(
shared_suffix(&["1éabc", "2éabc"], 3).as_deref(),
Some("éabc")
);
}
#[test]
fn random_unique_values_are_volatile() {
let exemplars: Vec<Value> = (0..4)
.map(|i| {
json!({
"tool": "runner",
"task": "sync",
"token": format!("a9f{i}c2d4e6b8a0f1c3d5e7f9b1a3c5d{i}"),
})
})
.collect();
let report = draft(&exemplars, &[], &fixed_config()).unwrap();
let token = report.fields.iter().find(|f| f.field == "token").unwrap();
assert_eq!(token.stability, Stability::Volatile);
assert!(!token.selected);
}
#[test]
fn baseline_contrast_prefers_rare_fields() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"action": "exfil", "proto": "tcp"}))
.collect();
let baseline: Vec<Value> = (0..20)
.map(|i| json!({"action": format!("browse{i}"), "proto": "tcp"}))
.collect();
let report = draft(&exemplars, &baseline, &fixed_config()).unwrap();
let action = report.fields.iter().find(|f| f.field == "action").unwrap();
let proto = report.fields.iter().find(|f| f.field == "proto").unwrap();
assert!(
action.score > proto.score,
"baseline-rare field must outrank the ubiquitous one"
);
assert_eq!(proto.baseline_prevalence, Some(1.0));
assert_eq!(action.baseline_prevalence, Some(0.0));
}
#[test]
fn structural_fields_are_demoted_without_baseline() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"hostname": "web-01", "action": "exfil"}))
.collect();
let report = draft(&exemplars, &[], &fixed_config()).unwrap();
let host = report
.fields
.iter()
.find(|f| f.field == "hostname")
.unwrap();
let action = report.fields.iter().find(|f| f.field == "action").unwrap();
assert!(action.score > host.score);
}
#[test]
fn deterministic_output_across_runs() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"vendor": "acme", "action": "alert", "sig": "S-1001"}))
.collect();
let a = draft(&exemplars, &[], &fixed_config()).unwrap().rule_yaml;
let b = draft(&exemplars, &[], &fixed_config()).unwrap().rule_yaml;
assert_eq!(a, b, "draft output must be byte-identical across runs");
}
#[test]
fn shared_path_tail_becomes_endswith() {
let exemplars = vec![
json!({"Image": "C:\\Tools\\whoami.exe", "kind": "proc"}),
json!({"Image": "C:\\Windows\\System32\\whoami.exe", "kind": "proc"}),
json!({"Image": "D:\\stage\\whoami.exe", "kind": "proc"}),
json!({"Image": "E:\\x\\whoami.exe", "kind": "proc"}),
json!({"Image": "F:\\y\\whoami.exe", "kind": "proc"}),
];
let cfg = DraftConfig {
max_value_cardinality: 3,
..fixed_config()
};
let report = draft(&exemplars, &[], &cfg).unwrap();
assert!(
report.rule_yaml.contains("Image|endswith: '\\whoami.exe'"),
"expected endswith derivation, got:\n{}",
report.rule_yaml
);
}
#[test]
fn shared_prefix_becomes_startswith() {
let exemplars: Vec<Value> = (0..5)
.map(|i| json!({"url": format!("https://evil.example/payload{i}"), "verb": "GET"}))
.collect();
let cfg = DraftConfig {
max_value_cardinality: 3,
..fixed_config()
};
let report = draft(&exemplars, &[], &cfg).unwrap();
assert!(
report
.rule_yaml
.contains("url|startswith: 'https://evil.example/payload'"),
"expected startswith derivation, got:\n{}",
report.rule_yaml
);
}
#[test]
fn short_generic_tokens_are_never_chosen() {
let exemplars: Vec<Value> = (0..5)
.map(|i| json!({"cmd": format!("{i}zz run q{i}"), "kind": "x"}))
.collect();
let cfg = DraftConfig {
max_value_cardinality: 3,
..fixed_config()
};
let report = draft(&exemplars, &[], &cfg).unwrap();
let cmd = report.fields.iter().find(|f| f.field == "cmd").unwrap();
assert_eq!(cmd.stability, Stability::Volatile);
assert!(!report.rule_yaml.contains("cmd|contains"));
}
#[test]
fn baseline_generic_token_is_rejected() {
let exemplars: Vec<Value> = (0..5)
.map(|i| json!({"proc": format!("powershell -x {i}q{i}w{i}"), "kind": "spawn"}))
.collect();
let baseline: Vec<Value> = (0..20)
.map(|i| json!({"proc": format!("powershell -File login{i}.ps1"), "kind": "spawn"}))
.collect();
let cfg = DraftConfig {
max_value_cardinality: 3,
min_fields: 1,
..fixed_config()
};
let report = draft(&exemplars, &baseline, &cfg).unwrap();
assert!(
!report.rule_yaml.contains("proc|contains: powershell"),
"generic baseline token must be rejected, got:\n{}",
report.rule_yaml
);
}
#[test]
fn wildcard_specials_in_values_are_escaped() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"query": "SELECT * FROM users?", "app": "dbd"}))
.collect();
let report = draft(&exemplars, &[], &fixed_config()).unwrap();
assert!(
report.rule_yaml.contains(r"SELECT \* FROM users\?"),
"wildcards must be escaped, got:\n{}",
report.rule_yaml
);
assert_eq!(report.exemplar_matched, 3);
}
#[test]
fn escape_sigma_value_handles_backslash_adjacency() {
assert_eq!(escape_sigma_value(r"C:\Windows"), r"C:\Windows");
assert_eq!(escape_sigma_value("a*b"), r"a\*b");
assert_eq!(escape_sigma_value("a?b"), r"a\?b");
assert_eq!(escape_sigma_value(r"a\*b"), r"a\\\*b");
assert_eq!(escape_sigma_value(r"a\\b"), r"a\\\\b");
assert_eq!(escape_sigma_value(r"trailing\"), r"trailing\\");
}
#[test]
fn distinct_value_groups_split_into_selections() {
let exemplars = vec![
json!({"Image": "C:\\W\\vssadmin.exe", "CommandLine": "vssadmin delete shadows", "k": "p"}),
json!({"Image": "C:\\W\\vssadmin.exe", "CommandLine": "vssadmin delete shadows", "k": "p"}),
json!({"Image": "C:\\W\\wmic.exe", "CommandLine": "wmic shadowcopy delete", "k": "p"}),
json!({"Image": "C:\\W\\wmic.exe", "CommandLine": "wmic shadowcopy delete", "k": "p"}),
];
let report = draft(&exemplars, &[], &fixed_config()).unwrap();
assert!(
report.rule_yaml.contains("condition: 1 of selection_*"),
"expected a group split, got:\n{}",
report.rule_yaml
);
assert!(report.rule_yaml.contains("selection_vssadmin:"));
assert!(report.rule_yaml.contains("selection_wmic:"));
assert_eq!(report.exemplar_matched, 4);
}
#[test]
fn no_split_when_values_do_not_partition() {
let exemplars: Vec<Value> = (0..4)
.map(|_| json!({"vendor": "acme", "action": "alert"}))
.collect();
let report = draft(&exemplars, &[], &fixed_config()).unwrap();
assert!(report.rule_yaml.contains("condition: selection\n"));
}
#[test]
fn sysmon_event_id_maps_to_category() {
let exemplars: Vec<Value> = (0..3)
.map(|_| {
json!({
"Channel": "Microsoft-Windows-Sysmon/Operational",
"EventID": 1,
"Image": "C:\\W\\evil.exe",
"CommandLine": "evil.exe --run",
})
})
.collect();
let report = draft(&exemplars, &[], &fixed_config()).unwrap();
assert!(report.rule_yaml.contains("category: process_creation"));
assert!(report.rule_yaml.contains("product: windows"));
assert!(!report.rule_yaml.contains("service: sysmon"));
}
#[test]
fn sysmon_without_shared_event_id_keeps_service() {
let exemplars = vec![
json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 1, "Image": "C:\\W\\a.exe", "RuleName": "t"}),
json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 3, "Image": "C:\\W\\a.exe", "RuleName": "t"}),
];
let report = draft(&exemplars, &[], &fixed_config()).unwrap();
assert!(report.rule_yaml.contains("service: sysmon"));
assert!(report.rule_yaml.contains("product: windows"));
}
#[test]
fn logsource_overrides_win() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"vendor": "acme", "action": "alert"}))
.collect();
let cfg = DraftConfig {
logsource_product: Some("acme_fw".to_string()),
logsource_category: Some("firewall".to_string()),
..fixed_config()
};
let report = draft(&exemplars, &[], &cfg).unwrap();
assert!(report.rule_yaml.contains("product: acme_fw"));
assert!(report.rule_yaml.contains("category: firewall"));
assert!(!report.rule_yaml.contains("todo"));
}
#[test]
fn unknown_schema_gets_todo_placeholder() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"vendor": "acme", "action": "alert"}))
.collect();
let report = draft(&exemplars, &[], &fixed_config()).unwrap();
assert!(report.rule_yaml.contains("product: todo"));
assert!(
report
.warnings
.iter()
.any(|w| w.contains("logsource could not be inferred"))
);
}
#[test]
fn draft_round_trips_and_matches_exemplars() {
let exemplars: Vec<Value> = (0..4)
.map(|_| json!({"vendor": "acme", "action": "exfil", "dst_port": 443}))
.collect();
let report = draft(&exemplars, &[], &fixed_config()).unwrap();
let collection =
rsigma_parser::parse_sigma_yaml(&report.rule_yaml).expect("emitted draft must parse");
let mut engine = Engine::new();
engine.add_collection(&collection).unwrap();
for e in &events(&exemplars) {
assert!(!engine.evaluate(e).is_empty(), "exemplar must match");
}
assert_eq!(report.exemplar_matched, report.exemplar_total);
assert!(
report
.rule_yaml
.contains("id: 00000000-0000-4000-8000-000000000000")
);
assert!(report.rule_yaml.contains("status: experimental"));
assert!(report.rule_yaml.contains("level: medium"));
assert!(report.rule_yaml.contains("date: 2026-07-03"));
}
#[test]
fn typed_values_emit_as_numbers() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"vendor": "acme", "code": 4688}))
.collect();
let report = draft(&exemplars, &[], &fixed_config()).unwrap();
assert!(
report.rule_yaml.contains("code: 4688"),
"integers must emit bare, got:\n{}",
report.rule_yaml
);
}
#[test]
fn baseline_hits_are_counted_with_rate() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"vendor": "acme", "action": "alert"}))
.collect();
let mut baseline: Vec<Value> = (0..8)
.map(|i| json!({"vendor": "other", "action": format!("a{i}")}))
.collect();
baseline.push(json!({"vendor": "acme", "action": "alert"}));
baseline.push(json!({"vendor": "acme", "action": "alert"}));
let report = draft(&exemplars, &baseline, &fixed_config()).unwrap();
assert_eq!(report.baseline_total, 10);
assert_eq!(report.baseline_hits, Some(2));
assert!((report.baseline_hit_rate.unwrap() - 0.2).abs() < 1e-9);
assert!(report.warnings.iter().any(|w| w.contains("baseline")));
}
#[test]
fn skip_baseline_eval_keeps_scoring_but_not_hits() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"vendor": "acme", "action": "alert"}))
.collect();
let baseline: Vec<Value> = (0..5)
.map(|i| json!({"vendor": "other", "action": format!("a{i}")}))
.collect();
let cfg = DraftConfig {
evaluate_baseline: false,
..fixed_config()
};
let report = draft(&exemplars, &baseline, &cfg).unwrap();
assert_eq!(report.baseline_hits, None);
assert!(
report
.fields
.iter()
.any(|f| f.baseline_prevalence.is_some()),
"contrastive scoring still uses the baseline"
);
}
#[test]
fn relaxation_drops_partial_prevalence_fields() {
let mut exemplars: Vec<Value> = (0..2)
.map(|_| json!({"vendor": "acme", "action": "alert", "extra": "x"}))
.collect();
exemplars.extend((0..2).map(|_| json!({"vendor": "acme", "action": "alert"})));
let cfg = DraftConfig {
min_prevalence: 0.4,
..fixed_config()
};
let report = draft(&exemplars, &[], &cfg).unwrap();
assert_eq!(report.exemplar_matched, 4);
assert!(!report.rule_yaml.contains("extra"));
assert!(report.warnings.iter().any(|w| w.contains("relaxed")));
}
#[test]
fn floor_errors_instead_of_emitting_overbroad_draft() {
let mut exemplars: Vec<Value> = (0..2)
.map(|_| json!({"alpha": "one", "beta": "x"}))
.collect();
exemplars.extend((0..2).map(|_| json!({"alpha": "two", "gamma": "y"})));
let cfg = DraftConfig {
min_prevalence: 0.4,
min_fields: 2,
max_value_cardinality: 1,
..fixed_config()
};
let err = draft(&exemplars, &[], &cfg).unwrap_err();
assert!(
matches!(err, DraftError::CannotMatchExemplars { floor: 2, .. }),
"expected the floor error, got: {err}"
);
}
#[test]
fn forced_field_absent_from_exemplars_errors_immediately() {
let mut exemplars: Vec<Value> = (0..2)
.map(|_| json!({"vendor": "acme", "action": "alert", "extra": "x"}))
.collect();
exemplars.extend((0..2).map(|_| json!({"vendor": "acme", "action": "alert"})));
let cfg = DraftConfig {
include_fields: vec!["extra".to_string()],
min_prevalence: 0.4,
..fixed_config()
};
let err = draft(&exemplars, &[], &cfg).unwrap_err();
match err {
DraftError::ForcedFieldMismatch { fields, failing } => {
assert_eq!(fields, vec!["extra".to_string()]);
assert_eq!(failing, vec![2, 3]);
}
other => panic!("expected ForcedFieldMismatch, got: {other}"),
}
}
#[test]
fn no_exemplars_is_an_error() {
let err = draft(&[], &[], &fixed_config()).unwrap_err();
assert!(matches!(err, DraftError::NoExemplars));
}
#[test]
fn all_volatile_fields_is_an_error() {
let exemplars: Vec<Value> = (0..3)
.map(|i| {
json!({
"UtcTime": format!("2026-07-03T12:00:0{i}Z"),
"ProcessGuid": format!("6bde842e-a2f4-441e-b027-3aa79b1b2fc{i}"),
})
})
.collect();
let err = draft(&exemplars, &[], &fixed_config()).unwrap_err();
assert!(matches!(err, DraftError::NoCandidateFields(3)));
}
#[test]
fn include_and_exclude_fields_are_honored() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"vendor": "acme", "action": "alert", "noise": "same"}))
.collect();
let cfg = DraftConfig {
include_fields: vec!["noise".to_string()],
exclude_fields: vec!["vendor".to_string()],
max_fields: 2,
..fixed_config()
};
let report = draft(&exemplars, &[], &cfg).unwrap();
assert!(report.rule_yaml.contains("noise: same"));
assert!(!report.rule_yaml.contains("vendor"));
}
#[test]
fn title_override_and_derived_title() {
let exemplars: Vec<Value> = (0..3)
.map(|_| json!({"vendor": "acme", "action": "alert"}))
.collect();
let derived = draft(&exemplars, &[], &fixed_config()).unwrap();
assert!(
derived.rule_yaml.starts_with("title: 'Draft:")
|| derived.rule_yaml.starts_with("title: Draft"),
"derived title expected, got:\n{}",
derived.rule_yaml
);
let cfg = DraftConfig {
title: Some("Acme Exfil Detection".to_string()),
..fixed_config()
};
let titled = draft(&exemplars, &[], &cfg).unwrap();
assert!(titled.rule_yaml.starts_with("title: Acme Exfil Detection"));
}
}