rsigma-eval 0.15.0

Evaluator for Sigma detection and correlation rules — match rules against events
Documentation
//! Event abstraction for Sigma rule evaluation.
//!
//! Provides the [`Event`] trait for generic event access, the [`EventValue`]
//! enum representing field values, and concrete implementations:
//! - [`JsonEvent`] — zero-copy wrapper around `serde_json::Value`
//! - [`KvEvent`] — flat key-value pairs (e.g., from logfmt / syslog)
//! - [`PlainEvent`] — raw log line (keyword matching only)
//! - [`MapEvent`] — generic `HashMap<K, V>` adapter

mod json;
mod kv;
mod map;
mod plain;

pub use json::JsonEvent;
pub(crate) use json::resolve_array_index;
pub use kv::KvEvent;
pub use map::MapEvent;
pub use plain::PlainEvent;

use std::borrow::Cow;

use serde_json::Value;

// =============================================================================
// EventValue
// =============================================================================

/// A value retrieved from an event field.
///
/// Supports zero-copy borrows from JSON-backed events (`Cow::Borrowed`)
/// and owned values from non-JSON sources (`Cow::Owned`).
/// Null is distinct from field-absent (`get_field` returns `None`).
#[derive(Debug, Clone, PartialEq)]
pub enum EventValue<'a> {
    Str(Cow<'a, str>),
    Int(i64),
    Float(f64),
    Bool(bool),
    Null,
    Array(Vec<EventValue<'a>>),
    Map(Vec<(Cow<'a, str>, EventValue<'a>)>),
}

impl<'a> EventValue<'a> {
    /// Coerce to string. Str as-is, Int/Float decimal, Bool "true"/"false".
    #[inline]
    pub fn as_str(&self) -> Option<Cow<'_, str>> {
        match self {
            EventValue::Str(s) => Some(Cow::Borrowed(s)),
            EventValue::Int(n) => Some(Cow::Owned(n.to_string())),
            EventValue::Float(f) => Some(Cow::Owned(f.to_string())),
            EventValue::Bool(b) => Some(Cow::Borrowed(if *b { "true" } else { "false" })),
            _ => None,
        }
    }

    /// Coerce to f64. Int lossless, Float as-is, Str parsed.
    #[inline]
    pub fn as_f64(&self) -> Option<f64> {
        match self {
            EventValue::Float(f) => Some(*f),
            EventValue::Int(n) => Some(*n as f64),
            EventValue::Str(s) => s.parse().ok(),
            _ => None,
        }
    }

    /// Coerce to i64. Int as-is, Float truncated if exact, Str parsed.
    #[inline]
    pub fn as_i64(&self) -> Option<i64> {
        match self {
            EventValue::Int(n) => Some(*n),
            EventValue::Float(f) => {
                let truncated = *f as i64;
                if (truncated as f64 - f).abs() < f64::EPSILON {
                    Some(truncated)
                } else {
                    None
                }
            }
            EventValue::Str(s) => s.parse().ok(),
            _ => None,
        }
    }

    /// Coerce to bool. Bool as-is, Str: true/false/1/0/yes/no.
    #[inline]
    pub fn as_bool(&self) -> Option<bool> {
        match self {
            EventValue::Bool(b) => Some(*b),
            EventValue::Str(s) => match s.to_lowercase().as_str() {
                "true" | "1" | "yes" => Some(true),
                "false" | "0" | "no" => Some(false),
                _ => None,
            },
            _ => None,
        }
    }

    #[inline]
    pub fn is_null(&self) -> bool {
        matches!(self, EventValue::Null)
    }

    /// Convert to `serde_json::Value`.
    pub fn to_json(&self) -> Value {
        match self {
            EventValue::Str(s) => Value::String(s.to_string()),
            EventValue::Int(n) => Value::Number((*n).into()),
            EventValue::Float(f) => {
                serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
            }
            EventValue::Bool(b) => Value::Bool(*b),
            EventValue::Null => Value::Null,
            EventValue::Array(arr) => Value::Array(arr.iter().map(|v| v.to_json()).collect()),
            EventValue::Map(entries) => {
                let map = entries
                    .iter()
                    .map(|(k, v)| (k.to_string(), v.to_json()))
                    .collect();
                Value::Object(map)
            }
        }
    }
}

impl<'a> From<&'a Value> for EventValue<'a> {
    fn from(v: &'a Value) -> Self {
        match v {
            Value::String(s) => EventValue::Str(Cow::Borrowed(s.as_str())),
            Value::Number(n) => {
                if let Some(i) = n.as_i64() {
                    EventValue::Int(i)
                } else {
                    EventValue::Float(n.as_f64().unwrap_or(f64::NAN))
                }
            }
            Value::Bool(b) => EventValue::Bool(*b),
            Value::Null => EventValue::Null,
            Value::Array(arr) => EventValue::Array(arr.iter().map(EventValue::from).collect()),
            Value::Object(map) => EventValue::Map(
                map.iter()
                    .map(|(k, v)| (Cow::Borrowed(k.as_str()), EventValue::from(v)))
                    .collect(),
            ),
        }
    }
}

// =============================================================================
// Event trait
// =============================================================================

/// Generic interface for accessing event data during Sigma rule evaluation.
///
/// Implementations provide field lookup (with dot-notation), keyword search
/// over all string values, and serialization to JSON for correlation storage.
pub trait Event {
    /// Look up a field by name. Supports dot-notation for nested access.
    ///
    /// Returns `None` if the field is absent.
    /// Returns `Some(EventValue::Null)` if the field exists but is null.
    fn get_field(&self, path: &str) -> Option<EventValue<'_>>;

    /// Check if any string value anywhere in the event satisfies a predicate.
    /// Used by keyword detection.
    fn any_string_value(&self, pred: &dyn Fn(&str) -> bool) -> bool;

    /// Collect all string values in the event.
    fn all_string_values(&self) -> Vec<Cow<'_, str>>;

    /// Materialize the event as a `serde_json::Value`.
    fn to_json(&self) -> Value;

    /// Collect the names of every leaf field in the event, with nested
    /// objects flattened to dot-separated paths (e.g. `actor.id`).
    /// Intermediate object names are not emitted; only leaves count.
    /// Arrays contribute their parent path once; per-index suffixes are
    /// not emitted.
    ///
    /// Used by the daemon's opt-in field-observability surface; not on the
    /// detection hot path. The default implementation walks `to_json()`,
    /// which clones the event and allocates one `String` per leaf path;
    /// concrete event types override to skip the `to_json()` clone. The
    /// per-leaf `String` allocation is unavoidable for nested objects
    /// (the dot-joined path doesn't exist anywhere in the source) but
    /// flat formats like `KvEvent` can return `Cow::Borrowed`.
    fn field_keys(&self) -> Vec<Cow<'_, str>> {
        let mut paths: Vec<String> = Vec::new();
        collect_field_keys_json(&self.to_json(), "", &mut paths);
        paths.into_iter().map(Cow::Owned).collect()
    }
}

/// Maximum nesting depth honoured by `field_keys` default + JsonEvent
/// overrides. Matches the existing 64-level cap used elsewhere in the
/// crate for recursive JSON traversal.
pub(crate) const FIELD_KEYS_MAX_DEPTH: usize = 64;

/// Walk a JSON value and push every leaf field path into `out`. The
/// helper threads owned `String`s rather than `Cow` because every path
/// is constructed by `format!`-joining (or copying the top-level key),
/// so there are no borrowed shortcuts to capture.
pub(crate) fn collect_field_keys_json(value: &Value, prefix: &str, out: &mut Vec<String>) {
    collect_field_keys_json_depth(value, prefix, out, FIELD_KEYS_MAX_DEPTH);
}

fn collect_field_keys_json_depth(value: &Value, prefix: &str, out: &mut Vec<String>, depth: usize) {
    if depth == 0 {
        return;
    }
    if let Value::Object(map) = value {
        for (k, v) in map {
            let path = if prefix.is_empty() {
                k.clone()
            } else {
                format!("{prefix}.{k}")
            };
            match v {
                // Recurse into nested objects without emitting the
                // intermediate path; only leaf descendants count.
                Value::Object(_) => collect_field_keys_json_depth(v, &path, out, depth - 1),
                _ => out.push(path),
            }
        }
    }
}

impl<T: Event + ?Sized> Event for &T {
    fn get_field(&self, path: &str) -> Option<EventValue<'_>> {
        (**self).get_field(path)
    }

    fn any_string_value(&self, pred: &dyn Fn(&str) -> bool) -> bool {
        (**self).any_string_value(pred)
    }

    fn all_string_values(&self) -> Vec<Cow<'_, str>> {
        (**self).all_string_values()
    }

    fn to_json(&self) -> Value {
        (**self).to_json()
    }

    fn field_keys(&self) -> Vec<Cow<'_, str>> {
        (**self).field_keys()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn event_value_as_str() {
        assert_eq!(EventValue::Str(Cow::Borrowed("hi")).as_str().unwrap(), "hi");
        assert_eq!(EventValue::Int(42).as_str().unwrap(), "42");
        assert_eq!(EventValue::Float(2.71).as_str().unwrap(), "2.71");
        assert_eq!(EventValue::Bool(true).as_str().unwrap(), "true");
        assert!(EventValue::Null.as_str().is_none());
    }

    #[test]
    fn event_value_as_f64() {
        assert_eq!(EventValue::Float(2.71).as_f64(), Some(2.71));
        assert_eq!(EventValue::Int(42).as_f64(), Some(42.0));
        assert_eq!(EventValue::Str(Cow::Borrowed("1.5")).as_f64(), Some(1.5));
        assert!(EventValue::Bool(true).as_f64().is_none());
    }

    #[test]
    fn event_value_as_i64() {
        assert_eq!(EventValue::Int(42).as_i64(), Some(42));
        assert_eq!(EventValue::Float(42.0).as_i64(), Some(42));
        assert_eq!(EventValue::Float(42.5).as_i64(), None);
        assert_eq!(EventValue::Str(Cow::Borrowed("100")).as_i64(), Some(100));
    }

    #[test]
    fn event_value_to_json() {
        assert_eq!(EventValue::Str(Cow::Borrowed("hi")).to_json(), json!("hi"));
        assert_eq!(EventValue::Int(42).to_json(), json!(42));
        assert_eq!(EventValue::Bool(true).to_json(), json!(true));
        assert_eq!(EventValue::Null.to_json(), Value::Null);
    }
}