1use std::collections::{BTreeMap, BTreeSet};
4
5use chrono::DateTime;
6use rsigma_parser::Timespan;
7use serde::Serialize;
8use serde_json::Value;
9
10use crate::correlation_engine::{CorrelationConfig, CorrelationEngine};
11use crate::event::{Event, JsonEvent};
12use crate::key_shape::cluster_by_key_shape;
13
14use super::draft_core::{ValueForm, yaml_str, yaml_title_str};
15use super::{DraftCandidate, DraftConfig, DraftError};
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
19pub struct SourceLocation {
20 pub file: Option<String>,
22 pub line: Option<usize>,
24}
25
26impl SourceLocation {
27 fn label(&self) -> String {
28 match (&self.file, self.line) {
29 (Some(file), Some(line)) => format!("{file}:{line}"),
30 (Some(file), None) => file.clone(),
31 (None, Some(line)) => format!("line {line}"),
32 (None, None) => "unknown source".to_string(),
33 }
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize)]
39pub struct TimedEvent {
40 pub timestamp: Option<String>,
42 pub offset: Option<String>,
44 pub event: Value,
46 pub source: SourceLocation,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize)]
52pub struct GroupedExemplar {
53 pub id: String,
55 pub events: Vec<TimedEvent>,
57}
58
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum CorrelationDraftType {
63 #[default]
65 Auto,
66 Temporal,
68 TemporalOrdered,
70}
71
72impl CorrelationDraftType {
73 fn emitted_name(self, ordered: bool) -> &'static str {
74 match self {
75 Self::Temporal => "temporal",
76 Self::Auto if !ordered => "temporal",
77 Self::Auto | Self::TemporalOrdered => "temporal_ordered",
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
84pub struct CorrelationDraftConfig {
85 pub min_groups: usize,
87 pub min_slots: usize,
89 pub similarity: f64,
91 pub window_margin: f64,
93 pub correlation_type: CorrelationDraftType,
95 pub group_by: Vec<String>,
97 pub title: Option<String>,
99 pub correlation_id: Option<String>,
101 pub slot_ids: Vec<String>,
103 pub detection: DraftConfig,
105}
106
107impl Default for CorrelationDraftConfig {
108 fn default() -> Self {
109 Self {
110 min_groups: 3,
111 min_slots: 2,
112 similarity: 0.6,
113 window_margin: 1.5,
114 correlation_type: CorrelationDraftType::Auto,
115 group_by: Vec::new(),
116 title: None,
117 correlation_id: None,
118 slot_ids: Vec::new(),
119 detection: DraftConfig::default(),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Serialize)]
126pub struct CorrelationSlotReport {
127 pub name: String,
129 pub id: Option<String>,
131 pub support: usize,
133 pub group_support: usize,
135 pub selected_fields: Vec<String>,
137}
138
139#[derive(Debug, Clone, Serialize)]
141pub struct CorrelationVerification {
142 pub group: String,
144 pub negative: bool,
146 pub fired: bool,
148}
149
150#[derive(Debug, Clone, Serialize)]
152pub struct CorrelationDraftReport {
153 pub rule_yaml: String,
155 pub correlation_type: String,
157 pub group_by: Vec<String>,
159 pub timespan: String,
161 pub span_seconds: Vec<u64>,
163 pub gap_seconds: Vec<u64>,
165 pub slots: Vec<CorrelationSlotReport>,
167 pub verification: Vec<CorrelationVerification>,
169 pub warnings: Vec<String>,
171}
172
173#[derive(Debug, thiserror::Error)]
175pub enum CorrelationDraftError {
176 #[error("at least {minimum} positive groups are required, got {actual}")]
178 TooFewGroups { minimum: usize, actual: usize },
179 #[error("group '{group}' needs at least two events, got {actual}")]
181 TooFewEvents { group: String, actual: usize },
182 #[error(
184 "group '{group}' event {event} at {location} must contain exactly one of timestamp or offset"
185 )]
186 InvalidTimeKeys {
187 group: String,
188 event: usize,
189 location: String,
190 },
191 #[error("group '{group}' mixes timestamp and offset time modes")]
193 MixedTimeMode { group: String },
194 #[error("group '{group}' event {event} at {location} has invalid {kind} value '{value}'")]
196 InvalidTime {
197 group: String,
198 event: usize,
199 location: String,
200 kind: &'static str,
201 value: String,
202 },
203 #[error(
205 "group '{group}' events {first_event} and {second_event} have duplicate parsed time {time}"
206 )]
207 DuplicateTime {
208 group: String,
209 first_event: usize,
210 second_event: usize,
211 time: i64,
212 },
213 #[error("at least {minimum} recurring slots are required, got {actual}")]
215 TooFewSlots { minimum: usize, actual: usize },
216 #[error("group '{group}' repeats slot {slot} at input events {events:?}")]
218 DuplicateSlot {
219 group: String,
220 slot: usize,
221 events: Vec<usize>,
222 },
223 #[error("temporal_ordered was requested but groups disagree on slot order: {groups:?}")]
225 OrderInversion { groups: Vec<String> },
226 #[error("window margin must be finite and at least 1.0, got {0}")]
228 InvalidWindowMargin(f64),
229 #[error("inferred correlation window overflowed")]
231 WindowOverflow,
232 #[error("correlation entity is ambiguous; candidates: {candidates:?}")]
234 AmbiguousEntity { candidates: Vec<String> },
235 #[error(
237 "group-by field '{field}' is absent or unstable in group '{group}' at input event {event}"
238 )]
239 InvalidEntity {
240 field: String,
241 group: String,
242 event: usize,
243 },
244 #[error(
246 "every drafted slot rule needs an id: {slots} slots were inferred but only {provided} slot ids were supplied"
247 )]
248 MissingSlotIds { slots: usize, provided: usize },
249 #[error("failed to draft slot {slot}: {source}")]
251 SlotDraft {
252 slot: usize,
253 #[source]
254 source: DraftError,
255 },
256 #[error(
258 "group '{group}' event {event} assigned to '{assigned}' collides with {colliding:?}; selected forms: {forms:?}. Use cleaner exemplars, a representative baseline, or manual rule editing"
259 )]
260 CrossSlotMatch {
261 group: String,
262 event: usize,
263 assigned: String,
264 colliding: Vec<String>,
265 forms: Vec<String>,
266 },
267 #[error("slot '{slot}' cannot match assigned positives at the {floor}-field floor")]
269 SlotFloor { slot: String, floor: usize },
270 #[error("internal correlation draft error during {stage}: {message}")]
272 Internal {
273 stage: &'static str,
274 message: String,
275 },
276 #[error("positive group '{group}' {reason}")]
278 PositiveVerification { group: String, reason: String },
279 #[error("drafted correlation matched negative groups: {groups:?}")]
281 NegativeGroupMatched { groups: Vec<String> },
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285enum TimeMode {
286 Timestamp,
287 Offset,
288}
289
290#[derive(Debug, Clone)]
291struct NormalizedEvent {
292 input_index: usize,
293 time: i64,
294 event: Value,
295 keys: Vec<String>,
296 cluster: usize,
297}
298
299#[derive(Debug, Clone)]
300struct NormalizedGroup {
301 id: String,
302 events: Vec<NormalizedEvent>,
303}
304
305#[derive(Debug)]
306struct ShapeCluster {
307 seed_keys: Vec<String>,
308 members: Vec<(usize, usize)>,
309}
310
311#[derive(Debug)]
312struct ShapeItem {
313 keys: Vec<String>,
314 member: (usize, usize),
315}
316
317#[derive(Debug)]
318struct Slot {
319 cluster: usize,
320 name: String,
321 id: Option<String>,
322 members: Vec<(usize, usize)>,
323 candidate: DraftCandidate,
324 config: DraftConfig,
325}
326
327pub fn draft_correlation(
332 groups: &[GroupedExemplar],
333 negative_groups: &[GroupedExemplar],
334 baseline: &[Value],
335 config: &CorrelationDraftConfig,
336) -> Result<CorrelationDraftReport, CorrelationDraftError> {
337 let mut groups = normalize_groups(groups, config.min_groups)?;
338 let negatives = normalize_groups(negative_groups, 0)?;
339 let positive_count = groups.len();
340 groups.extend(negatives);
341 assign_clusters(&mut groups, config.similarity);
342 let negatives = groups.split_off(positive_count);
343
344 let (retained, mut warnings) = retained_clusters(&groups, config.min_slots)?;
345 validate_slot_counts(&groups, &retained)?;
346 let group_by = resolve_group_by(&groups, &retained, &config.group_by)?;
347 let (ordered, order, disagreeing) = infer_order(&groups, &retained);
348 if config.correlation_type == CorrelationDraftType::TemporalOrdered && !disagreeing.is_empty() {
349 return Err(CorrelationDraftError::OrderInversion {
350 groups: disagreeing,
351 });
352 }
353 if config.correlation_type == CorrelationDraftType::Auto && !disagreeing.is_empty() {
354 warnings.push(format!(
355 "slot order differs in groups {}; emitted temporal instead of temporal_ordered",
356 disagreeing.join(", ")
357 ));
358 }
359 let correlation_type = config.correlation_type.emitted_name(ordered);
360 let (timespan, spans, gaps) = infer_window(&groups, &retained, config.window_margin)?;
361 let correlation_id = config
362 .correlation_id
363 .clone()
364 .unwrap_or_else(|| "draft-correlation".to_string());
365
366 let mut slots = build_slots(
367 &groups,
368 baseline,
369 &group_by,
370 &order,
371 &config.slot_ids,
372 &config.detection,
373 )?;
374
375 let (rule_yaml, verification) = loop {
376 let yaml = emit_collection(
377 &groups,
378 &retained,
379 &slots,
380 correlation_type,
381 &group_by,
382 ×pan,
383 &correlation_id,
384 config,
385 );
386 let collection = rsigma_parser::parse_sigma_yaml(&yaml).map_err(|error| {
387 CorrelationDraftError::Internal {
388 stage: "parse",
389 message: error.to_string(),
390 }
391 })?;
392
393 if let Some((slot_index, local_event)) =
394 verify_identity(&collection, &groups, &retained, &slots)?
395 {
396 let slot = &mut slots[slot_index];
397 let floor = slot
398 .config
399 .min_fields
400 .min(slot.candidate.selected.len())
401 .max(1);
402 if slot.candidate.selected.len() <= floor
403 || slot
404 .candidate
405 .drop_lowest_eligible(Some(&[local_event]))
406 .is_none()
407 {
408 return Err(CorrelationDraftError::SlotFloor {
409 slot: slot.name.clone(),
410 floor,
411 });
412 }
413 warnings.push(format!(
414 "relaxed slot '{}' after an assigned positive failed verification",
415 slot.name
416 ));
417 continue;
418 }
419
420 let mut verification =
421 verify_groups(&collection, &groups, &retained, &correlation_id, false)?;
422 let mut negative_rows =
423 verify_groups(&collection, &negatives, &retained, &correlation_id, true)?;
424 verification.append(&mut negative_rows);
425 break (yaml, verification);
426 };
427
428 for finding in rsigma_parser::lint_yaml_str(&rule_yaml) {
429 warnings.push(format!("lint {}: {}", finding.rule, finding.message));
430 }
431
432 let slot_reports = slots
433 .iter()
434 .map(|slot| CorrelationSlotReport {
435 name: slot.name.clone(),
436 id: slot.id.clone(),
437 support: slot.members.len(),
438 group_support: slot
439 .members
440 .iter()
441 .map(|(group, _)| group)
442 .collect::<BTreeSet<_>>()
443 .len(),
444 selected_fields: selected_forms(slot),
445 })
446 .collect();
447
448 Ok(CorrelationDraftReport {
449 rule_yaml,
450 correlation_type: correlation_type.to_string(),
451 group_by,
452 timespan: timespan.original,
453 span_seconds: spans,
454 gap_seconds: gaps,
455 slots: slot_reports,
456 verification,
457 warnings,
458 })
459}
460
461fn normalize_groups(
462 groups: &[GroupedExemplar],
463 minimum: usize,
464) -> Result<Vec<NormalizedGroup>, CorrelationDraftError> {
465 if groups.len() < minimum {
466 return Err(CorrelationDraftError::TooFewGroups {
467 minimum,
468 actual: groups.len(),
469 });
470 }
471 let mut normalized = Vec::with_capacity(groups.len());
472 for group in groups {
473 if group.events.len() < 2 {
474 return Err(CorrelationDraftError::TooFewEvents {
475 group: group.id.clone(),
476 actual: group.events.len(),
477 });
478 }
479 let mut mode = None;
480 let mut events = Vec::with_capacity(group.events.len());
481 for (index, timed) in group.events.iter().enumerate() {
482 let current = match (&timed.timestamp, &timed.offset) {
483 (Some(_), None) => TimeMode::Timestamp,
484 (None, Some(_)) => TimeMode::Offset,
485 _ => {
486 return Err(CorrelationDraftError::InvalidTimeKeys {
487 group: group.id.clone(),
488 event: index,
489 location: timed.source.label(),
490 });
491 }
492 };
493 if mode.is_some_and(|expected| expected != current) {
494 return Err(CorrelationDraftError::MixedTimeMode {
495 group: group.id.clone(),
496 });
497 }
498 mode = Some(current);
499 let time = match current {
500 TimeMode::Timestamp => {
501 let raw = timed.timestamp.as_deref().unwrap();
502 let parsed = DateTime::parse_from_rfc3339(raw).map_err(|_| {
503 CorrelationDraftError::InvalidTime {
504 group: group.id.clone(),
505 event: index,
506 location: timed.source.label(),
507 kind: "timestamp",
508 value: raw.to_string(),
509 }
510 })?;
511 parsed.timestamp()
512 }
513 TimeMode::Offset => {
514 let raw = timed.offset.as_deref().unwrap();
515 let parsed =
516 Timespan::parse(raw).map_err(|_| CorrelationDraftError::InvalidTime {
517 group: group.id.clone(),
518 event: index,
519 location: timed.source.label(),
520 kind: "offset",
521 value: raw.to_string(),
522 })?;
523 i64::try_from(parsed.seconds).map_err(|_| {
524 CorrelationDraftError::InvalidTime {
525 group: group.id.clone(),
526 event: index,
527 location: timed.source.label(),
528 kind: "offset",
529 value: raw.to_string(),
530 }
531 })?
532 }
533 };
534 let json = JsonEvent::borrow(&timed.event);
535 let mut keys: Vec<String> = json
536 .field_keys()
537 .into_iter()
538 .map(|key| key.into_owned())
539 .collect();
540 keys.sort();
541 keys.dedup();
542 events.push(NormalizedEvent {
543 input_index: index,
544 time,
545 event: timed.event.clone(),
546 keys,
547 cluster: usize::MAX,
548 });
549 }
550 events.sort_by_key(|event| (event.time, event.input_index));
551 for pair in events.windows(2) {
552 if pair[0].time == pair[1].time {
553 return Err(CorrelationDraftError::DuplicateTime {
554 group: group.id.clone(),
555 first_event: pair[0].input_index,
556 second_event: pair[1].input_index,
557 time: pair[0].time,
558 });
559 }
560 }
561 normalized.push(NormalizedGroup {
562 id: group.id.clone(),
563 events,
564 });
565 }
566 normalized.sort_by(|a, b| a.id.cmp(&b.id));
567 Ok(normalized)
568}
569
570fn assign_clusters(groups: &mut [NormalizedGroup], similarity: f64) {
571 let items: Vec<ShapeItem> = groups
572 .iter()
573 .enumerate()
574 .flat_map(|(group, value)| {
575 value
576 .events
577 .iter()
578 .enumerate()
579 .map(move |(event, value)| ShapeItem {
580 keys: value.keys.clone(),
581 member: (group, event),
582 })
583 })
584 .collect();
585 let clusters = cluster_by_key_shape(
586 &items,
587 similarity,
588 |item| &item.keys,
589 |cluster: &ShapeCluster| &cluster.seed_keys,
590 |item| ShapeCluster {
591 seed_keys: item.keys.clone(),
592 members: vec![item.member],
593 },
594 |_, _| true,
595 |cluster, item| cluster.members.push(item.member),
596 );
597 for (cluster_index, cluster) in clusters.iter().enumerate() {
598 for &(group, event) in &cluster.members {
599 groups[group].events[event].cluster = cluster_index;
600 }
601 }
602}
603
604fn retained_clusters(
605 groups: &[NormalizedGroup],
606 minimum: usize,
607) -> Result<(BTreeSet<usize>, Vec<String>), CorrelationDraftError> {
608 let all: BTreeSet<usize> = groups
609 .iter()
610 .flat_map(|group| group.events.iter().map(|event| event.cluster))
611 .collect();
612 let retained: BTreeSet<usize> = all
613 .iter()
614 .copied()
615 .filter(|cluster| {
616 groups
617 .iter()
618 .all(|group| group.events.iter().any(|event| event.cluster == *cluster))
619 })
620 .collect();
621 if retained.len() < minimum {
622 return Err(CorrelationDraftError::TooFewSlots {
623 minimum,
624 actual: retained.len(),
625 });
626 }
627 let dropped: Vec<String> = all
628 .difference(&retained)
629 .map(|cluster| cluster.to_string())
630 .collect();
631 let warnings = if dropped.is_empty() {
632 Vec::new()
633 } else {
634 vec![format!(
635 "dropped incidental key-shape clusters absent from one or more positive groups: {}",
636 dropped.join(", ")
637 )]
638 };
639 Ok((retained, warnings))
640}
641
642fn validate_slot_counts(
643 groups: &[NormalizedGroup],
644 retained: &BTreeSet<usize>,
645) -> Result<(), CorrelationDraftError> {
646 for group in groups {
647 for &slot in retained {
648 let mut events: Vec<usize> = group
649 .events
650 .iter()
651 .filter(|event| event.cluster == slot)
652 .map(|event| event.input_index)
653 .collect();
654 events.sort_unstable();
655 if events.len() > 1 {
656 return Err(CorrelationDraftError::DuplicateSlot {
657 group: group.id.clone(),
658 slot,
659 events,
660 });
661 }
662 }
663 }
664 Ok(())
665}
666
667fn infer_order(
672 groups: &[NormalizedGroup],
673 retained: &BTreeSet<usize>,
674) -> (bool, Vec<usize>, Vec<String>) {
675 let orders: Vec<Vec<usize>> = groups
676 .iter()
677 .map(|group| {
678 group
679 .events
680 .iter()
681 .filter(|event| retained.contains(&event.cluster))
682 .map(|event| event.cluster)
683 .collect()
684 })
685 .collect();
686 let mut counts: BTreeMap<&Vec<usize>, usize> = BTreeMap::new();
687 for order in &orders {
688 *counts.entry(order).or_default() += 1;
689 }
690 let expected = counts
691 .iter()
692 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
693 .map(|(order, _)| (*order).clone())
694 .unwrap_or_default();
695 let disagreeing: Vec<String> = groups
696 .iter()
697 .zip(&orders)
698 .filter(|(_, order)| **order != expected)
699 .map(|(group, _)| group.id.clone())
700 .collect();
701 (disagreeing.is_empty(), expected, disagreeing)
702}
703
704fn infer_window(
705 groups: &[NormalizedGroup],
706 retained: &BTreeSet<usize>,
707 margin: f64,
708) -> Result<(Timespan, Vec<u64>, Vec<u64>), CorrelationDraftError> {
709 if !margin.is_finite() || margin < 1.0 {
710 return Err(CorrelationDraftError::InvalidWindowMargin(margin));
711 }
712 let mut spans = Vec::with_capacity(groups.len());
713 let mut gaps = Vec::new();
714 for group in groups {
715 let times: Vec<i64> = group
716 .events
717 .iter()
718 .filter(|event| retained.contains(&event.cluster))
719 .map(|event| event.time)
720 .collect();
721 let first = *times.first().expect("retained slots exist");
722 let last = *times.last().expect("retained slots exist");
723 spans.push(
724 u64::try_from(
725 last.checked_sub(first)
726 .ok_or(CorrelationDraftError::WindowOverflow)?,
727 )
728 .map_err(|_| CorrelationDraftError::WindowOverflow)?,
729 );
730 for pair in times.windows(2) {
731 gaps.push(
732 u64::try_from(
733 pair[1]
734 .checked_sub(pair[0])
735 .ok_or(CorrelationDraftError::WindowOverflow)?,
736 )
737 .map_err(|_| CorrelationDraftError::WindowOverflow)?,
738 );
739 }
740 }
741 let maximum = spans.iter().copied().max().unwrap_or(0).max(1);
742 let scaled = (maximum as f64 * margin).ceil();
743 if !scaled.is_finite() || scaled > u64::MAX as f64 {
744 return Err(CorrelationDraftError::WindowOverflow);
745 }
746 let rounded = round_window(scaled as u64)?;
747 let timespan = Timespan::parse(&rounded).map_err(|error| CorrelationDraftError::Internal {
748 stage: "window construction",
749 message: error.to_string(),
750 })?;
751 Ok((timespan, spans, gaps))
752}
753
754fn round_window(seconds: u64) -> Result<String, CorrelationDraftError> {
755 const STEPS: &[(u64, &str)] = &[
756 (30, "30s"),
757 (60, "1m"),
758 (300, "5m"),
759 (900, "15m"),
760 (3_600, "1h"),
761 (21_600, "6h"),
762 (86_400, "24h"),
763 ];
764 if let Some((_, label)) = STEPS.iter().find(|(step, _)| seconds <= *step) {
765 return Ok((*label).to_string());
766 }
767 let days = seconds
768 .checked_add(86_399)
769 .ok_or(CorrelationDraftError::WindowOverflow)?
770 / 86_400;
771 Ok(format!("{days}d"))
772}
773
774fn resolve_group_by(
775 groups: &[NormalizedGroup],
776 retained: &BTreeSet<usize>,
777 explicit: &[String],
778) -> Result<Vec<String>, CorrelationDraftError> {
779 if !explicit.is_empty() {
780 for field in explicit {
781 validate_entity_field(groups, retained, field)?;
782 }
783 return Ok(explicit.to_vec());
784 }
785 let mut common: Option<BTreeSet<String>> = None;
786 for group in groups {
787 for event in group
788 .events
789 .iter()
790 .filter(|event| retained.contains(&event.cluster))
791 {
792 let keys: BTreeSet<String> = event.keys.iter().cloned().collect();
793 common = Some(match common {
794 Some(existing) => existing.intersection(&keys).cloned().collect(),
795 None => keys,
796 });
797 }
798 }
799 let candidates: Vec<String> = common
800 .unwrap_or_default()
801 .into_iter()
802 .filter(|field| validate_entity_field(groups, retained, field).is_ok())
803 .filter(|field| {
804 let values: BTreeSet<String> = groups
805 .iter()
806 .filter_map(|group| stable_group_value(group, retained, field))
807 .collect();
808 values.len() == groups.len()
809 })
810 .collect();
811 if candidates.len() != 1 {
812 return Err(CorrelationDraftError::AmbiguousEntity { candidates });
813 }
814 Ok(candidates)
815}
816
817fn validate_entity_field(
818 groups: &[NormalizedGroup],
819 retained: &BTreeSet<usize>,
820 field: &str,
821) -> Result<(), CorrelationDraftError> {
822 for group in groups {
823 let mut expected = None;
824 for event in group
825 .events
826 .iter()
827 .filter(|event| retained.contains(&event.cluster))
828 {
829 let value = JsonEvent::borrow(&event.event)
830 .get_field(field)
831 .map(|value| format!("{value:?}"));
832 if value.is_none()
833 || expected
834 .as_ref()
835 .is_some_and(|known| Some(known) != value.as_ref())
836 {
837 return Err(CorrelationDraftError::InvalidEntity {
838 field: field.to_string(),
839 group: group.id.clone(),
840 event: event.input_index,
841 });
842 }
843 expected = value;
844 }
845 }
846 Ok(())
847}
848
849fn stable_group_value(
850 group: &NormalizedGroup,
851 retained: &BTreeSet<usize>,
852 field: &str,
853) -> Option<String> {
854 let mut values = group
855 .events
856 .iter()
857 .filter(|event| retained.contains(&event.cluster))
858 .map(|event| {
859 JsonEvent::borrow(&event.event)
860 .get_field(field)
861 .map(|value| format!("{value:?}"))
862 });
863 let first = values.next()??;
864 values
865 .all(|value| value.as_deref() == Some(first.as_str()))
866 .then_some(first)
867}
868
869fn build_slots(
870 groups: &[NormalizedGroup],
871 baseline: &[Value],
872 group_by: &[String],
873 order: &[usize],
874 slot_ids: &[String],
875 detection_config: &DraftConfig,
876) -> Result<Vec<Slot>, CorrelationDraftError> {
877 if slot_ids.len() < order.len() && detection_config.rule_id.is_none() {
881 return Err(CorrelationDraftError::MissingSlotIds {
882 slots: order.len(),
883 provided: slot_ids.len(),
884 });
885 }
886 let mut slots = Vec::with_capacity(order.len());
887 let mut used_names = BTreeMap::new();
888 for (position, &cluster) in order.iter().enumerate() {
889 let members: Vec<(usize, usize)> = groups
890 .iter()
891 .enumerate()
892 .flat_map(|(group_index, group)| {
893 group
894 .events
895 .iter()
896 .enumerate()
897 .filter(move |(_, event)| event.cluster == cluster)
898 .map(move |(event_index, _)| (group_index, event_index))
899 })
900 .collect();
901 let positives: Vec<Value> = members
902 .iter()
903 .map(|&(group, event)| groups[group].events[event].event.clone())
904 .collect();
905 let mut contrast = baseline.to_vec();
906 for group in groups {
907 contrast.extend(
908 group
909 .events
910 .iter()
911 .filter(|event| event.cluster != cluster)
912 .map(|event| event.event.clone()),
913 );
914 }
915 let mut slot_config = detection_config.clone();
916 for field in group_by {
917 if !slot_config
918 .exclude_fields
919 .iter()
920 .any(|excluded| excluded.eq_ignore_ascii_case(field))
921 {
922 slot_config.exclude_fields.push(field.clone());
923 }
924 }
925 slot_config.rule_id = slot_ids.get(position).cloned().or_else(|| {
926 detection_config
927 .rule_id
928 .as_ref()
929 .map(|base| format!("{base}-{}", position + 1))
930 });
931 let positive_events: Vec<JsonEvent<'_>> = positives.iter().map(JsonEvent::borrow).collect();
932 let contrast_events: Vec<JsonEvent<'_>> = contrast.iter().map(JsonEvent::borrow).collect();
933 let candidate = DraftCandidate::build(&positive_events, &contrast_events, &slot_config)
934 .map_err(|source| CorrelationDraftError::SlotDraft {
935 slot: position,
936 source,
937 })?;
938 let base = slot_name(&candidate, position);
939 let count = used_names.entry(base.clone()).or_insert(0usize);
940 *count += 1;
941 let name = if *count == 1 {
942 base
943 } else {
944 format!("{base}_{count}")
945 };
946 slots.push(Slot {
947 cluster,
948 name,
949 id: slot_config.rule_id.clone(),
950 members,
951 candidate,
952 config: slot_config,
953 });
954 }
955 Ok(slots)
956}
957
958fn slot_name(candidate: &DraftCandidate, position: usize) -> String {
959 let marker = candidate.selected.first().and_then(|&index| {
960 candidate.profiles[index]
961 .form
962 .as_ref()
963 .and_then(form_marker)
964 });
965 let slug = marker
966 .as_deref()
967 .map(slugify)
968 .filter(|slug| !slug.is_empty())
969 .unwrap_or_else(|| format!("{}", position + 1));
970 format!("slot_{slug}")
971}
972
973fn form_marker(form: &ValueForm) -> Option<String> {
974 match form {
975 ValueForm::Exact(value) => Some(value.as_display()),
976 ValueForm::OneOf(values) => values.first().map(|value| value.as_display()),
977 ValueForm::EndsWith(value) | ValueForm::StartsWith(value) | ValueForm::Contains(value) => {
978 Some(value.clone())
979 }
980 ValueForm::ContainsAll(values) => values.first().cloned(),
981 }
982}
983
984fn slugify(value: &str) -> String {
985 let mut slug = String::new();
986 let mut separator = false;
987 for character in value.chars() {
988 if character.is_ascii_alphanumeric() {
989 slug.push(character.to_ascii_lowercase());
990 separator = false;
991 } else if !slug.is_empty() && !separator {
992 slug.push('_');
993 separator = true;
994 }
995 }
996 while slug.ends_with('_') {
997 slug.pop();
998 }
999 slug
1000}
1001
1002#[allow(clippy::too_many_arguments)]
1003fn emit_collection(
1004 groups: &[NormalizedGroup],
1005 retained: &BTreeSet<usize>,
1006 slots: &[Slot],
1007 correlation_type: &str,
1008 group_by: &[String],
1009 timespan: &Timespan,
1010 correlation_id: &str,
1011 config: &CorrelationDraftConfig,
1012) -> String {
1013 let mut output = String::new();
1014 for (index, slot) in slots.iter().enumerate() {
1015 if index > 0 {
1016 output.push_str("---\n");
1017 }
1018 let positives: Vec<Value> = slot
1019 .members
1020 .iter()
1021 .map(|&(group, event)| groups[group].events[event].event.clone())
1022 .collect();
1023 let events: Vec<JsonEvent<'_>> = positives.iter().map(JsonEvent::borrow).collect();
1024 output.push_str(
1025 &slot
1026 .candidate
1027 .emit_named(&events, &slot.config, Some(&slot.name)),
1028 );
1029 }
1030 output.push_str("---\n");
1031 let title = config
1032 .title
1033 .clone()
1034 .unwrap_or_else(|| "Draft temporal correlation".to_string());
1035 output.push_str(&format!("title: {}\n", yaml_title_str(&title)));
1036 output.push_str(&format!("id: {}\n", yaml_str(correlation_id)));
1037 output.push_str("status: experimental\n");
1038 output.push_str("correlation:\n");
1039 output.push_str(&format!(" type: {correlation_type}\n"));
1040 output.push_str(" rules:\n");
1041 for slot in slots {
1042 output.push_str(&format!(" - {}\n", yaml_str(&slot.name)));
1043 }
1044 output.push_str(" group-by:\n");
1045 for field in group_by {
1046 output.push_str(&format!(" - {}\n", yaml_str(field)));
1047 }
1048 output.push_str(&format!(" timespan: {}\n", timespan.original));
1049 output.push_str(" condition:\n");
1050 output.push_str(&format!(" gte: {}\n", slots.len()));
1051 output.push_str("custom_attributes:\n");
1052 output.push_str(" rsigma.exemplars:\n");
1053 let representative = representative_group(groups, retained);
1054 output.push_str(&format!(
1055 " - name: {}\n",
1056 yaml_str(&format!("observed group {}", representative.id))
1057 ));
1058 output.push_str(" expect: match\n");
1059 output.push_str(" events:\n");
1060 let first = representative
1061 .events
1062 .iter()
1063 .find(|event| retained.contains(&event.cluster))
1064 .map(|event| event.time)
1065 .unwrap_or(0);
1066 for event in representative
1067 .events
1068 .iter()
1069 .filter(|event| retained.contains(&event.cluster))
1070 {
1071 output.push_str(&format!(
1072 " - offset: {}s\n",
1073 event.time - first
1074 ));
1075 output.push_str(" event:\n");
1076 emit_json_mapping(&mut output, &event.event, " ");
1077 }
1078 output.push_str("level: medium\n");
1079 output
1080}
1081
1082fn representative_group<'a>(
1083 groups: &'a [NormalizedGroup],
1084 retained: &BTreeSet<usize>,
1085) -> &'a NormalizedGroup {
1086 let mut ranked: Vec<(u64, &str, &NormalizedGroup)> = groups
1087 .iter()
1088 .map(|group| {
1089 let times: Vec<i64> = group
1090 .events
1091 .iter()
1092 .filter(|event| retained.contains(&event.cluster))
1093 .map(|event| event.time)
1094 .collect();
1095 let span = times
1096 .last()
1097 .zip(times.first())
1098 .and_then(|(last, first)| last.checked_sub(*first))
1099 .and_then(|value| u64::try_from(value).ok())
1100 .unwrap_or(0);
1101 (span, group.id.as_str(), group)
1102 })
1103 .collect();
1104 ranked.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
1105 ranked[ranked.len() / 2].2
1106}
1107
1108fn emit_json_mapping(output: &mut String, value: &Value, indent: &str) {
1109 let Some(mapping) = value.as_object() else {
1110 output.push_str(&format!(
1111 "{indent}value: {}\n",
1112 yaml_str(&value.to_string())
1113 ));
1114 return;
1115 };
1116 let mut keys: Vec<&String> = mapping.keys().collect();
1117 keys.sort();
1118 for key in keys {
1119 emit_json_value(output, key, &mapping[key], indent);
1120 }
1121}
1122
1123fn emit_json_value(output: &mut String, key: &str, value: &Value, indent: &str) {
1124 let key = yaml_str(key);
1125 match value {
1126 Value::Null => output.push_str(&format!("{indent}{key}: null\n")),
1127 Value::Bool(value) => output.push_str(&format!("{indent}{key}: {value}\n")),
1128 Value::Number(value) => output.push_str(&format!("{indent}{key}: {value}\n")),
1129 Value::String(value) => {
1130 output.push_str(&format!("{indent}{key}: {}\n", yaml_str(value)));
1131 }
1132 Value::Object(_) => {
1133 output.push_str(&format!("{indent}{key}:\n"));
1134 emit_json_mapping(output, value, &format!("{indent} "));
1135 }
1136 Value::Array(values) => {
1137 output.push_str(&format!("{indent}{key}:\n"));
1138 for value in values {
1139 match value {
1140 Value::Null => output.push_str(&format!("{indent} - null\n")),
1141 Value::Bool(value) => {
1142 output.push_str(&format!("{indent} - {value}\n"));
1143 }
1144 Value::Number(value) => {
1145 output.push_str(&format!("{indent} - {value}\n"));
1146 }
1147 Value::String(value) => {
1148 output.push_str(&format!("{indent} - {}\n", yaml_str(value)));
1149 }
1150 _ => output
1151 .push_str(&format!("{indent} - {}\n", yaml_str(&value.to_string()))),
1152 }
1153 }
1154 }
1155 }
1156}
1157
1158fn verify_identity(
1159 collection: &rsigma_parser::SigmaCollection,
1160 groups: &[NormalizedGroup],
1161 retained: &BTreeSet<usize>,
1162 slots: &[Slot],
1163) -> Result<Option<(usize, usize)>, CorrelationDraftError> {
1164 for (group_index, group) in groups.iter().enumerate() {
1165 for (event_index, event) in group
1166 .events
1167 .iter()
1168 .enumerate()
1169 .filter(|(_, event)| retained.contains(&event.cluster))
1170 {
1171 let assigned = slots
1172 .iter()
1173 .position(|slot| slot.cluster == event.cluster)
1174 .expect("retained cluster has a slot");
1175 let local_event = slots[assigned]
1176 .members
1177 .iter()
1178 .position(|member| *member == (group_index, event_index))
1179 .expect("slot member exists");
1180 let mut engine = CorrelationEngine::new(CorrelationConfig::default());
1181 engine
1182 .add_collection(collection)
1183 .map_err(|error| CorrelationDraftError::Internal {
1184 stage: "compile",
1185 message: error.to_string(),
1186 })?;
1187 let json = JsonEvent::borrow(&event.event);
1188 let results = engine.process_event_at(&json, 1_700_000_000);
1189 let matched: BTreeSet<&str> = results
1190 .iter()
1191 .filter(|result| result.is_detection())
1192 .filter_map(|result| result.header.rule_id.as_deref())
1193 .collect();
1194 let assigned_id = slots[assigned].id.as_deref();
1195 if assigned_id.is_none_or(|id| !matched.contains(id)) {
1196 return Ok(Some((assigned, local_event)));
1197 }
1198 let colliding: Vec<String> = slots
1199 .iter()
1200 .enumerate()
1201 .filter(|(index, slot)| {
1202 *index != assigned && slot.id.as_deref().is_some_and(|id| matched.contains(id))
1203 })
1204 .map(|(_, slot)| slot.name.clone())
1205 .collect();
1206 if !colliding.is_empty() {
1207 let mut forms = selected_forms(&slots[assigned]);
1208 for slot in slots.iter().filter(|slot| colliding.contains(&slot.name)) {
1209 forms.extend(selected_forms(slot));
1210 }
1211 return Err(CorrelationDraftError::CrossSlotMatch {
1212 group: group.id.clone(),
1213 event: event.input_index,
1214 assigned: slots[assigned].name.clone(),
1215 colliding,
1216 forms,
1217 });
1218 }
1219 }
1220 }
1221 Ok(None)
1222}
1223
1224fn selected_forms(slot: &Slot) -> Vec<String> {
1225 slot.candidate
1226 .selected
1227 .iter()
1228 .map(|&index| {
1229 let profile = &slot.candidate.profiles[index];
1230 format!(
1231 "{}={:?}",
1232 profile.field(),
1233 profile.form.as_ref().expect("selected form")
1234 )
1235 })
1236 .collect()
1237}
1238
1239fn verify_groups(
1240 collection: &rsigma_parser::SigmaCollection,
1241 groups: &[NormalizedGroup],
1242 retained: &BTreeSet<usize>,
1243 correlation_id: &str,
1244 negative: bool,
1245) -> Result<Vec<CorrelationVerification>, CorrelationDraftError> {
1246 let mut rows = Vec::with_capacity(groups.len());
1247 let mut failed_negatives = Vec::new();
1248 for group in groups {
1249 let mut engine = CorrelationEngine::new(CorrelationConfig::default());
1250 engine
1251 .add_collection(collection)
1252 .map_err(|error| CorrelationDraftError::Internal {
1253 stage: "compile",
1254 message: error.to_string(),
1255 })?;
1256 let replay_events: Vec<&NormalizedEvent> = group
1262 .events
1263 .iter()
1264 .filter(|event| negative || retained.contains(&event.cluster))
1265 .collect();
1266 let first = replay_events.first().map(|event| event.time).unwrap_or(0);
1267 let mut fired = false;
1268 for (index, event) in replay_events.iter().enumerate() {
1269 let json = JsonEvent::borrow(&event.event);
1270 let timestamp = 1_700_000_000i64
1271 .checked_add(event.time - first)
1272 .ok_or(CorrelationDraftError::WindowOverflow)?;
1273 let target = engine
1274 .process_event_at(&json, timestamp)
1275 .iter()
1276 .any(|result| {
1277 result.is_correlation()
1278 && result.header.rule_id.as_deref() == Some(correlation_id)
1279 });
1280 if target {
1281 fired = true;
1282 if !negative && index + 1 < replay_events.len() {
1283 return Err(CorrelationDraftError::PositiveVerification {
1284 group: group.id.clone(),
1285 reason: format!("fired prematurely at retained event {index}"),
1286 });
1287 }
1288 }
1289 }
1290 if negative && fired {
1291 failed_negatives.push(group.id.clone());
1292 } else if !negative && !fired {
1293 return Err(CorrelationDraftError::PositiveVerification {
1294 group: group.id.clone(),
1295 reason: "did not fire by the end of the group".to_string(),
1296 });
1297 }
1298 rows.push(CorrelationVerification {
1299 group: group.id.clone(),
1300 negative,
1301 fired,
1302 });
1303 }
1304 if !failed_negatives.is_empty() {
1305 return Err(CorrelationDraftError::NegativeGroupMatched {
1306 groups: failed_negatives,
1307 });
1308 }
1309 Ok(rows)
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314 use serde_json::json;
1315
1316 use super::*;
1317
1318 fn timed(offset: Option<&str>, timestamp: Option<&str>, event: Value) -> TimedEvent {
1319 TimedEvent {
1320 timestamp: timestamp.map(str::to_string),
1321 offset: offset.map(str::to_string),
1322 event,
1323 source: SourceLocation::default(),
1324 }
1325 }
1326
1327 fn group(id: &str, user: &str, reversed: bool) -> GroupedExemplar {
1328 let first = timed(
1329 Some(if reversed { "20s" } else { "0s" }),
1330 None,
1331 json!({"kind": "reset", "user": user, "factor": "totp", "reset_reason": "recovery"}),
1332 );
1333 let second = timed(
1334 Some(if reversed { "0s" } else { "20s" }),
1335 None,
1336 json!({"kind": "session", "user": user, "new_asn": true, "asn": 64512, "session_type": "web"}),
1337 );
1338 GroupedExemplar {
1339 id: id.to_string(),
1340 events: vec![first, second],
1341 }
1342 }
1343
1344 fn groups() -> Vec<GroupedExemplar> {
1345 vec![
1346 group("g1", "alice", false),
1347 group("g2", "bob", false),
1348 group("g3", "carol", false),
1349 ]
1350 }
1351
1352 fn config() -> CorrelationDraftConfig {
1353 CorrelationDraftConfig {
1354 correlation_id: Some("00000000-0000-4000-8000-000000000003".to_string()),
1355 slot_ids: vec![
1356 "00000000-0000-4000-8000-000000000001".to_string(),
1357 "00000000-0000-4000-8000-000000000002".to_string(),
1358 ],
1359 detection: DraftConfig {
1360 date: Some("2026-09-04".to_string()),
1361 min_fields: 1,
1362 ..DraftConfig::default()
1363 },
1364 ..CorrelationDraftConfig::default()
1365 }
1366 }
1367
1368 #[test]
1369 fn validates_and_sorts_timestamp_groups() {
1370 let mut values = groups();
1371 for (index, group) in values.iter_mut().enumerate() {
1372 group.events[0].offset = None;
1373 group.events[0].timestamp = Some(format!("2026-01-01T00:00:{:02}Z", 20 + index));
1374 group.events[1].offset = None;
1375 group.events[1].timestamp = Some(format!("2026-01-01T00:00:{index:02}Z"));
1376 }
1377 let normalized = normalize_groups(&values, 3).unwrap();
1378 assert!(
1379 normalized
1380 .iter()
1381 .all(|group| group.events[0].time < group.events[1].time)
1382 );
1383 }
1384
1385 #[test]
1386 fn validates_offset_groups() {
1387 let normalized = normalize_groups(&groups(), 3).unwrap();
1388 assert_eq!(normalized[0].events[0].time, 0);
1389 assert_eq!(normalized[0].events[1].time, 20);
1390 }
1391
1392 #[test]
1393 fn rejects_mixed_neither_both_invalid_and_duplicate_times() {
1394 let mut mixed = groups();
1395 mixed[0].events[1].offset = None;
1396 mixed[0].events[1].timestamp = Some("2026-01-01T00:00:00Z".to_string());
1397 assert!(matches!(
1398 normalize_groups(&mixed, 3),
1399 Err(CorrelationDraftError::MixedTimeMode { .. })
1400 ));
1401
1402 let mut neither = groups();
1403 neither[0].events[0].offset = None;
1404 assert!(matches!(
1405 normalize_groups(&neither, 3),
1406 Err(CorrelationDraftError::InvalidTimeKeys { .. })
1407 ));
1408
1409 let mut both = groups();
1410 both[0].events[0].timestamp = Some("2026-01-01T00:00:00Z".to_string());
1411 assert!(matches!(
1412 normalize_groups(&both, 3),
1413 Err(CorrelationDraftError::InvalidTimeKeys { .. })
1414 ));
1415
1416 let mut invalid = groups();
1417 invalid[0].events[0].offset = Some("bad".to_string());
1418 assert!(matches!(
1419 normalize_groups(&invalid, 3),
1420 Err(CorrelationDraftError::InvalidTime { .. })
1421 ));
1422
1423 let mut duplicate = groups();
1424 duplicate[0].events[1].offset = Some("0s".to_string());
1425 assert!(matches!(
1426 normalize_groups(&duplicate, 3),
1427 Err(CorrelationDraftError::DuplicateTime { .. })
1428 ));
1429 }
1430
1431 #[test]
1432 fn rejects_too_few_groups_and_events() {
1433 assert!(matches!(
1434 normalize_groups(&groups()[..2], 3),
1435 Err(CorrelationDraftError::TooFewGroups { .. })
1436 ));
1437 let mut values = groups();
1438 values[0].events.pop();
1439 assert!(matches!(
1440 normalize_groups(&values, 3),
1441 Err(CorrelationDraftError::TooFewEvents { .. })
1442 ));
1443 }
1444
1445 #[test]
1446 fn drafts_ordered_collection_deterministically() {
1447 let first = draft_correlation(&groups(), &[], &[], &config()).unwrap();
1448 let second = draft_correlation(&groups(), &[], &[], &config()).unwrap();
1449 assert_eq!(first.rule_yaml, second.rule_yaml);
1450 assert_eq!(
1451 first.rule_yaml,
1452 include_str!("golden/correlation_ordered.yaml")
1453 );
1454 assert_eq!(first.correlation_type, "temporal_ordered");
1455 assert_eq!(first.group_by, vec!["user"]);
1456 assert!(first.rule_yaml.contains("rsigma.exemplars:"));
1457 assert!(rsigma_parser::parse_sigma_yaml(&first.rule_yaml).is_ok());
1458 assert!(first.verification.iter().all(|row| row.fired));
1459 }
1460
1461 #[test]
1462 fn unordered_collection_matches_golden() {
1463 let mut cfg = config();
1464 cfg.correlation_type = CorrelationDraftType::Temporal;
1465 let report = draft_correlation(&groups(), &[], &[], &cfg).unwrap();
1466 assert_eq!(
1467 report.rule_yaml,
1468 include_str!("golden/correlation_unordered.yaml")
1469 );
1470 }
1471
1472 #[test]
1473 fn auto_downgrades_order_inversions_and_forced_order_errors() {
1474 let mut values = groups();
1475 values[2] = group("g3", "carol", true);
1476 let report = draft_correlation(&values, &[], &[], &config()).unwrap();
1477 assert_eq!(report.correlation_type, "temporal");
1478 assert!(report.warnings.iter().any(|warning| warning.contains("g3")));
1479
1480 let mut forced = config();
1481 forced.correlation_type = CorrelationDraftType::TemporalOrdered;
1482 assert!(matches!(
1483 draft_correlation(&values, &[], &[], &forced),
1484 Err(CorrelationDraftError::OrderInversion { .. })
1485 ));
1486 }
1487
1488 #[test]
1489 fn majority_order_blames_the_actual_outlier() {
1490 let mut values = groups();
1493 values[0] = group("g1", "alice", true);
1494 let report = draft_correlation(&values, &[], &[], &config()).unwrap();
1495 assert_eq!(report.correlation_type, "temporal");
1496 assert!(report.warnings.iter().any(|warning| {
1497 warning.contains("g1") && !warning.contains("g2") && !warning.contains("g3")
1498 }));
1499 }
1500
1501 #[test]
1502 fn window_uses_maximum_span_and_never_rounds_down() {
1503 let mut values = groups();
1504 values[1].events[1].offset = Some("61s".to_string());
1505 let report = draft_correlation(&values, &[], &[], &config()).unwrap();
1506 assert_eq!(report.span_seconds, vec![20, 61, 20]);
1507 assert_eq!(report.timespan, "5m");
1508 assert!(matches!(
1509 infer_window(
1510 &normalize_groups(&groups(), 3).unwrap(),
1511 &BTreeSet::new(),
1512 0.9
1513 ),
1514 Err(CorrelationDraftError::InvalidWindowMargin(_))
1515 ));
1516 assert_eq!(round_window(86_401).unwrap(), "2d");
1517 }
1518
1519 #[test]
1520 fn incidental_clusters_are_dropped_with_a_warning() {
1521 let mut values = groups();
1522 values[0].events.push(timed(
1523 Some("10s"),
1524 None,
1525 json!({"audit_only": true, "trace_marker": "one"}),
1526 ));
1527 let report = draft_correlation(&values, &[], &[], &config()).unwrap();
1528 assert!(
1529 report
1530 .warnings
1531 .iter()
1532 .any(|warning| warning.contains("incidental key-shape"))
1533 );
1534 assert_eq!(report.slots.len(), 2);
1535 }
1536
1537 #[test]
1538 fn repeated_retained_slot_is_a_pointed_error() {
1539 let mut values = groups();
1540 values[0].events.push(timed(
1541 Some("10s"),
1542 None,
1543 json!({"kind": "reset", "user": "alice", "factor": "totp", "reset_reason": "recovery"}),
1544 ));
1545 assert!(matches!(
1548 draft_correlation(&values, &[], &[], &config()),
1549 Err(CorrelationDraftError::DuplicateSlot {
1550 group,
1551 slot: _,
1552 events
1553 }) if group == "g1" && events == vec![0, 2]
1554 ));
1555 }
1556
1557 #[test]
1558 fn missing_slot_ids_error_before_verification() {
1559 let mut cfg = config();
1560 cfg.slot_ids.truncate(1);
1561 cfg.detection.rule_id = None;
1562 assert!(matches!(
1563 draft_correlation(&groups(), &[], &[], &cfg),
1564 Err(CorrelationDraftError::MissingSlotIds {
1565 slots: 2,
1566 provided: 1
1567 })
1568 ));
1569 }
1570
1571 #[test]
1572 fn too_few_recurring_slots_is_an_error() {
1573 let values: Vec<GroupedExemplar> = [("g1", "alice"), ("g2", "bob"), ("g3", "carol")]
1574 .into_iter()
1575 .map(|(id, user)| GroupedExemplar {
1576 id: id.to_string(),
1577 events: vec![
1578 timed(Some("0s"), None, json!({"kind": "same", "user": user})),
1579 timed(Some("10s"), None, json!({"kind": "other", "user": user})),
1580 ],
1581 })
1582 .collect();
1583 assert!(matches!(
1584 draft_correlation(&values, &[], &[], &config()),
1585 Err(CorrelationDraftError::TooFewSlots {
1586 minimum: 2,
1587 actual: 1
1588 })
1589 ));
1590 }
1591
1592 #[test]
1593 fn explicit_composite_entity_is_excluded_from_slots() {
1594 let mut values = groups();
1595 for group in &mut values {
1596 let tenant = format!("tenant-{}", group.id);
1597 for event in &mut group.events {
1598 event.event["tenant"] = json!(tenant);
1599 }
1600 }
1601 let mut cfg = config();
1602 cfg.group_by = vec!["user".to_string(), "tenant".to_string()];
1603 let report = draft_correlation(&values, &[], &[], &cfg).unwrap();
1604 assert_eq!(report.group_by, cfg.group_by);
1605 assert!(
1606 report
1607 .slots
1608 .iter()
1609 .flat_map(|slot| &slot.selected_fields)
1610 .all(|field| !field.starts_with("user=") && !field.starts_with("tenant="))
1611 );
1612 }
1613
1614 #[test]
1615 fn rejects_ambiguous_entity_and_negative_matches() {
1616 let mut ambiguous = groups();
1617 for group in &mut ambiguous {
1618 let tenant = format!("tenant-{}", group.id);
1619 for event in &mut group.events {
1620 event.event["tenant"] = json!(tenant);
1621 }
1622 }
1623 match draft_correlation(&ambiguous, &[], &[], &config()) {
1624 Err(CorrelationDraftError::AmbiguousEntity { candidates }) => {
1625 assert_eq!(candidates, vec!["tenant", "user"]);
1626 }
1627 other => panic!("expected deterministic entity ambiguity, got {other:?}"),
1628 }
1629 assert!(matches!(
1630 draft_correlation(&groups(), &[group("bad", "mallory", false)], &[], &config()),
1631 Err(CorrelationDraftError::NegativeGroupMatched { .. })
1632 ));
1633 }
1634
1635 #[test]
1636 fn invalid_entity_reports_input_position() {
1637 let mut values = groups();
1640 values[0] = group("g1", "alice", true);
1641 values[0].events[1]
1642 .event
1643 .as_object_mut()
1644 .unwrap()
1645 .remove("user");
1646 let mut cfg = config();
1647 cfg.group_by = vec!["user".to_string()];
1648 assert!(matches!(
1649 draft_correlation(&values, &[], &[], &cfg),
1650 Err(CorrelationDraftError::InvalidEntity { field, group, event })
1651 if field == "user" && group == "g1" && event == 1
1652 ));
1653 }
1654
1655 #[test]
1656 fn entity_inference_refuses_to_guess_when_no_field_is_group_stable() {
1657 let mut values = groups();
1658 for group in &mut values {
1659 group.events[1].event["user"] = json!(format!("{}-other", group.id));
1660 }
1661 assert!(matches!(
1662 draft_correlation(&values, &[], &[], &config()),
1663 Err(CorrelationDraftError::AmbiguousEntity { candidates }) if candidates.is_empty()
1664 ));
1665 }
1666
1667 #[test]
1668 fn clean_negative_group_isolated_replay_stays_clear() {
1669 let mut negative = group("benign", "mallory", false);
1670 negative.events[0].event["factor"] = json!("webauthn");
1671 negative.events[0].event["reset_reason"] = json!("admin");
1672 negative.events[1].event["asn"] = json!(64496);
1673 negative.events[1].event["new_asn"] = json!(false);
1674 negative.events[1].event["session_type"] = json!("mobile");
1675 let report = draft_correlation(&groups(), &[negative], &[], &config()).unwrap();
1676 assert!(
1677 report
1678 .verification
1679 .iter()
1680 .any(|row| { row.group == "benign" && row.negative && !row.fired })
1681 );
1682 }
1683
1684 #[test]
1685 fn negative_events_outside_retained_clusters_still_replay() {
1686 let mut negative = group("shifted", "mallory", false);
1691 for (event, extras) in negative.events.iter_mut().zip([3usize, 4]) {
1692 for extra in 0..extras {
1693 event.event[format!("extra_{extra}")] = json!("noise");
1694 }
1695 }
1696 assert!(matches!(
1697 draft_correlation(&groups(), &[negative], &[], &config()),
1698 Err(CorrelationDraftError::NegativeGroupMatched { groups }) if groups == vec!["shifted"]
1699 ));
1700 }
1701
1702 #[test]
1703 fn verification_ignores_unrelated_correlation_results() {
1704 let report = draft_correlation(&groups(), &[], &[], &config()).unwrap();
1705 let collection = rsigma_parser::parse_sigma_yaml(&report.rule_yaml).unwrap();
1706 let mut normalized = normalize_groups(&groups(), 3).unwrap();
1707 assign_clusters(&mut normalized, 0.6);
1708 let (retained, _) = retained_clusters(&normalized, 2).unwrap();
1709 assert!(matches!(
1710 verify_groups(
1711 &collection,
1712 &normalized,
1713 &retained,
1714 "unrelated-correlation",
1715 false
1716 ),
1717 Err(CorrelationDraftError::PositiveVerification { .. })
1718 ));
1719 }
1720
1721 #[test]
1722 fn verification_rejects_premature_target_firing() {
1723 let report = draft_correlation(&groups(), &[], &[], &config()).unwrap();
1724 let yaml = report
1725 .rule_yaml
1726 .replace(
1727 " - slot_totp\n - slot_64512\n",
1728 " - slot_totp\n",
1729 )
1730 .replace(" gte: 2\n", " gte: 1\n");
1731 let collection = rsigma_parser::parse_sigma_yaml(&yaml).unwrap();
1732 let mut normalized = normalize_groups(&groups(), 3).unwrap();
1733 assign_clusters(&mut normalized, 0.6);
1734 let (retained, _) = retained_clusters(&normalized, 2).unwrap();
1735 assert!(matches!(
1736 verify_groups(
1737 &collection,
1738 &normalized,
1739 &retained,
1740 "00000000-0000-4000-8000-000000000003",
1741 false
1742 ),
1743 Err(CorrelationDraftError::PositiveVerification { reason, .. })
1744 if reason.contains("prematurely")
1745 ));
1746 }
1747}