mod helpers;
use helpers::{corr_engine, corr_engine_with_config, process};
use rsigma_eval::{
CorrelationAction, CorrelationConfig, CorrelationEngine, JsonEvent, TimestampFallback,
};
use rsigma_parser::parse_sigma_yaml;
use serde_json::json;
const EVENT_COUNT_YAML: &str = r#"
title: Login
id: login-rule
logsource:
category: auth
detection:
selection:
EventType: login
condition: selection
---
title: Many Logins
correlation:
type: event_count
rules:
- login-rule
group-by:
- User
timespan: 60s
condition:
gte: 3
level: high
"#;
fn login_event(user: &str) -> serde_json::Value {
json!({"EventType": "login", "User": user})
}
#[test]
fn window_expiry_all_events_stale() {
let mut engine = corr_engine(EVENT_COUNT_YAML);
let base = 1000;
for i in 0..3 {
process(&mut engine, login_event("admin"), base + i);
}
let r = process(&mut engine, login_event("admin"), base + 200);
assert!(
r.correlations.is_empty(),
"stale events should have expired, only 1 event in window"
);
}
#[test]
fn exact_window_boundary() {
let mut engine = corr_engine(EVENT_COUNT_YAML);
let base = 1000;
process(&mut engine, login_event("admin"), base);
process(&mut engine, login_event("admin"), base + 59);
let r = process(&mut engine, login_event("admin"), base + 60);
let fired = !r.correlations.is_empty();
if fired {
assert_eq!(r.correlations[0].aggregated_value, 3.0);
}
}
#[test]
fn missing_group_by_field_does_not_panic() {
let mut engine = corr_engine(EVENT_COUNT_YAML);
let base = 1000;
for i in 0..5 {
let r = process(&mut engine, json!({"EventType": "login"}), base + i);
if i >= 2 {
let _ = r;
}
}
}
#[test]
fn group_by_with_object_value() {
let mut engine = corr_engine(EVENT_COUNT_YAML);
let base = 1000;
for i in 0..5 {
let r = process(
&mut engine,
json!({"EventType": "login", "User": {"name": "admin"}}),
base + i,
);
let _ = r;
}
}
#[test]
fn temporal_ordered_interleaved_only_correct_sequence_matches() {
let yaml = r#"
title: Rule A
id: rule-a
logsource:
category: test
detection:
selection:
type: a
condition: selection
---
title: Rule B
id: rule-b
logsource:
category: test
detection:
selection:
type: b
condition: selection
---
title: Rule C
id: rule-c
logsource:
category: test
detection:
selection:
type: c
condition: selection
---
title: A then B then C
correlation:
type: temporal_ordered
rules:
- rule-a
- rule-b
- rule-c
group-by:
- User
timespan: 120s
condition:
gte: 3
level: high
"#;
let mut engine = corr_engine(yaml);
let base = 1000;
process(&mut engine, json!({"type": "c", "User": "admin"}), base);
process(&mut engine, json!({"type": "b", "User": "admin"}), base + 1);
let r = process(&mut engine, json!({"type": "a", "User": "admin"}), base + 2);
assert!(r.correlations.is_empty(), "reverse order should not fire");
process(
&mut engine,
json!({"type": "a", "User": "admin"}),
base + 10,
);
process(
&mut engine,
json!({"type": "b", "User": "admin"}),
base + 11,
);
let r = process(
&mut engine,
json!({"type": "c", "User": "admin"}),
base + 12,
);
assert_eq!(r.correlations.len(), 1, "correct A->B->C order should fire");
}
#[test]
fn state_eviction_under_max_state_entries() {
let config = CorrelationConfig {
max_state_entries: 10,
..Default::default()
};
let mut engine = corr_engine_with_config(EVENT_COUNT_YAML, config);
let base = 1000;
for i in 0..15 {
process(
&mut engine,
login_event(&format!("user_{i}")),
base + i as i64,
);
}
for i in 0..3 {
process(&mut engine, login_event("new_user"), base + 20 + i);
}
}
#[test]
fn suppress_prevents_re_fire_within_window() {
let config = CorrelationConfig {
suppress: Some(30),
..Default::default()
};
let mut engine = corr_engine_with_config(EVENT_COUNT_YAML, config);
let base = 1000;
process(&mut engine, login_event("admin"), base);
process(&mut engine, login_event("admin"), base + 1);
let r = process(&mut engine, login_event("admin"), base + 2);
assert_eq!(r.correlations.len(), 1, "should fire first time");
let r = process(&mut engine, login_event("admin"), base + 10);
assert!(r.correlations.is_empty(), "should be suppressed within 30s");
let r = process(&mut engine, login_event("admin"), base + 35);
let _ = r;
}
#[test]
fn reset_action_clears_window_after_firing() {
let config = CorrelationConfig {
action_on_match: CorrelationAction::Reset,
..Default::default()
};
let mut engine = corr_engine_with_config(EVENT_COUNT_YAML, config);
let base = 1000;
process(&mut engine, login_event("admin"), base);
process(&mut engine, login_event("admin"), base + 1);
let r = process(&mut engine, login_event("admin"), base + 2);
assert_eq!(r.correlations.len(), 1, "should fire");
let r = process(&mut engine, login_event("admin"), base + 3);
assert!(r.correlations.is_empty(), "window should have been reset");
process(&mut engine, login_event("admin"), base + 4);
let r = process(&mut engine, login_event("admin"), base + 5);
assert_eq!(
r.correlations.len(),
1,
"should fire again after 3 fresh events"
);
}
#[test]
fn timestamp_fallback_skip_runs_detection_but_skips_correlation() {
let config = CorrelationConfig {
timestamp_fallback: TimestampFallback::Skip,
..Default::default()
};
let collection = parse_sigma_yaml(EVENT_COUNT_YAML).unwrap();
let mut engine = CorrelationEngine::new(config);
engine.add_collection(&collection).unwrap();
for _ in 0..5 {
let ev = json!({"EventType": "login", "User": "admin"});
let event = JsonEvent::borrow(&ev);
let r = engine.process_event(&event);
assert_eq!(r.detections.len(), 1, "detection should fire");
assert!(
r.correlations.is_empty(),
"correlation should be skipped without timestamp"
);
}
}
#[test]
fn multiple_group_by_fields_create_distinct_groups() {
let yaml = r#"
title: Login
id: login-rule
logsource:
category: auth
detection:
selection:
EventType: login
condition: selection
---
title: Login Burst
correlation:
type: event_count
rules:
- login-rule
group-by:
- User
- SourceIP
timespan: 60s
condition:
gte: 2
level: high
"#;
let mut engine = corr_engine(yaml);
let base = 1000;
process(
&mut engine,
json!({"EventType": "login", "User": "admin", "SourceIP": "10.0.0.1"}),
base,
);
let r = process(
&mut engine,
json!({"EventType": "login", "User": "admin", "SourceIP": "10.0.0.2"}),
base + 1,
);
assert!(
r.correlations.is_empty(),
"different (User, SourceIP) groups should not combine"
);
let r = process(
&mut engine,
json!({"EventType": "login", "User": "admin", "SourceIP": "10.0.0.1"}),
base + 2,
);
assert_eq!(r.correlations.len(), 1, "same group should accumulate");
assert_eq!(
r.correlations[0].group_key,
vec![
("User".to_string(), "admin".to_string()),
("SourceIP".to_string(), "10.0.0.1".to_string()),
]
);
}