Skip to main content

rsigma_eval/
schema.rs

1//! Schema classification: recognize the structure of a parsed event.
2//!
3//! Real-world streams mix log schemas: one feed can carry ECS-normalized
4//! events, raw (rendered) Windows Event Log, flat Sysmon JSON, CEF, OCSF, or
5//! vendor-specific shapes, and the wire format is often still JSON while only
6//! the field names differ. This module recognizes which schema a parsed event
7//! belongs to from its *content* (marker fields and values), not from the
8//! input format, so it works regardless of how the event arrived.
9//!
10//! Classification is declarative: each [`SchemaSignature`] is a set of
11//! [`SchemaPredicate`]s that must all hold (logical AND). The
12//! [`SchemaClassifier`] returns the highest-[`specificity`](SchemaSignature::specificity)
13//! signature that matches, breaking ties by name for determinism. Returning
14//! `None` means the event matched no signature ("unknown"), which is the
15//! actionable signal for surfacing unsupported schemas.
16//!
17//! Built-in signatures cover `ecs`, `ocsf`, `windows_eventlog`, `sysmon`,
18//! `cef`, and a low-specificity `generic_json` fallback for structured events
19//! that match no specific security schema. Users extend the set with their own
20//! signatures loaded from YAML (see [`parse_schema_signatures`]).
21//!
22//! Detection-side only: this recognizes events so callers can route them to the
23//! right field-mapping pipeline. It does not collect, transport, or normalize
24//! events.
25
26use std::collections::HashMap;
27use std::fs;
28use std::path::Path;
29use std::sync::Mutex;
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::time::Instant;
32
33use regex::Regex;
34use rsigma_parser::LogSource;
35use serde::{Deserialize, Serialize};
36
37use crate::event::Event;
38
39/// Numeric comparison operator for [`SchemaPredicate::Compare`].
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CompareOp {
42    /// Strictly greater than.
43    Gt,
44    /// Greater than or equal.
45    Gte,
46    /// Strictly less than.
47    Lt,
48    /// Less than or equal.
49    Lte,
50}
51
52impl CompareOp {
53    fn apply(self, lhs: f64, rhs: f64) -> bool {
54        match self {
55            CompareOp::Gt => lhs > rhs,
56            CompareOp::Gte => lhs >= rhs,
57            CompareOp::Lt => lhs < rhs,
58            CompareOp::Lte => lhs <= rhs,
59        }
60    }
61
62    fn symbol(self) -> &'static str {
63        match self {
64            CompareOp::Gt => ">",
65            CompareOp::Gte => ">=",
66            CompareOp::Lt => "<",
67            CompareOp::Lte => "<=",
68        }
69    }
70}
71
72/// A single condition over a parsed event used to recognize a schema.
73///
74/// Field names use the same dot-notation as [`Event::get_field`], so nested
75/// shapes like `Event.System.EventID` or `ecs.version` work whether the event
76/// is nested or carries flattened dotted keys.
77#[derive(Debug, Clone)]
78pub enum SchemaPredicate {
79    /// The named field is present (any non-absent value, including null).
80    FieldPresent(String),
81    /// The named field is absent.
82    FieldAbsent(String),
83    /// At least one of the named fields is present.
84    AnyOf(Vec<String>),
85    /// The field is present and its string-coerced value equals `value`
86    /// (ASCII case-insensitive).
87    Equals { field: String, value: String },
88    /// The field is present and its string-coerced value matches `regex`.
89    Matches { field: String, regex: Regex },
90    /// The field is present, numeric-coercible, and compares to `value` under
91    /// `op`. A non-numeric or absent field fails closed (no match).
92    Compare {
93        field: String,
94        op: CompareOp,
95        value: f64,
96    },
97    /// The field is present and its string-coerced value equals one of
98    /// `values` (ASCII case-insensitive). The multi-value form of `Equals`.
99    In { field: String, values: Vec<String> },
100    /// Both fields are present, string-coercible, and equal (case-insensitive).
101    FieldEqualsField { left: String, right: String },
102    /// Logical negation of the inner predicate.
103    Not(Box<SchemaPredicate>),
104    /// At least one of the inner predicates holds (logical OR).
105    Any(Vec<SchemaPredicate>),
106    /// All of the inner predicates hold (logical AND). Useful as a group under
107    /// `Not` or `Any`.
108    All(Vec<SchemaPredicate>),
109    /// The event has at least one structured field. Used by the
110    /// `generic_json` fallback to distinguish structured events from
111    /// field-less ones (raw text, empty objects), which stay "unknown".
112    HasAnyField,
113}
114
115impl SchemaPredicate {
116    fn eval<E: Event + ?Sized>(&self, event: &E) -> bool {
117        match self {
118            SchemaPredicate::FieldPresent(f) => event.get_field(f).is_some(),
119            SchemaPredicate::FieldAbsent(f) => event.get_field(f).is_none(),
120            SchemaPredicate::AnyOf(fields) => fields.iter().any(|f| event.get_field(f).is_some()),
121            SchemaPredicate::Equals { field, value } => event
122                .get_field(field)
123                .and_then(|v| v.as_str().map(|s| s.as_ref().eq_ignore_ascii_case(value)))
124                .unwrap_or(false),
125            SchemaPredicate::Matches { field, regex } => event
126                .get_field(field)
127                .and_then(|v| v.as_str().map(|s| regex.is_match(s.as_ref())))
128                .unwrap_or(false),
129            SchemaPredicate::Compare { field, op, value } => event
130                .get_field(field)
131                .and_then(|v| v.as_f64())
132                .map(|n| op.apply(n, *value))
133                .unwrap_or(false),
134            SchemaPredicate::In { field, values } => event
135                .get_field(field)
136                .and_then(|v| {
137                    v.as_str().map(|s| {
138                        values
139                            .iter()
140                            .any(|val| s.as_ref().eq_ignore_ascii_case(val))
141                    })
142                })
143                .unwrap_or(false),
144            SchemaPredicate::FieldEqualsField { left, right } => {
145                let l = event
146                    .get_field(left)
147                    .and_then(|v| v.as_str().map(|s| s.into_owned()));
148                let r = event
149                    .get_field(right)
150                    .and_then(|v| v.as_str().map(|s| s.into_owned()));
151                matches!((l, r), (Some(a), Some(b)) if a.eq_ignore_ascii_case(&b))
152            }
153            SchemaPredicate::Not(inner) => !inner.eval(event),
154            SchemaPredicate::Any(preds) => preds.iter().any(|p| p.eval(event)),
155            SchemaPredicate::All(preds) => preds.iter().all(|p| p.eval(event)),
156            SchemaPredicate::HasAnyField => !event.field_keys().is_empty(),
157        }
158    }
159
160    /// A compact human description of the predicate, for `explain` output.
161    fn describe(&self) -> String {
162        match self {
163            SchemaPredicate::FieldPresent(f) => format!("field_present({f})"),
164            SchemaPredicate::FieldAbsent(f) => format!("field_absent({f})"),
165            SchemaPredicate::AnyOf(fs) => format!("any_of([{}])", fs.join(", ")),
166            SchemaPredicate::Equals { field, value } => format!("{field} == \"{value}\""),
167            SchemaPredicate::Matches { field, regex } => {
168                format!("{field} matches /{}/", regex.as_str())
169            }
170            SchemaPredicate::Compare { field, op, value } => {
171                format!("{field} {} {value}", op.symbol())
172            }
173            SchemaPredicate::In { field, values } => format!("{field} in [{}]", values.join(", ")),
174            SchemaPredicate::FieldEqualsField { left, right } => format!("{left} == {right}"),
175            SchemaPredicate::Not(inner) => format!("not({})", inner.describe()),
176            SchemaPredicate::Any(ps) => format!(
177                "any({})",
178                ps.iter()
179                    .map(|p| p.describe())
180                    .collect::<Vec<_>>()
181                    .join(" | ")
182            ),
183            SchemaPredicate::All(ps) => format!(
184                "all({})",
185                ps.iter()
186                    .map(|p| p.describe())
187                    .collect::<Vec<_>>()
188                    .join(" & ")
189            ),
190            SchemaPredicate::HasAnyField => "has_any_field".to_string(),
191        }
192    }
193}
194
195/// A named schema recognizer: every predicate must hold for the signature to
196/// match. Higher `specificity` wins when several signatures match the same
197/// event. Multiple signatures may share a `name` (for example several distinct
198/// ways to recognize Sysmon); the classifier reports the name.
199#[derive(Debug, Clone)]
200pub struct SchemaSignature {
201    /// Schema label reported on a match (for example `ecs`, `sysmon`).
202    pub name: String,
203    /// Conditions that must all hold (logical AND). An empty predicate set
204    /// matches every event; prefer [`SchemaPredicate::HasAnyField`] for a
205    /// structured-event fallback.
206    pub predicates: Vec<SchemaPredicate>,
207    /// Tie-breaking weight; the highest-specificity matching signature wins.
208    pub specificity: u32,
209}
210
211impl SchemaSignature {
212    fn matches<E: Event + ?Sized>(&self, event: &E) -> bool {
213        self.predicates.iter().all(|p| p.eval(event))
214    }
215
216    fn explain<E: Event + ?Sized>(&self, event: &E) -> SignatureExplanation {
217        let predicates: Vec<PredicateOutcome> = self
218            .predicates
219            .iter()
220            .map(|p| PredicateOutcome {
221                predicate: p.describe(),
222                matched: p.eval(event),
223            })
224            .collect();
225        let predicates_matched = predicates.iter().all(|p| p.matched);
226        SignatureExplanation {
227            name: self.name.clone(),
228            specificity: self.specificity,
229            predicates_matched,
230            predicates,
231        }
232    }
233}
234
235/// The outcome of one predicate within a [`SignatureExplanation`].
236#[derive(Debug, Clone, Serialize)]
237pub struct PredicateOutcome {
238    /// Human description of the predicate (for example `field_present(ecs.version)`).
239    pub predicate: String,
240    /// Whether the predicate held for the event.
241    pub matched: bool,
242}
243
244/// Per-signature detail produced by [`SchemaClassifier::explain`].
245#[derive(Debug, Clone, Serialize)]
246pub struct SignatureExplanation {
247    /// The signature's schema name.
248    pub name: String,
249    /// The signature's tie-breaking specificity.
250    pub specificity: u32,
251    /// Whether every predicate held (the signature matched).
252    pub predicates_matched: bool,
253    /// Per-predicate outcomes, in signature order.
254    pub predicates: Vec<PredicateOutcome>,
255}
256
257/// Why an event classified (or did not) as reported by
258/// [`SchemaClassifier::explain`]: the winning schema (if any) plus the
259/// signature that explains the outcome (the winning signature, or for an
260/// unknown event the closest near-miss).
261#[derive(Debug, Clone, Serialize)]
262pub struct SchemaExplanation {
263    /// The classified schema name, or `None` when the event matched none.
264    pub matched: Option<String>,
265    /// The winning signature's specificity, when matched.
266    pub specificity: Option<u32>,
267    /// The explaining signature: the winner when matched, otherwise the
268    /// highest-scoring near-miss (most predicates passing).
269    pub signature: Option<SignatureExplanation>,
270}
271
272/// The result of classifying an event: the matched schema name and the
273/// specificity of the signature that matched.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct SchemaMatch {
276    pub name: String,
277    pub specificity: u32,
278}
279
280/// Recognizes the schema of parsed events from a set of signatures.
281///
282/// Signatures are sorted once at construction (specificity descending, then
283/// name ascending) so [`classify`](Self::classify) returns the best match with
284/// a single in-order scan.
285#[derive(Debug, Clone)]
286pub struct SchemaClassifier {
287    signatures: Vec<SchemaSignature>,
288}
289
290impl SchemaClassifier {
291    /// Build a classifier from an explicit signature set.
292    pub fn new(mut signatures: Vec<SchemaSignature>) -> Self {
293        signatures.sort_by(|a, b| {
294            b.specificity
295                .cmp(&a.specificity)
296                .then_with(|| a.name.cmp(&b.name))
297        });
298        Self { signatures }
299    }
300
301    /// Build a classifier from the built-in signatures only.
302    pub fn builtin() -> Self {
303        Self::new(builtin_signatures())
304    }
305
306    /// Build a classifier from the built-ins plus user-supplied signatures.
307    /// User signatures are added to (not replacing) the built-ins; a user
308    /// signature with a higher specificity than a built-in wins on overlap.
309    pub fn with_user_signatures(user: Vec<SchemaSignature>) -> Self {
310        let mut signatures = builtin_signatures();
311        signatures.extend(user);
312        Self::new(signatures)
313    }
314
315    /// Classify an event. Returns the highest-specificity matching schema, or
316    /// `None` when the event matches no signature ("unknown").
317    pub fn classify<E: Event + ?Sized>(&self, event: &E) -> Option<SchemaMatch> {
318        self.signatures
319            .iter()
320            .find(|s| s.matches(event))
321            .map(|s| SchemaMatch {
322                name: s.name.clone(),
323                specificity: s.specificity,
324            })
325    }
326
327    /// Classify and also report ambiguity: `true` when another signature with a
328    /// different name matches at the same (winning) specificity, so the winner
329    /// was chosen by the name tie-break rather than by specificity. Ambiguity
330    /// signals that routing intent may be nondeterministic and a signature
331    /// wants a distinguishing predicate or a specificity bump.
332    pub fn classify_with_ambiguity<E: Event + ?Sized>(
333        &self,
334        event: &E,
335    ) -> (Option<SchemaMatch>, bool) {
336        // Signatures are sorted specificity-descending, so the first match is
337        // the winner and any following match with equal specificity but a
338        // different name is a genuine tie.
339        let mut matching = self.signatures.iter().filter(|s| s.matches(event));
340        let Some(winner) = matching.next() else {
341            return (None, false);
342        };
343        let ambiguous = matching
344            .take_while(|s| s.specificity == winner.specificity)
345            .any(|s| s.name != winner.name);
346        (
347            Some(SchemaMatch {
348                name: winner.name.clone(),
349                specificity: winner.specificity,
350            }),
351            ambiguous,
352        )
353    }
354
355    /// All matching schema names for an event, most specific first. Useful for
356    /// tuning signatures (seeing what else an event could match). Deduplicated
357    /// by name while preserving order.
358    pub fn classify_all<E: Event + ?Sized>(&self, event: &E) -> Vec<String> {
359        let mut out: Vec<String> = Vec::new();
360        for sig in self.signatures.iter().filter(|s| s.matches(event)) {
361            if !out.iter().any(|n| n == &sig.name) {
362                out.push(sig.name.clone());
363            }
364        }
365        out
366    }
367
368    /// Explain how an event classifies: the winning schema (if any) plus the
369    /// signature that explains it (the winning signature, or for an unknown
370    /// event the closest near-miss, the non-matching signature with the most
371    /// passing predicates). For tuning signatures.
372    pub fn explain<E: Event + ?Sized>(&self, event: &E) -> SchemaExplanation {
373        let mut best_near: Option<SignatureExplanation> = None;
374        let mut best_near_passing = 0usize;
375        for sig in &self.signatures {
376            let ex = sig.explain(event);
377            if ex.predicates_matched {
378                return SchemaExplanation {
379                    matched: Some(ex.name.clone()),
380                    specificity: Some(ex.specificity),
381                    signature: Some(ex),
382                };
383            }
384            // Signatures are sorted specificity-descending, so the first
385            // signature reaching a given passing count wins the tie-break.
386            let passing = ex.predicates.iter().filter(|p| p.matched).count();
387            if best_near.is_none() || passing > best_near_passing {
388                best_near_passing = passing;
389                best_near = Some(ex);
390            }
391        }
392        SchemaExplanation {
393            matched: None,
394            specificity: None,
395            signature: best_near,
396        }
397    }
398
399    /// Distinct schema names this classifier can produce, most specific first.
400    pub fn schema_names(&self) -> Vec<&str> {
401        let mut out: Vec<&str> = Vec::new();
402        for sig in &self.signatures {
403            if !out.contains(&sig.name.as_str()) {
404                out.push(sig.name.as_str());
405            }
406        }
407        out
408    }
409}
410
411impl Default for SchemaClassifier {
412    fn default() -> Self {
413        Self::builtin()
414    }
415}
416
417/// The built-in schema signatures, derived from the public schema specs:
418/// Elastic Common Schema, OCSF, the Windows event XML model, Microsoft
419/// Sysmon, and the ArcSight CEF spec.
420fn builtin_signatures() -> Vec<SchemaSignature> {
421    vec![
422        // ECS on Windows: ECS plus a Windows marker. More specific than plain
423        // `ecs` so it wins, and it aliases to `ecs` for routing (see
424        // `builtin_schema_aliases`) while carrying an implied `product:
425        // windows` for logsource pruning.
426        SchemaSignature {
427            name: "ecs_windows".to_string(),
428            specificity: 105,
429            predicates: vec![
430                SchemaPredicate::FieldPresent("ecs.version".to_string()),
431                SchemaPredicate::Any(vec![
432                    SchemaPredicate::FieldPresent("winlog.channel".to_string()),
433                    SchemaPredicate::FieldPresent("winlog.event_id".to_string()),
434                    SchemaPredicate::Equals {
435                        field: "host.os.type".to_string(),
436                        value: "windows".to_string(),
437                    },
438                    SchemaPredicate::Equals {
439                        field: "os.type".to_string(),
440                        value: "windows".to_string(),
441                    },
442                ]),
443            ],
444        },
445        // ECS on Linux: ECS plus a Linux marker. Aliases to `ecs`, implies
446        // `product: linux`.
447        SchemaSignature {
448            name: "ecs_linux".to_string(),
449            specificity: 105,
450            predicates: vec![
451                SchemaPredicate::FieldPresent("ecs.version".to_string()),
452                SchemaPredicate::Any(vec![
453                    SchemaPredicate::Equals {
454                        field: "host.os.type".to_string(),
455                        value: "linux".to_string(),
456                    },
457                    SchemaPredicate::Equals {
458                        field: "os.type".to_string(),
459                        value: "linux".to_string(),
460                    },
461                    SchemaPredicate::FieldPresent("host.os.kernel".to_string()),
462                ]),
463            ],
464        },
465        // ECS (Elastic Common Schema): `ecs.version` is the canonical marker.
466        SchemaSignature {
467            name: "ecs".to_string(),
468            specificity: 100,
469            predicates: vec![SchemaPredicate::FieldPresent("ecs.version".to_string())],
470        },
471        // OCSF: class_uid plus metadata.version are mandatory discriminators.
472        SchemaSignature {
473            name: "ocsf".to_string(),
474            specificity: 95,
475            predicates: vec![
476                SchemaPredicate::FieldPresent("class_uid".to_string()),
477                SchemaPredicate::FieldPresent("metadata.version".to_string()),
478            ],
479        },
480        // Rendered Windows Event Log (EVTX decoded to JSON): Event.System.*.
481        SchemaSignature {
482            name: "windows_eventlog".to_string(),
483            specificity: 90,
484            predicates: vec![SchemaPredicate::AnyOf(vec![
485                "Event.System.EventID".to_string(),
486                "Event.System.Provider".to_string(),
487            ])],
488        },
489        // Sysmon (flat) via the operational channel marker.
490        SchemaSignature {
491            name: "sysmon".to_string(),
492            specificity: 88,
493            predicates: vec![SchemaPredicate::Equals {
494                field: "Channel".to_string(),
495                value: "Microsoft-Windows-Sysmon/Operational".to_string(),
496            }],
497        },
498        // Sysmon (flat) via the provider marker.
499        SchemaSignature {
500            name: "sysmon".to_string(),
501            specificity: 88,
502            predicates: vec![SchemaPredicate::Equals {
503                field: "Provider_Name".to_string(),
504                value: "Microsoft-Windows-Sysmon".to_string(),
505            }],
506        },
507        // Sysmon (flat) via field shape when no provider/channel tag is present.
508        SchemaSignature {
509            name: "sysmon".to_string(),
510            specificity: 80,
511            predicates: vec![
512                SchemaPredicate::FieldPresent("EventID".to_string()),
513                SchemaPredicate::FieldPresent("ProcessGuid".to_string()),
514                SchemaPredicate::AnyOf(vec!["Image".to_string(), "CommandLine".to_string()]),
515            ],
516        },
517        // CEF: structured header fields produced by the CEF parser or carried
518        // in JSON (deviceVendor / deviceProduct / signatureId).
519        SchemaSignature {
520            name: "cef".to_string(),
521            specificity: 85,
522            predicates: vec![
523                SchemaPredicate::FieldPresent("deviceVendor".to_string()),
524                SchemaPredicate::FieldPresent("deviceProduct".to_string()),
525                SchemaPredicate::FieldPresent("signatureId".to_string()),
526            ],
527        },
528        // Generic JSON: any structured event that matched no specific schema.
529        SchemaSignature {
530            name: "generic_json".to_string(),
531            specificity: 0,
532            predicates: vec![SchemaPredicate::HasAnyField],
533        },
534    ]
535}
536
537/// Distinct built-in schema names, most specific first.
538pub fn builtin_schema_names() -> Vec<&'static str> {
539    vec![
540        "ecs_windows",
541        "ecs_linux",
542        "ecs",
543        "ocsf",
544        "windows_eventlog",
545        "sysmon",
546        "cef",
547        "generic_json",
548    ]
549}
550
551/// Built-in schema aliases: a specialized schema that routes as another schema.
552///
553/// `ecs_windows` and `ecs_linux` are ECS specializations that carry a platform
554/// (and thus an implied logsource for pruning) but route as `ecs`, so an
555/// existing `ecs` binding still matches them.
556fn builtin_schema_aliases() -> HashMap<String, String> {
557    HashMap::from([
558        ("ecs_windows".to_string(), "ecs".to_string()),
559        ("ecs_linux".to_string(), "ecs".to_string()),
560    ])
561}
562
563// =============================================================================
564// User-supplied signatures (YAML config)
565// =============================================================================
566
567/// Errors raised while loading user schema signatures.
568#[derive(Debug, thiserror::Error)]
569pub enum SchemaError {
570    /// The signatures file could not be read.
571    #[error("cannot read schema signatures file '{path}': {source}")]
572    Io {
573        path: String,
574        #[source]
575        source: std::io::Error,
576    },
577    /// The signatures YAML failed to parse.
578    #[error("schema signatures YAML parse error: {0}")]
579    Parse(String),
580    /// A predicate carried an invalid regular expression.
581    #[error("invalid regex in schema '{name}': {error}")]
582    InvalidRegex { name: String, error: String },
583}
584
585/// A `{ field: ..., value: ... }` pair used by the `equals` and `matches`
586/// predicate forms.
587#[derive(Debug, Clone, Deserialize)]
588#[serde(deny_unknown_fields)]
589pub struct FieldValueConfig {
590    pub field: String,
591    pub value: String,
592}
593
594/// A `{ field: ..., value: <number> }` pair used by the numeric comparison
595/// predicate forms (`gt`, `gte`, `lt`, `lte`).
596#[derive(Debug, Clone, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct FieldNumberConfig {
599    pub field: String,
600    pub value: f64,
601}
602
603/// A `{ field: ..., values: [...] }` pair used by the `in` predicate form.
604#[derive(Debug, Clone, Deserialize)]
605#[serde(deny_unknown_fields)]
606pub struct FieldValuesConfig {
607    pub field: String,
608    pub values: Vec<String>,
609}
610
611/// A `{ left: ..., right: ... }` pair used by the `field_equals_field` form.
612#[derive(Debug, Clone, Deserialize)]
613#[serde(deny_unknown_fields)]
614pub struct FieldPairConfig {
615    pub left: String,
616    pub right: String,
617}
618
619/// A predicate as written in YAML: a single-key map, for example
620/// `field_present: ecs.version` or `equals: { field: type, value: alert }`.
621/// Exactly one form must be set per list entry. The `not`/`any`/`all` group
622/// forms nest predicate lists to express OR and NOT within one signature.
623#[derive(Debug, Clone, Default, Deserialize)]
624#[serde(deny_unknown_fields)]
625pub struct SchemaPredicateConfig {
626    /// `field_present: <field>`
627    #[serde(default)]
628    pub field_present: Option<String>,
629    /// `field_absent: <field>`
630    #[serde(default)]
631    pub field_absent: Option<String>,
632    /// `any_of: [<field>, ...]`
633    #[serde(default)]
634    pub any_of: Option<Vec<String>>,
635    /// `equals: { field: <field>, value: <value> }`
636    #[serde(default)]
637    pub equals: Option<FieldValueConfig>,
638    /// `matches: { field: <field>, value: <regex> }`
639    #[serde(default)]
640    pub matches: Option<FieldValueConfig>,
641    /// `gt: { field: <field>, value: <number> }`
642    #[serde(default)]
643    pub gt: Option<FieldNumberConfig>,
644    /// `gte: { field: <field>, value: <number> }`
645    #[serde(default)]
646    pub gte: Option<FieldNumberConfig>,
647    /// `lt: { field: <field>, value: <number> }`
648    #[serde(default)]
649    pub lt: Option<FieldNumberConfig>,
650    /// `lte: { field: <field>, value: <number> }`
651    #[serde(default)]
652    pub lte: Option<FieldNumberConfig>,
653    /// `in: { field: <field>, values: [...] }`
654    #[serde(default, rename = "in")]
655    pub in_set: Option<FieldValuesConfig>,
656    /// `field_equals_field: { left: <field>, right: <field> }`
657    #[serde(default)]
658    pub field_equals_field: Option<FieldPairConfig>,
659    /// `not: <predicate>`
660    #[serde(default)]
661    pub not: Option<Box<SchemaPredicateConfig>>,
662    /// `any: [<predicate>, ...]`
663    #[serde(default)]
664    pub any: Option<Vec<SchemaPredicateConfig>>,
665    /// `all: [<predicate>, ...]`
666    #[serde(default)]
667    pub all: Option<Vec<SchemaPredicateConfig>>,
668}
669
670impl SchemaPredicateConfig {
671    fn build(self, schema_name: &str) -> Result<SchemaPredicate, SchemaError> {
672        let mut chosen: Option<SchemaPredicate> = None;
673        let mut set = 0u32;
674        if let Some(f) = self.field_present {
675            set += 1;
676            chosen = Some(SchemaPredicate::FieldPresent(f));
677        }
678        if let Some(f) = self.field_absent {
679            set += 1;
680            chosen = Some(SchemaPredicate::FieldAbsent(f));
681        }
682        if let Some(fields) = self.any_of {
683            set += 1;
684            chosen = Some(SchemaPredicate::AnyOf(fields));
685        }
686        if let Some(fv) = self.equals {
687            set += 1;
688            chosen = Some(SchemaPredicate::Equals {
689                field: fv.field,
690                value: fv.value,
691            });
692        }
693        if let Some(fv) = self.matches {
694            set += 1;
695            chosen = Some(SchemaPredicate::Matches {
696                field: fv.field,
697                regex: Regex::new(&fv.value).map_err(|e| SchemaError::InvalidRegex {
698                    name: schema_name.to_string(),
699                    error: e.to_string(),
700                })?,
701            });
702        }
703        for (op, cfg) in [
704            (CompareOp::Gt, self.gt),
705            (CompareOp::Gte, self.gte),
706            (CompareOp::Lt, self.lt),
707            (CompareOp::Lte, self.lte),
708        ] {
709            if let Some(fv) = cfg {
710                set += 1;
711                chosen = Some(SchemaPredicate::Compare {
712                    field: fv.field,
713                    op,
714                    value: fv.value,
715                });
716            }
717        }
718        if let Some(fv) = self.in_set {
719            set += 1;
720            chosen = Some(SchemaPredicate::In {
721                field: fv.field,
722                values: fv.values,
723            });
724        }
725        if let Some(fp) = self.field_equals_field {
726            set += 1;
727            chosen = Some(SchemaPredicate::FieldEqualsField {
728                left: fp.left,
729                right: fp.right,
730            });
731        }
732        if let Some(inner) = self.not {
733            set += 1;
734            chosen = Some(SchemaPredicate::Not(Box::new(inner.build(schema_name)?)));
735        }
736        if let Some(list) = self.any {
737            set += 1;
738            chosen = Some(SchemaPredicate::Any(build_group(list, schema_name, "any")?));
739        }
740        if let Some(list) = self.all {
741            set += 1;
742            chosen = Some(SchemaPredicate::All(build_group(list, schema_name, "all")?));
743        }
744        match (set, chosen) {
745            (1, Some(p)) => Ok(p),
746            (0, _) => Err(SchemaError::Parse(format!(
747                "schema '{schema_name}': a predicate has no condition (expected one of \
748                 field_present, field_absent, any_of, equals, matches, gt, gte, lt, lte, \
749                 in, field_equals_field, not, any, all)"
750            ))),
751            _ => Err(SchemaError::Parse(format!(
752                "schema '{schema_name}': a predicate sets multiple conditions; use one per list item"
753            ))),
754        }
755    }
756}
757
758/// Build a non-empty list of sub-predicates for the `any`/`all` group forms.
759fn build_group(
760    list: Vec<SchemaPredicateConfig>,
761    schema_name: &str,
762    kind: &str,
763) -> Result<Vec<SchemaPredicate>, SchemaError> {
764    if list.is_empty() {
765        return Err(SchemaError::Parse(format!(
766            "schema '{schema_name}': '{kind}' needs at least one sub-predicate"
767        )));
768    }
769    list.into_iter().map(|p| p.build(schema_name)).collect()
770}
771
772/// A signature as written in YAML.
773#[derive(Debug, Clone, Deserialize)]
774pub struct SchemaSignatureConfig {
775    /// Schema label reported on a match.
776    pub name: String,
777    /// Tie-breaking weight (default 50, above `generic_json` and below the
778    /// strong built-ins by default).
779    #[serde(default = "default_user_specificity")]
780    pub specificity: u32,
781    /// Conditions that must all hold.
782    #[serde(default, rename = "match")]
783    pub predicates: Vec<SchemaPredicateConfig>,
784}
785
786fn default_user_specificity() -> u32 {
787    50
788}
789
790/// Top-level YAML document holding a `schemas:` list and an optional
791/// `routing:` section.
792#[derive(Debug, Clone, Default, Deserialize)]
793pub struct SchemaSignaturesFile {
794    #[serde(default)]
795    pub schemas: Vec<SchemaSignatureConfig>,
796    #[serde(default)]
797    pub routing: Option<RoutingConfig>,
798}
799
800impl SchemaSignatureConfig {
801    fn build(self) -> Result<SchemaSignature, SchemaError> {
802        let name = self.name;
803        let predicates = self
804            .predicates
805            .into_iter()
806            .map(|p| p.build(&name))
807            .collect::<Result<Vec<_>, _>>()?;
808        Ok(SchemaSignature {
809            name,
810            predicates,
811            specificity: self.specificity,
812        })
813    }
814}
815
816/// Parse user schema signatures from a YAML string.
817pub fn parse_schema_signatures(yaml: &str) -> Result<Vec<SchemaSignature>, SchemaError> {
818    let file: SchemaSignaturesFile =
819        yaml_serde::from_str(yaml).map_err(|e| SchemaError::Parse(e.to_string()))?;
820    file.schemas.into_iter().map(|s| s.build()).collect()
821}
822
823/// Load user schema signatures from a YAML file path.
824pub fn load_schema_signatures(path: &Path) -> Result<Vec<SchemaSignature>, SchemaError> {
825    let content = fs::read_to_string(path).map_err(|e| SchemaError::Io {
826        path: path.display().to_string(),
827        source: e,
828    })?;
829    parse_schema_signatures(&content)
830}
831
832/// Parse both the user signatures and the optional routing section from a YAML
833/// string.
834pub fn parse_schema_config(
835    yaml: &str,
836) -> Result<(Vec<SchemaSignature>, Option<RoutingConfig>), SchemaError> {
837    let file: SchemaSignaturesFile =
838        yaml_serde::from_str(yaml).map_err(|e| SchemaError::Parse(e.to_string()))?;
839    let signatures = file
840        .schemas
841        .into_iter()
842        .map(|s| s.build())
843        .collect::<Result<Vec<_>, _>>()?;
844    Ok((signatures, file.routing))
845}
846
847/// Load both the user signatures and the optional routing section from a YAML
848/// file path.
849pub fn load_schema_config(
850    path: &Path,
851) -> Result<(Vec<SchemaSignature>, Option<RoutingConfig>), SchemaError> {
852    let content = fs::read_to_string(path).map_err(|e| SchemaError::Io {
853        path: path.display().to_string(),
854        source: e,
855    })?;
856    parse_schema_config(&content)
857}
858
859/// Validate a parsed schema config for common authoring mistakes, returning a
860/// list of human-readable findings (empty means clean). Static checks only, no
861/// event data:
862///
863/// - duplicate user signatures (same name and identical predicates);
864/// - unreachable signatures shadowed by a strictly-higher-specificity
865///   signature whose predicates are a subset (so the shadowed one can never be
866///   the top match);
867/// - routing bindings referencing a schema no signature can produce;
868/// - duplicate routing bindings for the same schema.
869///
870/// Pipeline-name resolvability is checked by the caller (the CLI), which owns
871/// pipeline resolution.
872pub fn validate_schema_config(
873    user_signatures: &[SchemaSignature],
874    routing: Option<&RoutingConfig>,
875) -> Vec<String> {
876    let mut findings = Vec::new();
877
878    // The full effective signature set (built-ins plus user).
879    let mut all = builtin_signatures();
880    all.extend(user_signatures.iter().cloned());
881    let preds = |s: &SchemaSignature| -> Vec<String> {
882        s.predicates.iter().map(|p| p.describe()).collect()
883    };
884
885    // Duplicate user signatures (same name, identical predicate set).
886    for i in 0..user_signatures.len() {
887        for j in (i + 1)..user_signatures.len() {
888            if user_signatures[i].name == user_signatures[j].name
889                && preds(&user_signatures[i]) == preds(&user_signatures[j])
890            {
891                findings.push(format!(
892                    "duplicate signature '{}' with identical predicates",
893                    user_signatures[i].name
894                ));
895            }
896        }
897    }
898
899    // Unreachable (shadowed) signatures.
900    for b in &all {
901        let b_preds = preds(b);
902        for a in &all {
903            if a.name != b.name
904                && a.specificity > b.specificity
905                && !a.predicates.is_empty()
906                && preds(a).iter().all(|p| b_preds.contains(p))
907            {
908                findings.push(format!(
909                    "signature '{}' (specificity {}) is unreachable: shadowed by '{}' (specificity {}) whose predicates are a subset",
910                    b.name, b.specificity, a.name, a.specificity
911                ));
912                break;
913            }
914        }
915    }
916
917    // Routing binding checks.
918    if let Some(routing) = routing {
919        let mut known: std::collections::HashSet<&str> =
920            builtin_schema_names().into_iter().collect();
921        for s in user_signatures {
922            known.insert(s.name.as_str());
923        }
924        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
925        for binding in &routing.bindings {
926            if !known.contains(binding.schema.as_str()) {
927                findings.push(format!(
928                    "routing binding references unknown schema '{}' (no built-in or user signature produces it)",
929                    binding.schema
930                ));
931            }
932            if !seen.insert(binding.schema.as_str()) {
933                findings.push(format!(
934                    "duplicate routing binding for schema '{}'",
935                    binding.schema
936                ));
937            }
938        }
939        for (alias, canonical) in &routing.aliases {
940            if !known.contains(canonical.as_str()) {
941                findings.push(format!(
942                    "alias '{alias}' targets unknown schema '{canonical}' (no built-in or user signature produces it)"
943                ));
944            }
945        }
946    }
947
948    findings
949}
950
951// =============================================================================
952// Routing: schema -> pipeline-set bindings and the dispatch plan
953// =============================================================================
954
955/// What to do with an event whose schema matched no signature.
956#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
957#[serde(rename_all = "snake_case")]
958pub enum OnUnknown {
959    /// Evaluate against the default pipeline-set and log a warning.
960    #[default]
961    Warn,
962    /// Drop the event without evaluating.
963    Drop,
964    /// Evaluate against the default pipeline-set without logging.
965    Passthrough,
966    /// Drop the event and flag it as an error (non-zero exit / error counter).
967    Error,
968}
969
970/// The logsource a recognized schema implies, used to fill gaps in an event's
971/// logsource for conflict-based pruning when the event carries no explicit
972/// `product`/`service`/`category` field.
973#[derive(Debug, Clone, Default, Deserialize)]
974#[serde(deny_unknown_fields)]
975pub struct SchemaLogsource {
976    #[serde(default)]
977    pub product: Option<String>,
978    #[serde(default)]
979    pub service: Option<String>,
980    #[serde(default)]
981    pub category: Option<String>,
982    #[serde(default)]
983    pub custom: HashMap<String, String>,
984}
985
986impl SchemaLogsource {
987    fn to_logsource(&self) -> LogSource {
988        LogSource {
989            product: self.product.clone(),
990            service: self.service.clone(),
991            category: self.category.clone(),
992            custom: self.custom.clone(),
993            ..LogSource::default()
994        }
995    }
996}
997
998/// A `schema -> pipelines` binding: events recognized as `schema` are
999/// evaluated against the engine built from `pipelines`.
1000#[derive(Debug, Clone, Deserialize)]
1001pub struct SchemaBinding {
1002    pub schema: String,
1003    /// Pipeline names or file paths, resolved by the caller.
1004    #[serde(default)]
1005    pub pipelines: Vec<String>,
1006    /// Optional logsource this schema implies. Overrides any built-in default
1007    /// for the schema and fills gaps in an event's logsource at pruning time.
1008    #[serde(default)]
1009    pub logsource: Option<SchemaLogsource>,
1010}
1011
1012/// Built-in schema-to-logsource defaults for the platform-locked schemas.
1013///
1014/// Only schemas that unambiguously imply a platform are listed. The plain
1015/// cross-platform schemas (`ecs`, `ocsf`, `cef`, `generic_json`) are omitted:
1016/// they must not imply a product, since doing so would prune correct rules for
1017/// the other platforms those schemas also carry. The `ecs_windows` and
1018/// `ecs_linux` specializations do carry a platform (and route as `ecs` via
1019/// [`builtin_schema_aliases`]).
1020fn builtin_schema_logsource() -> HashMap<String, LogSource> {
1021    fn ls(product: &str, service: Option<&str>) -> LogSource {
1022        LogSource {
1023            product: Some(product.to_string()),
1024            service: service.map(str::to_string),
1025            ..LogSource::default()
1026        }
1027    }
1028    HashMap::from([
1029        ("sysmon".to_string(), ls("windows", Some("sysmon"))),
1030        ("windows_eventlog".to_string(), ls("windows", None)),
1031        ("ecs_windows".to_string(), ls("windows", None)),
1032        ("ecs_linux".to_string(), ls("linux", None)),
1033    ])
1034}
1035
1036/// The `routing:` section of a schema config file.
1037#[derive(Debug, Clone, Default, Deserialize)]
1038pub struct RoutingConfig {
1039    #[serde(default)]
1040    pub on_unknown: OnUnknown,
1041    #[serde(default)]
1042    pub bindings: Vec<SchemaBinding>,
1043    /// Pipelines applied to known-but-unbound schemas and to the
1044    /// unknown-fallback path. Empty means "rules with no pipeline".
1045    #[serde(default)]
1046    pub default_pipelines: Vec<String>,
1047    /// User-defined schema aliases (`schema -> canonical schema`): an event
1048    /// classified as an alias routes as though it were the canonical schema,
1049    /// so one binding covers a family of related schemas. Merged over the
1050    /// built-in `ecs_windows`/`ecs_linux` -> `ecs` aliases.
1051    #[serde(default)]
1052    pub aliases: HashMap<String, String>,
1053}
1054
1055/// The decision for one event, produced by [`RoutingPlan::decide`].
1056#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1057pub enum RouteDecision {
1058    /// Evaluate against the pipeline-set at this index. `unknown` is true when
1059    /// the event matched no signature and fell through to the default set.
1060    Evaluate { set: usize, unknown: bool },
1061    /// Drop the event without evaluating (`on_unknown: drop`).
1062    Drop,
1063    /// Drop and flag as an error (`on_unknown: error`).
1064    Error,
1065}
1066
1067/// A resolved routing plan: the deduplicated pipeline-sets to build one engine
1068/// each, plus the schema-to-set mapping and the unknown-handling policy.
1069///
1070/// Pure data: it decides *which* pipeline-set an event routes to, leaving the
1071/// engine construction and dispatch to the caller. The default set (index 0)
1072/// is always present, so there is always a fallback target.
1073#[derive(Debug, Clone)]
1074pub struct RoutingPlan {
1075    /// Deduplicated pipeline-sets. Index 0 is always the default set.
1076    pipeline_sets: Vec<Vec<String>>,
1077    /// Recognized schema name -> pipeline-set index.
1078    schema_to_set: HashMap<String, usize>,
1079    /// Recognized schema name -> the logsource it implies (built-in defaults
1080    /// plus per-binding overrides). Used to fill gaps in an event's logsource
1081    /// for conflict-based pruning.
1082    schema_logsource: HashMap<String, LogSource>,
1083    /// Schema aliases (`schema -> canonical schema`): built-in ECS platform
1084    /// specializations plus any from the config. An aliased schema routes as
1085    /// its canonical when the canonical is bound and the alias itself is not.
1086    aliases: HashMap<String, String>,
1087    on_unknown: OnUnknown,
1088}
1089
1090impl RoutingPlan {
1091    /// Build a plan from a routing config, deduplicating identical
1092    /// pipeline-sets so the caller compiles each distinct set once.
1093    pub fn from_config(config: &RoutingConfig) -> Self {
1094        // Index 0 is always the default set.
1095        let mut pipeline_sets: Vec<Vec<String>> = vec![config.default_pipelines.clone()];
1096        let mut schema_to_set: HashMap<String, usize> = HashMap::new();
1097        // Seed the built-in platform-locked defaults, then let bindings
1098        // override or add per-schema logsources.
1099        let mut schema_logsource = builtin_schema_logsource();
1100        // Seed built-in aliases, then merge any from the config.
1101        let mut aliases = builtin_schema_aliases();
1102        for (alias, canonical) in &config.aliases {
1103            aliases.insert(alias.clone(), canonical.clone());
1104        }
1105
1106        for binding in &config.bindings {
1107            let idx = pipeline_sets
1108                .iter()
1109                .position(|s| s == &binding.pipelines)
1110                .unwrap_or_else(|| {
1111                    pipeline_sets.push(binding.pipelines.clone());
1112                    pipeline_sets.len() - 1
1113                });
1114            schema_to_set.insert(binding.schema.clone(), idx);
1115            if let Some(ls) = &binding.logsource {
1116                schema_logsource.insert(binding.schema.clone(), ls.to_logsource());
1117            }
1118        }
1119
1120        RoutingPlan {
1121            pipeline_sets,
1122            schema_to_set,
1123            schema_logsource,
1124            aliases,
1125            on_unknown: config.on_unknown,
1126        }
1127    }
1128
1129    /// The deduplicated pipeline-sets, in index order (set 0 is the default).
1130    /// The caller builds one engine per entry.
1131    pub fn pipeline_sets(&self) -> &[Vec<String>] {
1132        &self.pipeline_sets
1133    }
1134
1135    /// The configured unknown-handling policy.
1136    pub fn on_unknown(&self) -> OnUnknown {
1137        self.on_unknown
1138    }
1139
1140    /// The logsource a recognized schema implies, if any (built-in default or
1141    /// binding override). Used by the router to fill gaps in an event's
1142    /// logsource before conflict-based pruning.
1143    pub fn schema_logsource(&self, schema: &str) -> Option<&LogSource> {
1144        self.schema_logsource.get(schema)
1145    }
1146
1147    /// The recognized schema names that carry an implied logsource (built-in
1148    /// defaults plus binding overrides), sorted for deterministic output.
1149    pub fn schemas_with_logsource(&self) -> Vec<String> {
1150        let mut names: Vec<String> = self.schema_logsource.keys().cloned().collect();
1151        names.sort();
1152        names
1153    }
1154
1155    /// For each pipeline-set index, the set of lowercased products whose rules
1156    /// are safe to keep when partitioning per-schema engines, or `None` to keep
1157    /// the full ruleset.
1158    ///
1159    /// A set is partitionable only when every schema that can route to it
1160    /// (direct bindings plus aliases) implies a product; if any routing schema
1161    /// is product-less (cross-platform), the set keeps all rules. The default
1162    /// set (index 0) is never partitioned, because unbound and unknown events
1163    /// route there and could be any product. Callers still apply their own
1164    /// pipeline-safety check (a product-setting `change_logsource` disables
1165    /// partitioning for that set).
1166    pub fn set_product_partition(&self) -> Vec<Option<std::collections::HashSet<String>>> {
1167        use std::collections::HashSet;
1168        let n = self.pipeline_sets.len();
1169        let mut out: Vec<Option<HashSet<String>>> = (0..n).map(|_| Some(HashSet::new())).collect();
1170        if let Some(first) = out.get_mut(0) {
1171            *first = None;
1172        }
1173
1174        // (set index, schema) pairs: direct bindings, plus aliases whose
1175        // canonical is bound and which are not themselves directly bound.
1176        let mut routes: Vec<(usize, &str)> = self
1177            .schema_to_set
1178            .iter()
1179            .map(|(s, &set)| (set, s.as_str()))
1180            .collect();
1181        for (alias, canonical) in &self.aliases {
1182            if !self.schema_to_set.contains_key(alias)
1183                && let Some(&set) = self.schema_to_set.get(canonical)
1184            {
1185                routes.push((set, alias.as_str()));
1186            }
1187        }
1188
1189        for (set, schema) in routes {
1190            if set == 0 {
1191                continue;
1192            }
1193            let product = self
1194                .schema_logsource
1195                .get(schema)
1196                .and_then(|ls| ls.product.as_deref());
1197            let Some(slot) = out.get_mut(set) else {
1198                continue;
1199            };
1200            match product {
1201                Some(p) => {
1202                    if let Some(products) = slot {
1203                        products.insert(p.to_ascii_lowercase());
1204                    }
1205                }
1206                None => *slot = None,
1207            }
1208        }
1209        out
1210    }
1211
1212    /// Decide how to route an event given its classified schema (or `None`
1213    /// when nothing matched).
1214    pub fn decide(&self, schema: Option<&str>) -> RouteDecision {
1215        match schema {
1216            // Recognized and bound: its own set.
1217            Some(s) if self.schema_to_set.contains_key(s) => RouteDecision::Evaluate {
1218                set: self.schema_to_set[s],
1219                unknown: false,
1220            },
1221            // Recognized but unbound: route as the canonical schema if this is
1222            // an alias whose canonical is bound (for example `ecs_windows` ->
1223            // `ecs`), otherwise the default set. Not flagged unknown.
1224            Some(s)
1225                if self
1226                    .aliases
1227                    .get(s)
1228                    .and_then(|canonical| self.schema_to_set.get(canonical))
1229                    .is_some() =>
1230            {
1231                let canonical = &self.aliases[s];
1232                RouteDecision::Evaluate {
1233                    set: self.schema_to_set[canonical],
1234                    unknown: false,
1235                }
1236            }
1237            // Recognized but unbound: the default set, not flagged unknown.
1238            Some(_) => RouteDecision::Evaluate {
1239                set: 0,
1240                unknown: false,
1241            },
1242            // Unrecognized: per the unknown policy.
1243            None => match self.on_unknown {
1244                OnUnknown::Warn | OnUnknown::Passthrough => RouteDecision::Evaluate {
1245                    set: 0,
1246                    unknown: true,
1247                },
1248                OnUnknown::Drop => RouteDecision::Drop,
1249                OnUnknown::Error => RouteDecision::Error,
1250            },
1251        }
1252    }
1253}
1254
1255// =============================================================================
1256// SchemaObserver: opt-in per-schema counting for reporting
1257// =============================================================================
1258
1259/// One per-schema counter as exposed via [`SchemaObserver::snapshot`].
1260#[derive(Debug, Clone, PartialEq, Eq)]
1261pub struct SchemaCountEntry {
1262    /// Recognized schema name.
1263    pub schema: String,
1264    /// Number of events classified as this schema since the last reset.
1265    pub count: u64,
1266}
1267
1268/// A redacted field-key shape of unknown events, for signature authoring.
1269#[derive(Debug, Clone, PartialEq, Eq)]
1270pub struct UnknownShapeEntry {
1271    /// The sorted, deduplicated field keys of the unknown events (values are
1272    /// never captured, only key names).
1273    pub keys: Vec<String>,
1274    /// Number of unknown events with this exact key shape since the last reset.
1275    pub count: u64,
1276}
1277
1278/// Maximum distinct unknown-event shapes retained, to bound memory.
1279const UNKNOWN_SHAPE_CAP: usize = 200;
1280/// Maximum field keys kept per shape, to bound a single shape's size.
1281const UNKNOWN_SHAPE_MAX_KEYS: usize = 64;
1282
1283/// Immutable snapshot of a [`SchemaObserver`] at one moment.
1284#[derive(Debug, Clone, Default)]
1285pub struct SchemaObservation {
1286    /// Per-schema counts, sorted by descending count then ascending name.
1287    pub by_schema: Vec<SchemaCountEntry>,
1288    /// Events classified into a known schema since the last reset.
1289    pub classified: u64,
1290    /// Events that matched no signature since the last reset.
1291    pub unknown: u64,
1292    /// Events where two different-name signatures tied at the winning
1293    /// specificity since the last reset (the name tie-break decided routing).
1294    pub ambiguous: u64,
1295    /// Redacted field-key shapes of unknown events, most frequent first, to
1296    /// help author signatures for what is currently unrecognized.
1297    pub unknown_shapes: Vec<UnknownShapeEntry>,
1298    /// Redacted field-key shapes of discovery-unrecognized events (no match or
1299    /// `generic_json`), most frequent first. Populated only when the observer's
1300    /// discovery sampler is enabled; the input to schema signature discovery.
1301    pub unrecognized_shapes: Vec<UnknownShapeEntry>,
1302    /// Total events observed since the last reset (`classified + unknown`).
1303    pub events_observed: u64,
1304    /// Lifetime total of classified events, ignoring resets. Monotonic, so it
1305    /// can drive Prometheus counters across observer resets.
1306    pub lifetime_classified: u64,
1307    /// Lifetime total of unknown events, ignoring resets. Monotonic.
1308    pub lifetime_unknown: u64,
1309    /// Lifetime total of ambiguous classifications, ignoring resets. Monotonic.
1310    pub lifetime_ambiguous: u64,
1311    /// Seconds since the observer was created (or last reset).
1312    pub uptime_seconds: f64,
1313}
1314
1315/// Opt-in counter that classifies each observed event and tallies per-schema
1316/// (and unknown) totals. Mirrors the design of [`FieldObserver`](crate::FieldObserver):
1317/// shared behind an `Arc`, cheap repeated snapshots, monotonic lifetime
1318/// counters for a Prometheus bridge. The schema set is small and bounded, so
1319/// there is no key cap.
1320pub struct SchemaObserver {
1321    classifier: SchemaClassifier,
1322    counts: Mutex<HashMap<String, u64>>,
1323    unknown: AtomicU64,
1324    ambiguous: AtomicU64,
1325    /// Redacted field-key shapes of unknown (no-match) events (bounded by
1326    /// [`UNKNOWN_SHAPE_CAP`]).
1327    unknown_shapes: Mutex<HashMap<Vec<String>, u64>>,
1328    /// Opt-in: when set, also samples the redacted field-key shapes of events
1329    /// that are unrecognized *for discovery purposes* (no match OR the
1330    /// low-specificity `generic_json` catch-all) into
1331    /// [`unrecognized_shapes`](Self::unrecognized_shapes), the input to schema
1332    /// signature discovery. Kept separate from [`Self::unknown_shapes`] so the
1333    /// existing `unknown` semantics are unchanged.
1334    discovery_sampling: bool,
1335    /// Redacted field-key shapes of discovery-unrecognized events (no-match or
1336    /// `generic_json`), populated only when `discovery_sampling` is set.
1337    unrecognized_shapes: Mutex<HashMap<Vec<String>, u64>>,
1338    lifetime_classified: AtomicU64,
1339    lifetime_unknown: AtomicU64,
1340    lifetime_ambiguous: AtomicU64,
1341    start: Mutex<Instant>,
1342}
1343
1344impl SchemaObserver {
1345    /// Create an observer backed by the given classifier (discovery sampling
1346    /// off).
1347    pub fn new(classifier: SchemaClassifier) -> Self {
1348        Self::new_with_discovery(classifier, false)
1349    }
1350
1351    /// Create an observer, optionally enabling the discovery sampler that
1352    /// records redacted shapes of `generic_json` and no-match events for
1353    /// schema signature discovery.
1354    pub fn new_with_discovery(classifier: SchemaClassifier, discovery_sampling: bool) -> Self {
1355        Self {
1356            classifier,
1357            counts: Mutex::new(HashMap::new()),
1358            unknown: AtomicU64::new(0),
1359            ambiguous: AtomicU64::new(0),
1360            unknown_shapes: Mutex::new(HashMap::new()),
1361            discovery_sampling,
1362            unrecognized_shapes: Mutex::new(HashMap::new()),
1363            lifetime_classified: AtomicU64::new(0),
1364            lifetime_unknown: AtomicU64::new(0),
1365            lifetime_ambiguous: AtomicU64::new(0),
1366            start: Mutex::new(Instant::now()),
1367        }
1368    }
1369
1370    /// Whether the discovery sampler (recording unrecognized-event shapes into
1371    /// [`SchemaObservation::unrecognized_shapes`]) is on.
1372    pub fn discovery_sampling(&self) -> bool {
1373        self.discovery_sampling
1374    }
1375
1376    /// Create an observer using the built-in classifier.
1377    pub fn builtin() -> Self {
1378        Self::new(SchemaClassifier::builtin())
1379    }
1380
1381    /// Classify an event and update the counters. Takes `&self` so the
1382    /// observer can be shared behind an `Arc`.
1383    pub fn observe<E: Event + ?Sized>(&self, event: &E) {
1384        let (matched, ambiguous) = self.classifier.classify_with_ambiguity(event);
1385        if ambiguous {
1386            self.ambiguous.fetch_add(1, Ordering::Relaxed);
1387            self.lifetime_ambiguous.fetch_add(1, Ordering::Relaxed);
1388        }
1389        // Sample the shape for discovery when the event is unrecognized for
1390        // discovery purposes: it matched nothing, or only the low-specificity
1391        // `generic_json` catch-all (which is not a real schema).
1392        let discovery_unrecognized = match &matched {
1393            None => true,
1394            Some(m) => m.name == "generic_json",
1395        };
1396        if self.discovery_sampling && discovery_unrecognized {
1397            self.record_unrecognized_shape(event);
1398        }
1399
1400        match matched {
1401            Some(m) => {
1402                self.lifetime_classified.fetch_add(1, Ordering::Relaxed);
1403                let mut counts = self.counts.lock().expect("schema observer mutex poisoned");
1404                *counts.entry(m.name).or_insert(0) += 1;
1405            }
1406            None => {
1407                self.unknown.fetch_add(1, Ordering::Relaxed);
1408                self.lifetime_unknown.fetch_add(1, Ordering::Relaxed);
1409                self.record_unknown_shape(event);
1410            }
1411        }
1412    }
1413
1414    /// Record the redacted field-key shape of one unknown event, capped in both
1415    /// distinct-shape count and per-shape key count.
1416    fn record_unknown_shape<E: Event + ?Sized>(&self, event: &E) {
1417        let mut keys: Vec<String> = event.field_keys().iter().map(|k| k.to_string()).collect();
1418        keys.sort();
1419        keys.dedup();
1420        keys.truncate(UNKNOWN_SHAPE_MAX_KEYS);
1421        let mut shapes = self
1422            .unknown_shapes
1423            .lock()
1424            .expect("schema observer shapes mutex poisoned");
1425        // Only add a new shape when under the cap; always count a known one.
1426        if shapes.contains_key(&keys) || shapes.len() < UNKNOWN_SHAPE_CAP {
1427            *shapes.entry(keys).or_insert(0) += 1;
1428        }
1429    }
1430
1431    /// Record the redacted field-key shape of one discovery-unrecognized event
1432    /// (no match or `generic_json`) into the discovery sampler, capped the same
1433    /// way as [`Self::record_unknown_shape`].
1434    fn record_unrecognized_shape<E: Event + ?Sized>(&self, event: &E) {
1435        let mut keys: Vec<String> = event.field_keys().iter().map(|k| k.to_string()).collect();
1436        keys.sort();
1437        keys.dedup();
1438        keys.truncate(UNKNOWN_SHAPE_MAX_KEYS);
1439        if keys.is_empty() {
1440            return;
1441        }
1442        let mut shapes = self
1443            .unrecognized_shapes
1444            .lock()
1445            .expect("schema observer shapes mutex poisoned");
1446        if shapes.contains_key(&keys) || shapes.len() < UNKNOWN_SHAPE_CAP {
1447            *shapes.entry(keys).or_insert(0) += 1;
1448        }
1449    }
1450
1451    /// Snapshot the current counts, sorted by descending count then name.
1452    pub fn snapshot(&self) -> SchemaObservation {
1453        let counts = self.counts.lock().expect("schema observer mutex poisoned");
1454        let mut by_schema: Vec<SchemaCountEntry> = counts
1455            .iter()
1456            .map(|(schema, count)| SchemaCountEntry {
1457                schema: schema.clone(),
1458                count: *count,
1459            })
1460            .collect();
1461        let classified: u64 = counts.values().sum();
1462        drop(counts);
1463        by_schema.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.schema.cmp(&b.schema)));
1464
1465        let shapes = self
1466            .unknown_shapes
1467            .lock()
1468            .expect("schema observer shapes mutex poisoned");
1469        let mut unknown_shapes: Vec<UnknownShapeEntry> = shapes
1470            .iter()
1471            .map(|(keys, count)| UnknownShapeEntry {
1472                keys: keys.clone(),
1473                count: *count,
1474            })
1475            .collect();
1476        drop(shapes);
1477        unknown_shapes.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.keys.cmp(&b.keys)));
1478
1479        let unrec = self
1480            .unrecognized_shapes
1481            .lock()
1482            .expect("schema observer shapes mutex poisoned");
1483        let mut unrecognized_shapes: Vec<UnknownShapeEntry> = unrec
1484            .iter()
1485            .map(|(keys, count)| UnknownShapeEntry {
1486                keys: keys.clone(),
1487                count: *count,
1488            })
1489            .collect();
1490        drop(unrec);
1491        unrecognized_shapes.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.keys.cmp(&b.keys)));
1492
1493        let unknown = self.unknown.load(Ordering::Relaxed);
1494        SchemaObservation {
1495            by_schema,
1496            classified,
1497            unknown,
1498            ambiguous: self.ambiguous.load(Ordering::Relaxed),
1499            unknown_shapes,
1500            unrecognized_shapes,
1501            // Derived (not a separate counter) so every snapshot is internally
1502            // consistent: a reader that sees `events_observed == N` also sees
1503            // the `classified`/`unknown` reads that sum to N, since each
1504            // observed event increments exactly one of the two.
1505            events_observed: classified + unknown,
1506            lifetime_classified: self.lifetime_classified.load(Ordering::Relaxed),
1507            lifetime_unknown: self.lifetime_unknown.load(Ordering::Relaxed),
1508            lifetime_ambiguous: self.lifetime_ambiguous.load(Ordering::Relaxed),
1509            uptime_seconds: self
1510                .start
1511                .lock()
1512                .expect("schema observer start mutex poisoned")
1513                .elapsed()
1514                .as_secs_f64(),
1515        }
1516    }
1517
1518    /// Reset the since-last-reset counters (lifetime totals are preserved).
1519    /// Returns the previous `(classified, unknown)` pair.
1520    pub fn reset(&self) -> (u64, u64) {
1521        let mut counts = self.counts.lock().expect("schema observer mutex poisoned");
1522        let previous_classified: u64 = counts.values().sum();
1523        counts.clear();
1524        drop(counts);
1525        self.unknown_shapes
1526            .lock()
1527            .expect("schema observer shapes mutex poisoned")
1528            .clear();
1529        self.unrecognized_shapes
1530            .lock()
1531            .expect("schema observer shapes mutex poisoned")
1532            .clear();
1533        let previous_unknown = self.unknown.swap(0, Ordering::Relaxed);
1534        self.ambiguous.store(0, Ordering::Relaxed);
1535        *self
1536            .start
1537            .lock()
1538            .expect("schema observer start mutex poisoned") = Instant::now();
1539        (previous_classified, previous_unknown)
1540    }
1541
1542    /// Lifetime classified total, ignoring resets. Monotonic.
1543    pub fn lifetime_classified(&self) -> u64 {
1544        self.lifetime_classified.load(Ordering::Relaxed)
1545    }
1546
1547    /// Lifetime unknown total, ignoring resets. Monotonic.
1548    pub fn lifetime_unknown(&self) -> u64 {
1549        self.lifetime_unknown.load(Ordering::Relaxed)
1550    }
1551
1552    /// Lifetime ambiguous total, ignoring resets. Monotonic.
1553    pub fn lifetime_ambiguous(&self) -> u64 {
1554        self.lifetime_ambiguous.load(Ordering::Relaxed)
1555    }
1556}
1557
1558#[cfg(test)]
1559mod tests {
1560    use super::*;
1561    use crate::event::JsonEvent;
1562    use serde_json::json;
1563
1564    fn classify(value: &serde_json::Value) -> Option<String> {
1565        SchemaClassifier::builtin()
1566            .classify(&JsonEvent::borrow(value))
1567            .map(|m| m.name)
1568    }
1569
1570    #[test]
1571    fn recognizes_ecs_by_version_marker() {
1572        let v = json!({"ecs": {"version": "8.11.0"}, "process": {"command_line": "whoami"}});
1573        assert_eq!(classify(&v).as_deref(), Some("ecs"));
1574    }
1575
1576    #[test]
1577    fn recognizes_ecs_with_flattened_keys() {
1578        let v = json!({"ecs.version": "8.11.0", "process.command_line": "whoami"});
1579        assert_eq!(classify(&v).as_deref(), Some("ecs"));
1580    }
1581
1582    #[test]
1583    fn recognizes_ocsf_by_class_and_metadata() {
1584        let v = json!({"class_uid": 1001, "category_uid": 1, "metadata": {"version": "1.1.0"}});
1585        assert_eq!(classify(&v).as_deref(), Some("ocsf"));
1586    }
1587
1588    #[test]
1589    fn recognizes_rendered_windows_event_log() {
1590        let v = json!({"Event": {"System": {"EventID": 4688, "Provider": "Microsoft-Windows-Security-Auditing"}}});
1591        assert_eq!(classify(&v).as_deref(), Some("windows_eventlog"));
1592    }
1593
1594    #[test]
1595    fn recognizes_sysmon_by_channel() {
1596        let v = json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 1, "Image": "C:/cmd.exe"});
1597        assert_eq!(classify(&v).as_deref(), Some("sysmon"));
1598    }
1599
1600    #[test]
1601    fn recognizes_sysmon_by_provider() {
1602        let v = json!({"Provider_Name": "Microsoft-Windows-Sysmon", "EventID": 3});
1603        assert_eq!(classify(&v).as_deref(), Some("sysmon"));
1604    }
1605
1606    #[test]
1607    fn recognizes_flat_sysmon_by_field_shape() {
1608        let v = json!({"EventID": 1, "ProcessGuid": "{abc}", "CommandLine": "cmd /c whoami"});
1609        assert_eq!(classify(&v).as_deref(), Some("sysmon"));
1610    }
1611
1612    #[test]
1613    fn recognizes_cef_structured_fields() {
1614        let v = json!({"deviceVendor": "Security", "deviceProduct": "IDS", "signatureId": "100", "src": "10.0.0.1"});
1615        assert_eq!(classify(&v).as_deref(), Some("cef"));
1616    }
1617
1618    #[test]
1619    fn falls_back_to_generic_json_for_unrecognized_structured_events() {
1620        let v = json!({"some_vendor_field": "x", "another": 1});
1621        assert_eq!(classify(&v).as_deref(), Some("generic_json"));
1622    }
1623
1624    #[test]
1625    fn fieldless_events_are_unknown() {
1626        // Empty object: no fields, no signature matches (not even generic_json).
1627        assert_eq!(classify(&json!({})), None);
1628        // JSON scalar/array carries no named fields either.
1629        assert_eq!(classify(&json!("just a string")), None);
1630    }
1631
1632    #[test]
1633    fn specificity_prefers_specific_schema_over_generic() {
1634        // Carries both an ECS marker and arbitrary extra fields; ECS wins.
1635        let v = json!({"ecs.version": "8.0.0", "vendor_blob": {"x": 1}});
1636        let cls = SchemaClassifier::builtin();
1637        let m = cls.classify(&JsonEvent::borrow(&v)).unwrap();
1638        assert_eq!(m.name, "ecs");
1639        assert_eq!(m.specificity, 100);
1640        // generic_json is still a candidate, just lower priority.
1641        let all = cls.classify_all(&JsonEvent::borrow(&v));
1642        assert_eq!(all.first().map(String::as_str), Some("ecs"));
1643        assert!(all.iter().any(|n| n == "generic_json"));
1644    }
1645
1646    #[test]
1647    fn schema_names_lists_builtins_most_specific_first() {
1648        let classifier = SchemaClassifier::builtin();
1649        let names = classifier.schema_names();
1650        // The ECS platform specializations (specificity 105) sort ahead of
1651        // plain `ecs` (100); the two 105s tie-break by name (ecs_linux first).
1652        assert_eq!(names.first(), Some(&"ecs_linux"));
1653        assert!(names.contains(&"ecs_windows"));
1654        assert!(names.contains(&"ecs"));
1655        assert!(names.contains(&"generic_json"));
1656        // generic_json is the lowest-specificity, so it sorts last.
1657        assert_eq!(names.last(), Some(&"generic_json"));
1658    }
1659
1660    #[test]
1661    fn ecs_windows_specialization_classifies_and_aliases_to_ecs() {
1662        // An ECS event carrying a Windows marker classifies as the more
1663        // specific ecs_windows, not plain ecs.
1664        let v = json!({"ecs.version": "8.11.0", "winlog": {"channel": "Security"}});
1665        assert_eq!(classify(&v).as_deref(), Some("ecs_windows"));
1666        // A plain ECS event (no platform marker) stays ecs.
1667        let plain = json!({"ecs.version": "8.11.0", "process": {"command_line": "whoami"}});
1668        assert_eq!(classify(&plain).as_deref(), Some("ecs"));
1669
1670        // ecs_windows implies product: windows for pruning, and aliases to ecs
1671        // for routing, so an `ecs` binding matches an ecs_windows event.
1672        let config = RoutingConfig {
1673            on_unknown: OnUnknown::Warn,
1674            default_pipelines: vec![],
1675            aliases: HashMap::new(),
1676            bindings: vec![SchemaBinding {
1677                schema: "ecs".to_string(),
1678                pipelines: vec!["ecs_windows".to_string()],
1679                logsource: None,
1680            }],
1681        };
1682        let plan = RoutingPlan::from_config(&config);
1683        let ecs_set = match plan.decide(Some("ecs")) {
1684            RouteDecision::Evaluate { set, .. } => set,
1685            other => panic!("unexpected: {other:?}"),
1686        };
1687        // ecs_windows routes to the same set as ecs via the built-in alias.
1688        assert_eq!(plan.decide(Some("ecs_windows")), plan.decide(Some("ecs")));
1689        assert_ne!(ecs_set, 0, "ecs binding is a non-default set");
1690        assert_eq!(
1691            plan.schema_logsource("ecs_windows")
1692                .and_then(|l| l.product.as_deref()),
1693            Some("windows")
1694        );
1695    }
1696
1697    #[test]
1698    fn user_alias_routes_as_canonical() {
1699        let yaml = r#"
1700schemas:
1701  - name: my_win
1702    specificity: 70
1703    match:
1704      - field_present: vendor.win_marker
1705routing:
1706  aliases:
1707    my_win: ecs
1708  bindings:
1709    - schema: ecs
1710      pipelines: [ecs_windows]
1711"#;
1712        let (_sigs, routing) = parse_schema_config(yaml).unwrap();
1713        let plan = RoutingPlan::from_config(&routing.expect("routing"));
1714        // my_win aliases to ecs, so it routes to the ecs binding's set.
1715        assert_eq!(plan.decide(Some("my_win")), plan.decide(Some("ecs")));
1716        assert!(matches!(
1717            plan.decide(Some("my_win")),
1718            RouteDecision::Evaluate { unknown: false, .. }
1719        ));
1720    }
1721
1722    #[test]
1723    fn set_product_partition_only_for_platform_locked_sets() {
1724        let config = RoutingConfig {
1725            on_unknown: OnUnknown::Warn,
1726            default_pipelines: vec![],
1727            aliases: HashMap::new(),
1728            bindings: vec![
1729                SchemaBinding {
1730                    schema: "sysmon".to_string(),
1731                    pipelines: vec!["p_sysmon".to_string()],
1732                    logsource: None,
1733                },
1734                SchemaBinding {
1735                    schema: "ecs".to_string(),
1736                    pipelines: vec!["p_ecs".to_string()],
1737                    logsource: None,
1738                },
1739            ],
1740        };
1741        let plan = RoutingPlan::from_config(&config);
1742        let part = plan.set_product_partition();
1743        assert!(part[0].is_none(), "default set is never partitioned");
1744
1745        let set_of = |schema| match plan.decide(Some(schema)) {
1746            RouteDecision::Evaluate { set, .. } => set,
1747            other => panic!("unexpected: {other:?}"),
1748        };
1749        // sysmon set: only windows (platform-locked) -> partitionable.
1750        let sysmon_set = set_of("sysmon");
1751        assert_eq!(
1752            part[sysmon_set].as_ref().map(|s| s.contains("windows")),
1753            Some(true)
1754        );
1755        // ecs set: ecs is cross-platform (no implied product) -> keep all.
1756        assert!(part[set_of("ecs")].is_none());
1757    }
1758
1759    #[test]
1760    fn parses_user_signatures_from_yaml() {
1761        let yaml = r#"
1762schemas:
1763  - name: my_vendor
1764    specificity: 70
1765    match:
1766      - field_present: vendor.product
1767      - equals:
1768          field: event_type
1769          value: alert
1770      - any_of: [a, b]
1771"#;
1772        let sigs = parse_schema_signatures(yaml).expect("parse");
1773        assert_eq!(sigs.len(), 1);
1774        assert_eq!(sigs[0].name, "my_vendor");
1775        assert_eq!(sigs[0].specificity, 70);
1776        assert_eq!(sigs[0].predicates.len(), 3);
1777
1778        let cls = SchemaClassifier::with_user_signatures(sigs);
1779        let v = json!({"vendor": {"product": "X"}, "event_type": "ALERT", "a": 1});
1780        assert_eq!(
1781            cls.classify(&JsonEvent::borrow(&v))
1782                .map(|m| m.name)
1783                .as_deref(),
1784            Some("my_vendor")
1785        );
1786    }
1787
1788    #[test]
1789    fn user_signature_with_invalid_regex_is_rejected() {
1790        let yaml = r#"
1791schemas:
1792  - name: bad
1793    match:
1794      - matches:
1795          field: msg
1796          value: "([unclosed"
1797"#;
1798        let err = parse_schema_signatures(yaml).unwrap_err();
1799        assert!(matches!(err, SchemaError::InvalidRegex { .. }));
1800    }
1801
1802    #[test]
1803    fn user_regex_signature_matches_field_value() {
1804        let yaml = r#"
1805schemas:
1806  - name: cef_raw
1807    specificity: 60
1808    match:
1809      - matches:
1810          field: message
1811          value: "^CEF:\\d"
1812"#;
1813        let sigs = parse_schema_signatures(yaml).expect("parse");
1814        let cls = SchemaClassifier::with_user_signatures(sigs);
1815        let v = json!({"message": "CEF:0|Vendor|Product|1.0|100|Name|9|src=1.2.3.4"});
1816        assert_eq!(
1817            cls.classify(&JsonEvent::borrow(&v))
1818                .map(|m| m.name)
1819                .as_deref(),
1820            Some("cef_raw")
1821        );
1822    }
1823
1824    /// Build a single-signature classifier from a `match:` YAML body.
1825    fn classifier_from_match(match_body: &str) -> SchemaClassifier {
1826        let yaml = format!("schemas:\n  - name: t\n    specificity: 70\n    match:\n{match_body}");
1827        let sigs = parse_schema_signatures(&yaml).expect("parse");
1828        SchemaClassifier::new(sigs)
1829    }
1830
1831    fn matches_t(match_body: &str, event: &serde_json::Value) -> bool {
1832        classifier_from_match(match_body)
1833            .classify(&JsonEvent::borrow(event))
1834            .is_some()
1835    }
1836
1837    #[test]
1838    fn numeric_comparisons() {
1839        let body = "      - gte: { field: EventID, value: 4000 }\n";
1840        assert!(matches_t(body, &json!({"EventID": 4688})));
1841        assert!(matches_t(body, &json!({"EventID": 4000})));
1842        assert!(!matches_t(body, &json!({"EventID": 1})));
1843        // String-coercible numeric values work too.
1844        assert!(matches_t(body, &json!({"EventID": "4688"})));
1845        // A non-numeric field fails closed.
1846        assert!(!matches_t(body, &json!({"EventID": "not-a-number"})));
1847        // lt / gt / lte round out the operators.
1848        assert!(matches_t(
1849            "      - lt: { field: score, value: 10 }\n",
1850            &json!({"score": 9.5})
1851        ));
1852        assert!(matches_t(
1853            "      - gt: { field: score, value: 10 }\n",
1854            &json!({"score": 10.1})
1855        ));
1856    }
1857
1858    #[test]
1859    fn in_set_membership_is_case_insensitive() {
1860        let body = "      - in: { field: event_type, values: [alert, alarm] }\n";
1861        assert!(matches_t(body, &json!({"event_type": "ALERT"})));
1862        assert!(matches_t(body, &json!({"event_type": "alarm"})));
1863        assert!(!matches_t(body, &json!({"event_type": "info"})));
1864        assert!(!matches_t(body, &json!({})));
1865    }
1866
1867    #[test]
1868    fn field_equals_field_compares_two_fields() {
1869        let body = "      - field_equals_field: { left: a, right: b }\n";
1870        assert!(matches_t(body, &json!({"a": "X", "b": "x"})));
1871        assert!(!matches_t(body, &json!({"a": "X", "b": "y"})));
1872        // A missing side fails closed.
1873        assert!(!matches_t(body, &json!({"a": "X"})));
1874    }
1875
1876    #[test]
1877    fn recursive_not_any_all_groups() {
1878        // any: OR of two field-presence predicates.
1879        let any_body = "      - any:\n          - field_present: winlog.channel\n          - equals: { field: host.os.type, value: windows }\n";
1880        assert!(matches_t(
1881            any_body,
1882            &json!({"winlog": {"channel": "Security"}})
1883        ));
1884        assert!(matches_t(
1885            any_body,
1886            &json!({"host": {"os": {"type": "windows"}}})
1887        ));
1888        assert!(!matches_t(any_body, &json!({"unrelated": 1})));
1889
1890        // not: negation of a presence predicate.
1891        let not_body = "      - not: { field_present: ecs.version }\n";
1892        assert!(matches_t(not_body, &json!({"CommandLine": "whoami"})));
1893        assert!(!matches_t(not_body, &json!({"ecs.version": "8.0.0"})));
1894
1895        // all: nested AND, usable under not/any.
1896        let all_body = "      - all:\n          - field_present: a\n          - field_present: b\n";
1897        assert!(matches_t(all_body, &json!({"a": 1, "b": 2})));
1898        assert!(!matches_t(all_body, &json!({"a": 1})));
1899    }
1900
1901    #[test]
1902    fn empty_group_is_rejected() {
1903        let yaml = "schemas:\n  - name: t\n    match:\n      - any: []\n";
1904        let err = parse_schema_signatures(yaml).unwrap_err();
1905        assert!(
1906            matches!(&err, SchemaError::Parse(m) if m.contains("'any' needs at least one")),
1907            "got: {err}"
1908        );
1909    }
1910
1911    #[test]
1912    fn predicate_with_two_conditions_is_rejected() {
1913        let yaml = "schemas:\n  - name: t\n    match:\n      - field_present: a\n        field_absent: b\n";
1914        let err = parse_schema_signatures(yaml).unwrap_err();
1915        assert!(
1916            matches!(&err, SchemaError::Parse(m) if m.contains("multiple conditions")),
1917            "got: {err}"
1918        );
1919    }
1920
1921    #[test]
1922    fn explain_reports_matched_signature() {
1923        let cls = SchemaClassifier::builtin();
1924        let v = json!({"ecs.version": "8.0.0"});
1925        let ex = cls.explain(&JsonEvent::borrow(&v));
1926        assert_eq!(ex.matched.as_deref(), Some("ecs"));
1927        let sig = ex.signature.expect("signature");
1928        assert!(sig.predicates_matched);
1929        assert!(sig.predicates.iter().all(|p| p.matched));
1930    }
1931
1932    #[test]
1933    fn explain_reports_near_miss_for_unknown() {
1934        // Drop generic_json so a structured non-match is genuinely unknown.
1935        let sigs = builtin_signatures()
1936            .into_iter()
1937            .filter(|s| s.name != "generic_json")
1938            .collect();
1939        let cls = SchemaClassifier::new(sigs);
1940        // Sysmon-ish but missing ProcessGuid: unknown, near-miss is sysmon.
1941        let v = json!({"EventID": 1, "Image": "C:/cmd.exe"});
1942        let ex = cls.explain(&JsonEvent::borrow(&v));
1943        assert_eq!(ex.matched, None);
1944        let sig = ex.signature.expect("near-miss");
1945        assert_eq!(sig.name, "sysmon");
1946        assert!(!sig.predicates_matched);
1947        assert!(sig.predicates.iter().any(|p| !p.matched));
1948    }
1949
1950    #[test]
1951    fn validate_flags_unknown_binding_and_shadow() {
1952        let yaml = r#"
1953schemas:
1954  - name: shadowed
1955    specificity: 40
1956    match:
1957      - field_present: ecs.version
1958      - field_present: extra.marker
1959routing:
1960  bindings:
1961    - schema: ecs
1962      pipelines: [ecs_windows]
1963    - schema: nonexistent
1964      pipelines: [x]
1965"#;
1966        let (sigs, routing) = parse_schema_config(yaml).unwrap();
1967        let findings = validate_schema_config(&sigs, routing.as_ref());
1968        assert!(
1969            findings
1970                .iter()
1971                .any(|f| f.contains("unknown schema 'nonexistent'")),
1972            "findings: {findings:?}"
1973        );
1974        // `shadowed` needs ecs.version + extra.marker; the built-in ecs (spec
1975        // 100) needs only ecs.version (a subset), so `shadowed` is unreachable.
1976        assert!(
1977            findings
1978                .iter()
1979                .any(|f| f.contains("'shadowed'") && f.contains("unreachable")),
1980            "findings: {findings:?}"
1981        );
1982    }
1983
1984    #[test]
1985    fn observer_counts_per_schema_and_unknown() {
1986        let observer = SchemaObserver::builtin();
1987        observer.observe(&JsonEvent::borrow(&json!({"ecs.version": "8.0.0"})));
1988        observer.observe(&JsonEvent::borrow(&json!({"ecs.version": "8.1.0"})));
1989        observer.observe(&JsonEvent::borrow(
1990            &json!({"class_uid": 1001, "metadata": {"version": "1.1.0"}}),
1991        ));
1992        observer.observe(&JsonEvent::borrow(&json!({})));
1993
1994        let snap = observer.snapshot();
1995        assert_eq!(snap.events_observed, 4);
1996        assert_eq!(snap.classified, 3);
1997        assert_eq!(snap.unknown, 1);
1998        // Sorted by descending count, so ecs (2) comes first.
1999        assert_eq!(snap.by_schema[0].schema, "ecs");
2000        assert_eq!(snap.by_schema[0].count, 2);
2001        let ocsf = snap.by_schema.iter().find(|e| e.schema == "ocsf").unwrap();
2002        assert_eq!(ocsf.count, 1);
2003    }
2004
2005    #[test]
2006    fn routing_plan_dedups_pipeline_sets() {
2007        let config = RoutingConfig {
2008            on_unknown: OnUnknown::Warn,
2009            default_pipelines: vec![],
2010            aliases: HashMap::new(),
2011            bindings: vec![
2012                SchemaBinding {
2013                    schema: "ecs".to_string(),
2014                    pipelines: vec!["ecs_windows".to_string()],
2015                    logsource: None,
2016                },
2017                SchemaBinding {
2018                    schema: "winlogbeat".to_string(),
2019                    pipelines: vec!["ecs_windows".to_string()],
2020                    logsource: None,
2021                },
2022                SchemaBinding {
2023                    schema: "sysmon".to_string(),
2024                    pipelines: vec!["sysmon".to_string()],
2025                    logsource: None,
2026                },
2027            ],
2028        };
2029        let plan = RoutingPlan::from_config(&config);
2030        // Default set (0) + ecs_windows set + sysmon set = 3 distinct sets.
2031        assert_eq!(plan.pipeline_sets().len(), 3);
2032        // ecs and winlogbeat share the same deduped set.
2033        let ecs = plan.decide(Some("ecs"));
2034        let win = plan.decide(Some("winlogbeat"));
2035        assert_eq!(ecs, win);
2036        assert!(matches!(
2037            ecs,
2038            RouteDecision::Evaluate { unknown: false, .. }
2039        ));
2040        // sysmon is a different set.
2041        assert_ne!(plan.decide(Some("sysmon")), ecs);
2042    }
2043
2044    #[test]
2045    fn routing_decides_bound_unbound_and_unknown() {
2046        let config = RoutingConfig {
2047            on_unknown: OnUnknown::Warn,
2048            default_pipelines: vec![],
2049            aliases: HashMap::new(),
2050            bindings: vec![SchemaBinding {
2051                schema: "ecs".to_string(),
2052                pipelines: vec!["ecs_windows".to_string()],
2053                logsource: None,
2054            }],
2055        };
2056        let plan = RoutingPlan::from_config(&config);
2057        // Bound schema -> its own set, not flagged unknown.
2058        assert!(matches!(
2059            plan.decide(Some("ecs")),
2060            RouteDecision::Evaluate { unknown: false, .. }
2061        ));
2062        // Recognized but unbound -> default set (0), not flagged unknown.
2063        assert_eq!(
2064            plan.decide(Some("cef")),
2065            RouteDecision::Evaluate {
2066                set: 0,
2067                unknown: false
2068            }
2069        );
2070        // Unknown -> default set, flagged unknown (Warn).
2071        assert_eq!(
2072            plan.decide(None),
2073            RouteDecision::Evaluate {
2074                set: 0,
2075                unknown: true
2076            }
2077        );
2078    }
2079
2080    #[test]
2081    fn routing_on_unknown_policies() {
2082        let base = |policy| RoutingConfig {
2083            on_unknown: policy,
2084            default_pipelines: vec![],
2085            aliases: HashMap::new(),
2086            bindings: vec![],
2087        };
2088        assert_eq!(
2089            RoutingPlan::from_config(&base(OnUnknown::Drop)).decide(None),
2090            RouteDecision::Drop
2091        );
2092        assert_eq!(
2093            RoutingPlan::from_config(&base(OnUnknown::Error)).decide(None),
2094            RouteDecision::Error
2095        );
2096        assert_eq!(
2097            RoutingPlan::from_config(&base(OnUnknown::Passthrough)).decide(None),
2098            RouteDecision::Evaluate {
2099                set: 0,
2100                unknown: true
2101            }
2102        );
2103    }
2104
2105    #[test]
2106    fn parses_routing_section_from_yaml() {
2107        let yaml = r#"
2108schemas:
2109  - name: my_vendor
2110    match:
2111      - field_present: vendor.id
2112routing:
2113  on_unknown: drop
2114  default_pipelines: [base]
2115  bindings:
2116    - schema: ecs
2117      pipelines: [ecs_windows]
2118    - schema: my_vendor
2119      pipelines: [vendor_map, base]
2120"#;
2121        let (sigs, routing) = parse_schema_config(yaml).expect("parse");
2122        assert_eq!(sigs.len(), 1);
2123        let routing = routing.expect("routing present");
2124        assert_eq!(routing.on_unknown, OnUnknown::Drop);
2125        assert_eq!(routing.default_pipelines, vec!["base".to_string()]);
2126        assert_eq!(routing.bindings.len(), 2);
2127        let plan = RoutingPlan::from_config(&routing);
2128        // default [base], ecs [ecs_windows], my_vendor [vendor_map, base] = 3.
2129        assert_eq!(plan.pipeline_sets().len(), 3);
2130        assert_eq!(plan.decide(None), RouteDecision::Drop);
2131    }
2132
2133    #[test]
2134    fn schema_logsource_builtin_defaults_and_overrides() {
2135        // Built-in platform-locked defaults apply even without bindings.
2136        let plan = RoutingPlan::from_config(&RoutingConfig::default());
2137        let sysmon = plan.schema_logsource("sysmon").expect("sysmon default");
2138        assert_eq!(sysmon.product.as_deref(), Some("windows"));
2139        assert_eq!(sysmon.service.as_deref(), Some("sysmon"));
2140        assert_eq!(
2141            plan.schema_logsource("windows_eventlog")
2142                .and_then(|l| l.product.as_deref()),
2143            Some("windows")
2144        );
2145        // Cross-platform schemas imply nothing.
2146        assert!(plan.schema_logsource("ecs").is_none());
2147        assert!(plan.schema_logsource("cef").is_none());
2148
2149        // A binding can attach or override a schema's implied logsource.
2150        let yaml = r#"
2151schemas:
2152  - name: ecs_windows
2153    match:
2154      - field_present: ecs.version
2155      - field_present: winlog.channel
2156routing:
2157  bindings:
2158    - schema: ecs_windows
2159      pipelines: [ecs_windows]
2160      logsource:
2161        product: windows
2162    - schema: sysmon
2163      pipelines: [sysmon]
2164      logsource:
2165        product: windows
2166        service: sysmon
2167        custom:
2168          tenant: acme
2169"#;
2170        let (_sigs, routing) = parse_schema_config(yaml).expect("parse");
2171        let plan = RoutingPlan::from_config(&routing.expect("routing"));
2172        assert_eq!(
2173            plan.schema_logsource("ecs_windows")
2174                .and_then(|l| l.product.as_deref()),
2175            Some("windows")
2176        );
2177        let sysmon = plan.schema_logsource("sysmon").expect("sysmon override");
2178        assert_eq!(
2179            sysmon.custom.get("tenant").map(String::as_str),
2180            Some("acme")
2181        );
2182    }
2183
2184    #[test]
2185    fn observer_reset_preserves_lifetime_counters() {
2186        let observer = SchemaObserver::builtin();
2187        observer.observe(&JsonEvent::borrow(&json!({"ecs.version": "8.0.0"})));
2188        observer.observe(&JsonEvent::borrow(&json!({})));
2189        let (classified, unknown) = observer.reset();
2190        assert_eq!(classified, 1);
2191        assert_eq!(unknown, 1);
2192
2193        let snap = observer.snapshot();
2194        assert_eq!(snap.classified, 0);
2195        assert_eq!(snap.unknown, 0);
2196        assert_eq!(snap.events_observed, 0);
2197        // Lifetime totals survive the reset for the Prometheus bridge.
2198        assert_eq!(snap.lifetime_classified, 1);
2199        assert_eq!(snap.lifetime_unknown, 1);
2200    }
2201
2202    #[test]
2203    fn classify_with_ambiguity_flags_equal_specificity_ties() {
2204        // Two different-name signatures at the same specificity that both match.
2205        let sigs = vec![
2206            SchemaSignature {
2207                name: "alpha".to_string(),
2208                specificity: 70,
2209                predicates: vec![SchemaPredicate::FieldPresent("a".to_string())],
2210            },
2211            SchemaSignature {
2212                name: "beta".to_string(),
2213                specificity: 70,
2214                predicates: vec![SchemaPredicate::FieldPresent("a".to_string())],
2215            },
2216        ];
2217        let cls = SchemaClassifier::new(sigs);
2218        let (m, ambiguous) = cls.classify_with_ambiguity(&JsonEvent::borrow(&json!({"a": 1})));
2219        assert!(m.is_some());
2220        assert!(
2221            ambiguous,
2222            "equal-specificity different-name match is ambiguous"
2223        );
2224        // A single match is not ambiguous.
2225        let cls = SchemaClassifier::builtin();
2226        let (_, ambiguous) =
2227            cls.classify_with_ambiguity(&JsonEvent::borrow(&json!({"ecs.version": "8.0.0"})));
2228        assert!(!ambiguous);
2229    }
2230
2231    #[test]
2232    fn observer_records_ambiguity_and_unknown_shapes() {
2233        let sigs = vec![
2234            SchemaSignature {
2235                name: "alpha".to_string(),
2236                specificity: 70,
2237                predicates: vec![SchemaPredicate::FieldPresent("a".to_string())],
2238            },
2239            SchemaSignature {
2240                name: "beta".to_string(),
2241                specificity: 70,
2242                predicates: vec![SchemaPredicate::FieldPresent("a".to_string())],
2243            },
2244        ];
2245        let observer = SchemaObserver::new(SchemaClassifier::new(sigs));
2246        observer.observe(&JsonEvent::borrow(&json!({"a": 1}))); // ambiguous, classified
2247        observer.observe(&JsonEvent::borrow(&json!({"weird": 1, "shape": 2}))); // unknown
2248        observer.observe(&JsonEvent::borrow(&json!({"shape": 3, "weird": 4}))); // same shape
2249
2250        let snap = observer.snapshot();
2251        assert_eq!(snap.ambiguous, 1);
2252        assert_eq!(snap.unknown, 2);
2253        // Both unknown events share one redacted key shape [shape, weird].
2254        assert_eq!(snap.unknown_shapes.len(), 1);
2255        assert_eq!(snap.unknown_shapes[0].count, 2);
2256        assert_eq!(snap.unknown_shapes[0].keys, vec!["shape", "weird"]);
2257    }
2258}