rsigma-eval 0.24.0

Evaluator for Sigma detection and correlation rules — match rules against events
Documentation
use rsigma_eval::{CorrelationConfig, CorrelationEngine, Engine, EvalError};
use rsigma_parser::{
    Detection, SigmaRule, SigmaString, SigmaValue, parse_condition, parse_sigma_yaml,
};

/// A parsed rule whose `selection` item is rewritten afterwards, as a pipeline
/// can, so the compiler's own checks run on an item the parser never validated.
fn rule_with_item(modifiers: &[&str], value: &str) -> SigmaRule {
    let yaml = r#"
title: Rewritten
logsource:
    product: test
detection:
    selection:
        Field: x
    condition: selection
"#;
    let mut rule = parse_sigma_yaml(yaml).unwrap().rules.remove(0);
    let Some(Detection::AllOf(items)) = rule.detection.named.get_mut("selection") else {
        unreachable!("the selection is a single mapping");
    };
    items[0].field.modifiers = modifiers.iter().map(|m| m.parse().unwrap()).collect();
    items[0].values = vec![SigmaValue::String(SigmaString::new(value))];
    rule
}

#[test]
fn regex_the_evaluator_cannot_run_surfaces_at_compile_time() {
    // Lookbehind is valid Sigma regex syntax, so the parser accepts it, but
    // the evaluator's regex engine cannot run it.
    let yaml = r#"
title: Lookbehind
logsource:
    product: test
detection:
    selection:
        CommandLine|re: '(?<!\\)cmd'
    condition: selection
level: low
"#;
    let collection = parse_sigma_yaml(yaml).unwrap();
    assert!(collection.errors.is_empty(), "{:?}", collection.errors);
    let err = Engine::new().add_collection(&collection).unwrap_err();
    assert!(
        matches!(err, EvalError::InvalidRegex(_)),
        "expected InvalidRegex, got: {err}"
    );
}

#[test]
fn invalid_regex_is_rejected_at_compile_time() {
    let err = Engine::new()
        .add_rule(&rule_with_item(&["re"], "[unclosed"))
        .unwrap_err();
    assert!(
        matches!(err, EvalError::InvalidRegex(_)),
        "expected InvalidRegex, got: {err}"
    );
}

#[test]
fn invalid_cidr_is_rejected_at_compile_time() {
    for (cidr, expected) in [
        ("not-a-cidr", "expected address/prefix"),
        ("10.1.2.3/8", "host bits set"),
        ("2001:db8::1/32", "host bits set"),
    ] {
        let err = Engine::new()
            .add_rule(&rule_with_item(&["cidr"], cidr))
            .unwrap_err();
        assert!(
            matches!(err, EvalError::IncompatibleValue(_)) && err.to_string().contains(expected),
            "{cidr}: {err}"
        );
    }
}

#[test]
fn timestamp_part_with_non_numeric_string_is_incompatible() {
    let err = Engine::new()
        .add_rule(&rule_with_item(&["hour"], "three"))
        .unwrap_err();
    assert!(
        matches!(err, EvalError::IncompatibleValue(_)),
        "expected IncompatibleValue, got: {err}"
    );
}

#[test]
fn numeric_comparison_with_non_numeric_value() {
    let err = Engine::new()
        .add_rule(&rule_with_item(&["gt"], "not_a_number"))
        .unwrap_err();
    assert!(
        matches!(err, EvalError::ExpectedNumeric(_)),
        "expected ExpectedNumeric, got: {err}"
    );
}

#[test]
fn unknown_detection_in_condition_errors_at_compile_time() {
    let mut rule = rule_with_item(&[], "x");
    rule.detection.conditions = vec![parse_condition("selection and selection_b").unwrap()];
    let err = Engine::new().add_rule(&rule).unwrap_err();
    assert!(
        matches!(err, EvalError::UnknownDetection(ref name) if name == "selection_b"),
        "expected UnknownDetection(\"selection_b\"), got: {err}"
    );
}

#[test]
fn extended_condition_on_non_temporal_type() {
    // The CorrelationError fires when the condition is a string expression
    // (like "rule-a and rule-b") on a non-temporal type. A mapping condition
    // {gte: 3} is fine; only the extended string form is rejected.
    let yaml2 = 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: Bad Event Count
correlation:
    type: event_count
    rules:
        - rule-a
        - rule-b
    group-by:
        - User
    timespan: 60s
    condition: rule-a and rule-b
level: high
"#;
    let collection2 = parse_sigma_yaml(yaml2).unwrap();
    let mut engine2 = CorrelationEngine::new(CorrelationConfig::default());
    let err = engine2.add_collection(&collection2).unwrap_err();
    assert!(
        matches!(err, EvalError::CorrelationError(_)),
        "expected CorrelationError for extended condition on event_count, got: {err}"
    );
}

#[test]
fn correlation_cycle_detected_at_add_collection() {
    // A -> B -> C -> A creates a 3-node cycle
    let yaml = r#"
title: Detection
id: det-rule
logsource:
    category: test
detection:
    selection:
        type: event
    condition: selection
---
title: Corr A
id: corr-a
correlation:
    type: event_count
    rules:
        - corr-c
    group-by:
        - User
    timespan: 60s
    condition:
        gte: 2
level: high
---
title: Corr B
id: corr-b
correlation:
    type: event_count
    rules:
        - corr-a
    group-by:
        - User
    timespan: 60s
    condition:
        gte: 2
level: high
---
title: Corr C
id: corr-c
correlation:
    type: event_count
    rules:
        - corr-b
    group-by:
        - User
    timespan: 60s
    condition:
        gte: 2
level: high
"#;
    let collection = parse_sigma_yaml(yaml).unwrap();
    let mut engine = CorrelationEngine::new(CorrelationConfig::default());
    let err = engine.add_collection(&collection).unwrap_err();
    assert!(
        matches!(err, EvalError::CorrelationCycle(_)),
        "expected CorrelationCycle, got: {err}"
    );
}

#[test]
fn unknown_rule_ref_in_correlation_errors_at_add_time() {
    let yaml = r#"
title: Detection
id: det-rule
logsource:
    category: test
detection:
    selection:
        type: event
    condition: selection
---
title: Orphan Correlation
correlation:
    type: event_count
    rules:
        - nonexistent-rule-id
    group-by:
        - User
    timespan: 60s
    condition:
        gte: 1
level: high
"#;
    let collection = parse_sigma_yaml(yaml).unwrap();
    let mut engine = CorrelationEngine::new(CorrelationConfig::default());
    let err = engine.add_collection(&collection).unwrap_err();
    assert!(
        matches!(err, EvalError::UnknownRuleRef(ref name) if name == "nonexistent-rule-id"),
        "expected UnknownRuleRef(\"nonexistent-rule-id\"), got: {err}"
    );
}