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. Cloud/SaaS/Container sources are
20//! also recognized out of the box: AWS CloudTrail, AWS VPC Flow Logs, Azure
21//! (ActivityLogs, SignInLogs, AuditLogs), GCP Cloud Audit, Microsoft 365
22//! unified audit log, GitHub Audit, Okta System Log, OneLogin, Kubernetes
23//! audit, Docker events, and osquery.
24//! Users extend the set with their own signatures loaded from YAML (see
25//! [`parse_schema_signatures`]).
26//!
27//! Detection-side only: this recognizes events so callers can route them to the
28//! right field-mapping pipeline. It does not collect, transport, or normalize
29//! events.
30
31use std::collections::HashMap;
32use std::fs;
33use std::path::Path;
34use std::sync::Mutex;
35use std::sync::atomic::{AtomicU64, Ordering};
36use std::time::Instant;
37
38use regex::Regex;
39use rsigma_parser::LogSource;
40use serde::{Deserialize, Serialize};
41
42use crate::event::Event;
43
44/// Numeric comparison operator for [`SchemaPredicate::Compare`].
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum CompareOp {
47    /// Strictly greater than.
48    Gt,
49    /// Greater than or equal.
50    Gte,
51    /// Strictly less than.
52    Lt,
53    /// Less than or equal.
54    Lte,
55}
56
57impl CompareOp {
58    fn apply(self, lhs: f64, rhs: f64) -> bool {
59        match self {
60            CompareOp::Gt => lhs > rhs,
61            CompareOp::Gte => lhs >= rhs,
62            CompareOp::Lt => lhs < rhs,
63            CompareOp::Lte => lhs <= rhs,
64        }
65    }
66
67    fn symbol(self) -> &'static str {
68        match self {
69            CompareOp::Gt => ">",
70            CompareOp::Gte => ">=",
71            CompareOp::Lt => "<",
72            CompareOp::Lte => "<=",
73        }
74    }
75}
76
77/// A single condition over a parsed event used to recognize a schema.
78///
79/// Field names use the same dot-notation as [`Event::get_field`], so nested
80/// shapes like `Event.System.EventID` or `ecs.version` work whether the event
81/// is nested or carries flattened dotted keys.
82#[derive(Debug, Clone)]
83pub enum SchemaPredicate {
84    /// The named field is present (any non-absent value, including null).
85    FieldPresent(String),
86    /// The named field is absent.
87    FieldAbsent(String),
88    /// At least one of the named fields is present.
89    AnyOf(Vec<String>),
90    /// The field is present and its string-coerced value equals `value`
91    /// (ASCII case-insensitive).
92    Equals { field: String, value: String },
93    /// The field is present and its string-coerced value matches `regex`.
94    Matches { field: String, regex: Regex },
95    /// The field is present, numeric-coercible, and compares to `value` under
96    /// `op`. A non-numeric or absent field fails closed (no match).
97    Compare {
98        field: String,
99        op: CompareOp,
100        value: f64,
101    },
102    /// The field is present and its string-coerced value equals one of
103    /// `values` (ASCII case-insensitive). The multi-value form of `Equals`.
104    In { field: String, values: Vec<String> },
105    /// Both fields are present, string-coercible, and equal (case-insensitive).
106    FieldEqualsField { left: String, right: String },
107    /// Logical negation of the inner predicate.
108    Not(Box<SchemaPredicate>),
109    /// At least one of the inner predicates holds (logical OR).
110    Any(Vec<SchemaPredicate>),
111    /// All of the inner predicates hold (logical AND). Useful as a group under
112    /// `Not` or `Any`.
113    All(Vec<SchemaPredicate>),
114    /// The event has at least one structured field. Used by the
115    /// `generic_json` fallback to distinguish structured events from
116    /// field-less ones (raw text, empty objects), which stay "unknown".
117    HasAnyField,
118}
119
120impl SchemaPredicate {
121    fn eval<E: Event + ?Sized>(&self, event: &E) -> bool {
122        match self {
123            SchemaPredicate::FieldPresent(f) => event.get_field(f).is_some(),
124            SchemaPredicate::FieldAbsent(f) => event.get_field(f).is_none(),
125            SchemaPredicate::AnyOf(fields) => fields.iter().any(|f| event.get_field(f).is_some()),
126            SchemaPredicate::Equals { field, value } => event
127                .get_field(field)
128                .and_then(|v| v.as_str().map(|s| s.as_ref().eq_ignore_ascii_case(value)))
129                .unwrap_or(false),
130            SchemaPredicate::Matches { field, regex } => event
131                .get_field(field)
132                .and_then(|v| v.as_str().map(|s| regex.is_match(s.as_ref())))
133                .unwrap_or(false),
134            SchemaPredicate::Compare { field, op, value } => event
135                .get_field(field)
136                .and_then(|v| v.as_f64())
137                .map(|n| op.apply(n, *value))
138                .unwrap_or(false),
139            SchemaPredicate::In { field, values } => event
140                .get_field(field)
141                .and_then(|v| {
142                    v.as_str().map(|s| {
143                        values
144                            .iter()
145                            .any(|val| s.as_ref().eq_ignore_ascii_case(val))
146                    })
147                })
148                .unwrap_or(false),
149            SchemaPredicate::FieldEqualsField { left, right } => {
150                let l = event
151                    .get_field(left)
152                    .and_then(|v| v.as_str().map(|s| s.into_owned()));
153                let r = event
154                    .get_field(right)
155                    .and_then(|v| v.as_str().map(|s| s.into_owned()));
156                matches!((l, r), (Some(a), Some(b)) if a.eq_ignore_ascii_case(&b))
157            }
158            SchemaPredicate::Not(inner) => !inner.eval(event),
159            SchemaPredicate::Any(preds) => preds.iter().any(|p| p.eval(event)),
160            SchemaPredicate::All(preds) => preds.iter().all(|p| p.eval(event)),
161            SchemaPredicate::HasAnyField => !event.field_keys().is_empty(),
162        }
163    }
164
165    /// A compact human description of the predicate, for `explain` output.
166    fn describe(&self) -> String {
167        match self {
168            SchemaPredicate::FieldPresent(f) => format!("field_present({f})"),
169            SchemaPredicate::FieldAbsent(f) => format!("field_absent({f})"),
170            SchemaPredicate::AnyOf(fs) => format!("any_of([{}])", fs.join(", ")),
171            SchemaPredicate::Equals { field, value } => format!("{field} == \"{value}\""),
172            SchemaPredicate::Matches { field, regex } => {
173                format!("{field} matches /{}/", regex.as_str())
174            }
175            SchemaPredicate::Compare { field, op, value } => {
176                format!("{field} {} {value}", op.symbol())
177            }
178            SchemaPredicate::In { field, values } => format!("{field} in [{}]", values.join(", ")),
179            SchemaPredicate::FieldEqualsField { left, right } => format!("{left} == {right}"),
180            SchemaPredicate::Not(inner) => format!("not({})", inner.describe()),
181            SchemaPredicate::Any(ps) => format!(
182                "any({})",
183                ps.iter()
184                    .map(|p| p.describe())
185                    .collect::<Vec<_>>()
186                    .join(" | ")
187            ),
188            SchemaPredicate::All(ps) => format!(
189                "all({})",
190                ps.iter()
191                    .map(|p| p.describe())
192                    .collect::<Vec<_>>()
193                    .join(" & ")
194            ),
195            SchemaPredicate::HasAnyField => "has_any_field".to_string(),
196        }
197    }
198}
199
200/// A named schema recognizer: every predicate must hold for the signature to
201/// match. Higher `specificity` wins when several signatures match the same
202/// event. Multiple signatures may share a `name` (for example several distinct
203/// ways to recognize Sysmon); the classifier reports the name.
204#[derive(Debug, Clone)]
205pub struct SchemaSignature {
206    /// Schema label reported on a match (for example `ecs`, `sysmon`).
207    pub name: String,
208    /// Conditions that must all hold (logical AND). An empty predicate set
209    /// matches every event; prefer [`SchemaPredicate::HasAnyField`] for a
210    /// structured-event fallback.
211    pub predicates: Vec<SchemaPredicate>,
212    /// Tie-breaking weight; the highest-specificity matching signature wins.
213    pub specificity: u32,
214}
215
216impl SchemaSignature {
217    fn matches<E: Event + ?Sized>(&self, event: &E) -> bool {
218        self.predicates.iter().all(|p| p.eval(event))
219    }
220
221    fn explain<E: Event + ?Sized>(&self, event: &E) -> SignatureExplanation {
222        let predicates: Vec<PredicateOutcome> = self
223            .predicates
224            .iter()
225            .map(|p| PredicateOutcome {
226                predicate: p.describe(),
227                matched: p.eval(event),
228            })
229            .collect();
230        let predicates_matched = predicates.iter().all(|p| p.matched);
231        SignatureExplanation {
232            name: self.name.clone(),
233            specificity: self.specificity,
234            predicates_matched,
235            predicates,
236        }
237    }
238}
239
240/// The outcome of one predicate within a [`SignatureExplanation`].
241#[derive(Debug, Clone, Serialize)]
242pub struct PredicateOutcome {
243    /// Human description of the predicate (for example `field_present(ecs.version)`).
244    pub predicate: String,
245    /// Whether the predicate held for the event.
246    pub matched: bool,
247}
248
249/// Per-signature detail produced by [`SchemaClassifier::explain`].
250#[derive(Debug, Clone, Serialize)]
251pub struct SignatureExplanation {
252    /// The signature's schema name.
253    pub name: String,
254    /// The signature's tie-breaking specificity.
255    pub specificity: u32,
256    /// Whether every predicate held (the signature matched).
257    pub predicates_matched: bool,
258    /// Per-predicate outcomes, in signature order.
259    pub predicates: Vec<PredicateOutcome>,
260}
261
262/// Why an event classified (or did not) as reported by
263/// [`SchemaClassifier::explain`]: the winning schema (if any) plus the
264/// signature that explains the outcome (the winning signature, or for an
265/// unknown event the closest near-miss).
266#[derive(Debug, Clone, Serialize)]
267pub struct SchemaExplanation {
268    /// The classified schema name, or `None` when the event matched none.
269    pub matched: Option<String>,
270    /// The winning signature's specificity, when matched.
271    pub specificity: Option<u32>,
272    /// The explaining signature: the winner when matched, otherwise the
273    /// highest-scoring near-miss (most predicates passing).
274    pub signature: Option<SignatureExplanation>,
275}
276
277/// The result of classifying an event: the matched schema name and the
278/// specificity of the signature that matched.
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct SchemaMatch {
281    pub name: String,
282    pub specificity: u32,
283}
284
285/// Recognizes the schema of parsed events from a set of signatures.
286///
287/// Signatures are sorted once at construction (specificity descending, then
288/// name ascending) so [`classify`](Self::classify) returns the best match with
289/// a single in-order scan.
290#[derive(Debug, Clone)]
291pub struct SchemaClassifier {
292    signatures: Vec<SchemaSignature>,
293}
294
295impl SchemaClassifier {
296    /// Build a classifier from an explicit signature set.
297    pub fn new(mut signatures: Vec<SchemaSignature>) -> Self {
298        signatures.sort_by(|a, b| {
299            b.specificity
300                .cmp(&a.specificity)
301                .then_with(|| a.name.cmp(&b.name))
302        });
303        Self { signatures }
304    }
305
306    /// Build a classifier from the built-in signatures only.
307    pub fn builtin() -> Self {
308        Self::new(builtin_signatures())
309    }
310
311    /// Build a classifier from the built-ins plus user-supplied signatures.
312    /// User signatures are added to (not replacing) the built-ins; a user
313    /// signature with a higher specificity than a built-in wins on overlap.
314    pub fn with_user_signatures(user: Vec<SchemaSignature>) -> Self {
315        let mut signatures = builtin_signatures();
316        signatures.extend(user);
317        Self::new(signatures)
318    }
319
320    /// Classify an event. Returns the highest-specificity matching schema, or
321    /// `None` when the event matches no signature ("unknown").
322    pub fn classify<E: Event + ?Sized>(&self, event: &E) -> Option<SchemaMatch> {
323        self.signatures
324            .iter()
325            .find(|s| s.matches(event))
326            .map(|s| SchemaMatch {
327                name: s.name.clone(),
328                specificity: s.specificity,
329            })
330    }
331
332    /// Classify and also report ambiguity: `true` when another signature with a
333    /// different name matches at the same (winning) specificity, so the winner
334    /// was chosen by the name tie-break rather than by specificity. Ambiguity
335    /// signals that routing intent may be nondeterministic and a signature
336    /// wants a distinguishing predicate or a specificity bump.
337    pub fn classify_with_ambiguity<E: Event + ?Sized>(
338        &self,
339        event: &E,
340    ) -> (Option<SchemaMatch>, bool) {
341        // Signatures are sorted specificity-descending, so the first match is
342        // the winner and any following match with equal specificity but a
343        // different name is a genuine tie.
344        let mut matching = self.signatures.iter().filter(|s| s.matches(event));
345        let Some(winner) = matching.next() else {
346            return (None, false);
347        };
348        let ambiguous = matching
349            .take_while(|s| s.specificity == winner.specificity)
350            .any(|s| s.name != winner.name);
351        (
352            Some(SchemaMatch {
353                name: winner.name.clone(),
354                specificity: winner.specificity,
355            }),
356            ambiguous,
357        )
358    }
359
360    /// All matching schema names for an event, most specific first. Useful for
361    /// tuning signatures (seeing what else an event could match). Deduplicated
362    /// by name while preserving order.
363    pub fn classify_all<E: Event + ?Sized>(&self, event: &E) -> Vec<String> {
364        let mut out: Vec<String> = Vec::new();
365        for sig in self.signatures.iter().filter(|s| s.matches(event)) {
366            if !out.iter().any(|n| n == &sig.name) {
367                out.push(sig.name.clone());
368            }
369        }
370        out
371    }
372
373    /// Explain how an event classifies: the winning schema (if any) plus the
374    /// signature that explains it (the winning signature, or for an unknown
375    /// event the closest near-miss, the non-matching signature with the most
376    /// passing predicates). For tuning signatures.
377    pub fn explain<E: Event + ?Sized>(&self, event: &E) -> SchemaExplanation {
378        let mut best_near: Option<SignatureExplanation> = None;
379        let mut best_near_passing = 0usize;
380        for sig in &self.signatures {
381            let ex = sig.explain(event);
382            if ex.predicates_matched {
383                return SchemaExplanation {
384                    matched: Some(ex.name.clone()),
385                    specificity: Some(ex.specificity),
386                    signature: Some(ex),
387                };
388            }
389            // Signatures are sorted specificity-descending, so the first
390            // signature reaching a given passing count wins the tie-break.
391            let passing = ex.predicates.iter().filter(|p| p.matched).count();
392            if best_near.is_none() || passing > best_near_passing {
393                best_near_passing = passing;
394                best_near = Some(ex);
395            }
396        }
397        SchemaExplanation {
398            matched: None,
399            specificity: None,
400            signature: best_near,
401        }
402    }
403
404    /// Distinct schema names this classifier can produce, most specific first.
405    pub fn schema_names(&self) -> Vec<&str> {
406        let mut out: Vec<&str> = Vec::new();
407        for sig in &self.signatures {
408            if !out.contains(&sig.name.as_str()) {
409                out.push(sig.name.as_str());
410            }
411        }
412        out
413    }
414}
415
416impl Default for SchemaClassifier {
417    fn default() -> Self {
418        Self::builtin()
419    }
420}
421
422/// The built-in schema signatures, derived from the public schema specs:
423/// Elastic Common Schema, OCSF, the Windows event XML model, Microsoft
424/// Sysmon, and the ArcSight CEF spec.
425fn builtin_signatures() -> Vec<SchemaSignature> {
426    vec![
427        // ECS on Windows: ECS plus a Windows marker. More specific than plain
428        // `ecs` so it wins, and it aliases to `ecs` for routing (see
429        // `builtin_schema_aliases`) while carrying an implied `product:
430        // windows` for logsource pruning.
431        SchemaSignature {
432            name: "ecs_windows".to_string(),
433            specificity: 105,
434            predicates: vec![
435                SchemaPredicate::FieldPresent("ecs.version".to_string()),
436                SchemaPredicate::Any(vec![
437                    SchemaPredicate::FieldPresent("winlog.channel".to_string()),
438                    SchemaPredicate::FieldPresent("winlog.event_id".to_string()),
439                    SchemaPredicate::Equals {
440                        field: "host.os.type".to_string(),
441                        value: "windows".to_string(),
442                    },
443                    SchemaPredicate::Equals {
444                        field: "os.type".to_string(),
445                        value: "windows".to_string(),
446                    },
447                ]),
448            ],
449        },
450        // ECS on Linux: ECS plus a Linux marker. Aliases to `ecs`, implies
451        // `product: linux`.
452        SchemaSignature {
453            name: "ecs_linux".to_string(),
454            specificity: 105,
455            predicates: vec![
456                SchemaPredicate::FieldPresent("ecs.version".to_string()),
457                SchemaPredicate::Any(vec![
458                    SchemaPredicate::Equals {
459                        field: "host.os.type".to_string(),
460                        value: "linux".to_string(),
461                    },
462                    SchemaPredicate::Equals {
463                        field: "os.type".to_string(),
464                        value: "linux".to_string(),
465                    },
466                    SchemaPredicate::FieldPresent("host.os.kernel".to_string()),
467                ]),
468            ],
469        },
470        // ECS (Elastic Common Schema): `ecs.version` is the canonical marker.
471        SchemaSignature {
472            name: "ecs".to_string(),
473            specificity: 100,
474            predicates: vec![SchemaPredicate::FieldPresent("ecs.version".to_string())],
475        },
476        // OCSF: class_uid plus metadata.version are mandatory discriminators.
477        SchemaSignature {
478            name: "ocsf".to_string(),
479            specificity: 95,
480            predicates: vec![
481                SchemaPredicate::FieldPresent("class_uid".to_string()),
482                SchemaPredicate::FieldPresent("metadata.version".to_string()),
483            ],
484        },
485        // Rendered Windows Event Log (EVTX decoded to JSON): Event.System.*.
486        SchemaSignature {
487            name: "windows_eventlog".to_string(),
488            specificity: 90,
489            predicates: vec![SchemaPredicate::AnyOf(vec![
490                "Event.System.EventID".to_string(),
491                "Event.System.Provider".to_string(),
492            ])],
493        },
494        // Sysmon (flat) via the operational channel marker.
495        SchemaSignature {
496            name: "sysmon".to_string(),
497            specificity: 88,
498            predicates: vec![SchemaPredicate::Equals {
499                field: "Channel".to_string(),
500                value: "Microsoft-Windows-Sysmon/Operational".to_string(),
501            }],
502        },
503        // Sysmon (flat) via the provider marker.
504        SchemaSignature {
505            name: "sysmon".to_string(),
506            specificity: 88,
507            predicates: vec![SchemaPredicate::Equals {
508                field: "Provider_Name".to_string(),
509                value: "Microsoft-Windows-Sysmon".to_string(),
510            }],
511        },
512        // Sysmon (flat) via field shape when no provider/channel tag is present.
513        SchemaSignature {
514            name: "sysmon".to_string(),
515            specificity: 80,
516            predicates: vec![
517                SchemaPredicate::FieldPresent("EventID".to_string()),
518                SchemaPredicate::FieldPresent("ProcessGuid".to_string()),
519                SchemaPredicate::AnyOf(vec!["Image".to_string(), "CommandLine".to_string()]),
520            ],
521        },
522        // CEF: structured header fields produced by the CEF parser or carried
523        // in JSON (deviceVendor / deviceProduct / signatureId).
524        SchemaSignature {
525            name: "cef".to_string(),
526            specificity: 85,
527            predicates: vec![
528                SchemaPredicate::FieldPresent("deviceVendor".to_string()),
529                SchemaPredicate::FieldPresent("deviceProduct".to_string()),
530                SchemaPredicate::FieldPresent("signatureId".to_string()),
531            ],
532        },
533        // ─────────────────────────────────────────────────────────────────────
534        // Cloud / SaaS / Container sources (always-on recognition)
535        // ─────────────────────────────────────────────────────────────────────
536        // AWS VPC Flow Logs (JSON form): src + dst addr + action ACCEPT/REJECT.
537        // Off-taxonomy: ships as `{platform: aws, source: vpcflow}`.
538        SchemaSignature {
539            name: "aws_vpcflow".to_string(),
540            specificity: 80,
541            predicates: vec![
542                SchemaPredicate::FieldPresent("srcaddr".to_string()),
543                SchemaPredicate::FieldPresent("dstaddr".to_string()),
544                SchemaPredicate::In {
545                    field: "action".to_string(),
546                    values: vec!["ACCEPT".to_string(), "REJECT".to_string()],
547                },
548            ],
549        },
550        // AWS CloudTrail: `eventVersion` + `eventSource` + `eventID` +
551        // `userIdentity` collectively disambiguate CloudTrail from
552        // all other JSON schemas.
553        SchemaSignature {
554            name: "aws_cloudtrail".to_string(),
555            specificity: 85,
556            predicates: vec![
557                SchemaPredicate::FieldPresent("eventVersion".to_string()),
558                SchemaPredicate::FieldPresent("eventSource".to_string()),
559                SchemaPredicate::FieldPresent("eventID".to_string()),
560                SchemaPredicate::FieldPresent("userIdentity".to_string()),
561            ],
562        },
563        // OneLogin events: `event_type_id` is the single strongest discriminator,
564        // corroborated by account_id and created_at.
565        SchemaSignature {
566            name: "onelogin_events".to_string(),
567            specificity: 85,
568            predicates: vec![
569                SchemaPredicate::FieldPresent("event_type_id".to_string()),
570                SchemaPredicate::FieldPresent("account_id".to_string()),
571                SchemaPredicate::AnyOf(vec!["user_id".to_string(), "actor_user_id".to_string()]),
572            ],
573        },
574        // Kubernetes audit events: `kind: Event` with apiVersion
575        // `audit.k8s.io/` is unique to the Kubernetes audit backend;
576        // auditID and requestURI add corroborating markers.
577        SchemaSignature {
578            name: "k8s_audit".to_string(),
579            specificity: 92,
580            predicates: vec![
581                SchemaPredicate::Equals {
582                    field: "kind".to_string(),
583                    value: "Event".to_string(),
584                },
585                SchemaPredicate::Matches {
586                    field: "apiVersion".to_string(),
587                    regex: regex::Regex::new("^audit\\.k8s\\.io/")
588                        .expect("k8s audit apiVersion regex"),
589                },
590                SchemaPredicate::FieldPresent("auditID".to_string()),
591            ],
592        },
593        // GitHub audit log events: `action` + `actor` + any-of(
594        // `org`, `repo`) + `created_at`/`_document_id` distinguish GitHub
595        // audit JSON from all other event sources.
596        SchemaSignature {
597            name: "github_audit".to_string(),
598            specificity: 92,
599            predicates: vec![
600                SchemaPredicate::FieldPresent("action".to_string()),
601                SchemaPredicate::FieldPresent("actor".to_string()),
602                SchemaPredicate::AnyOf(vec!["org".to_string(), "repo".to_string()]),
603                SchemaPredicate::AnyOf(vec!["created_at".to_string(), "_document_id".to_string()]),
604            ],
605        },
606        // Okta System Log events: `eventType` is a unique per-event
607        // identifier (e.g. `user.lifecycle.activate.pre_auth`),
608        // corroborated by `actor`, `outcome.result`, and `published`.
609        SchemaSignature {
610            name: "okta_system_log".to_string(),
611            specificity: 88,
612            predicates: vec![
613                SchemaPredicate::FieldPresent("eventType".to_string()),
614                SchemaPredicate::FieldPresent("actor".to_string()),
615                SchemaPredicate::FieldPresent("published".to_string()),
616                SchemaPredicate::FieldPresent("outcome".to_string()),
617            ],
618        },
619        // Docker events: `Type` in {container,image,daemon, …} + `Action` +
620        // `Actor` is the canonical Docker CLI --format json event shape.
621        SchemaSignature {
622            name: "docker_events".to_string(),
623            specificity: 70,
624            predicates: vec![
625                SchemaPredicate::FieldPresent("Type".to_string()),
626                SchemaPredicate::FieldPresent("Action".to_string()),
627                SchemaPredicate::FieldPresent("Actor".to_string()),
628            ],
629        },
630        // osquery structured result: `name` (table) + `action` in
631        // {added, removed, snapshot} + `columns` or `snapshot` +
632        // `hostIdentifier` identifies the osquery log format.
633        SchemaSignature {
634            name: "osquery_result".to_string(),
635            specificity: 75,
636            predicates: vec![
637                SchemaPredicate::FieldPresent("name".to_string()),
638                SchemaPredicate::In {
639                    field: "action".to_string(),
640                    values: vec![
641                        "added".to_string(),
642                        "removed".to_string(),
643                        "snapshot".to_string(),
644                    ],
645                },
646                SchemaPredicate::AnyOf(vec!["columns".to_string(), "snapshot".to_string()]),
647                SchemaPredicate::FieldPresent("hostIdentifier".to_string()),
648            ],
649        },
650        // GCP AuditLog: the `@type` discriminator is a single-precision
651        // field that matches exactly the Cloud Audit Log proto type.
652        SchemaSignature {
653            name: "gcp_audit".to_string(),
654            specificity: 95,
655            predicates: vec![SchemaPredicate::Equals {
656                field: "protoPayload.@type".to_string(),
657                value: "type.googleapis.com/google.cloud.audit.AuditLog".to_string(),
658            }],
659        },
660        // Azure Activity Logs: `category` in {Administrative, Policy,
661        // Security} + `resourceId` (/subscriptions/…) + `operationName`.
662        // The subscription path is matched case-insensitively because Azure
663        // emits resource IDs in inconsistent casing across services.
664        SchemaSignature {
665            name: "azure_activitylogs".to_string(),
666            specificity: 90,
667            predicates: vec![
668                SchemaPredicate::In {
669                    field: "category".to_string(),
670                    values: vec![
671                        "Administrative".to_string(),
672                        "Policy".to_string(),
673                        "Security".to_string(),
674                    ],
675                },
676                SchemaPredicate::Matches {
677                    field: "id".to_string(),
678                    regex: regex::Regex::new("(?i)^/subscriptions/")
679                        .expect("Azure resourceId regex"),
680                },
681                SchemaPredicate::FieldPresent("operationName".to_string()),
682            ],
683        },
684        // Azure AuditLogs (Entra): `category: AuditLogs` + `properties` with
685        // `activityDisplayName` — Entra audit log discriminators.
686        SchemaSignature {
687            name: "azure_auditlogs".to_string(),
688            specificity: 90,
689            predicates: vec![
690                SchemaPredicate::Equals {
691                    field: "category".to_string(),
692                    value: "AuditLogs".to_string(),
693                },
694                SchemaPredicate::FieldPresent("properties.activityDisplayName".to_string()),
695            ],
696        },
697        // Azure SignInLogs (Entra): `category: SignInLogs` + `properties`
698        // with `ipAddress`/`userAgent` — Entra sign-in log discriminators.
699        SchemaSignature {
700            name: "azure_signinlogs".to_string(),
701            specificity: 90,
702            predicates: vec![
703                SchemaPredicate::Equals {
704                    field: "category".to_string(),
705                    value: "SignInLogs".to_string(),
706                },
707                SchemaPredicate::FieldPresent("properties.userDisplayName".to_string()),
708            ],
709        },
710        // Azure product-only fallback: when `category` is absent (e.g.
711        // an Azure resource-level event) but a subscription-level `resourceId`
712        // is present, classify as the generic Azure product. Case-insensitive
713        // to match Azure's inconsistent resource-ID casing.
714        SchemaSignature {
715            name: "azure".to_string(),
716            specificity: 65,
717            predicates: vec![SchemaPredicate::Matches {
718                field: "id".to_string(),
719                regex: regex::Regex::new("(?i)^/subscriptions/")
720                    .expect("Azure subscriptionId regex"),
721            }],
722        },
723        // Microsoft 365 unified audit log (Office 365 Management Activity API
724        // common schema): `RecordType` (int) + `Operation` + `CreationTime` +
725        // `Workload` identify the raw audit feed. SigmaHQ's `service: audit`
726        // rules match these native fields directly, so the feed maps to
727        // `product: m365, service: audit`. The exchange, threat_detection, and
728        // threat_management services use a separately normalized shape
729        // (`eventSource`/`eventName`/`status`, which are not Management
730        // Activity common-schema fields) and would need a normalization
731        // pipeline, so they are intentionally not classified here.
732        SchemaSignature {
733            name: "m365_audit".to_string(),
734            specificity: 88,
735            predicates: vec![
736                SchemaPredicate::FieldPresent("RecordType".to_string()),
737                SchemaPredicate::FieldPresent("Operation".to_string()),
738                SchemaPredicate::FieldPresent("CreationTime".to_string()),
739                SchemaPredicate::FieldPresent("Workload".to_string()),
740            ],
741        },
742        // Generic JSON: any structured event that matched no specific schema.
743        SchemaSignature {
744            name: "generic_json".to_string(),
745            specificity: 0,
746            predicates: vec![SchemaPredicate::HasAnyField],
747        },
748    ]
749}
750
751/// Distinct built-in schema names, ordered by non-increasing specificity
752/// (ties broken by name). Kept in sync with `builtin_signatures` by
753/// `builtin_schema_names_match_signatures` in the test module.
754pub fn builtin_schema_names() -> Vec<&'static str> {
755    vec![
756        // 105 — ECS platform specializations
757        "ecs_linux",
758        "ecs_windows",
759        // 100 — ECS baseline
760        "ecs",
761        // 95
762        "gcp_audit",
763        "ocsf",
764        // 92
765        "github_audit",
766        "k8s_audit",
767        // 90
768        "azure_activitylogs",
769        "azure_auditlogs",
770        "azure_signinlogs",
771        "windows_eventlog",
772        // 88
773        "m365_audit",
774        "okta_system_log",
775        "sysmon",
776        // 85
777        "aws_cloudtrail",
778        "cef",
779        "onelogin_events",
780        // 80
781        "aws_vpcflow",
782        // 75
783        "osquery_result",
784        // 70
785        "docker_events",
786        // 65 — Azure product-only fallback
787        "azure",
788        // 0 — generic JSON catch-all
789        "generic_json",
790    ]
791}
792
793/// Built-in schema aliases: a specialized schema that routes as another schema.
794///
795/// `ecs_windows` and `ecs_linux` are ECS specializations that carry a platform
796/// (and thus an implied logsource for pruning) but route as `ecs`, so an
797/// existing `ecs` binding still matches them.
798fn builtin_schema_aliases() -> HashMap<String, String> {
799    HashMap::from([
800        ("ecs_windows".to_string(), "ecs".to_string()),
801        ("ecs_linux".to_string(), "ecs".to_string()),
802    ])
803}
804
805// =============================================================================
806// User-supplied signatures (YAML config)
807// =============================================================================
808
809/// Errors raised while loading user schema signatures.
810#[derive(Debug, thiserror::Error)]
811pub enum SchemaError {
812    /// The signatures file could not be read.
813    #[error("cannot read schema signatures file '{path}': {source}")]
814    Io {
815        path: String,
816        #[source]
817        source: std::io::Error,
818    },
819    /// The signatures YAML failed to parse.
820    #[error("schema signatures YAML parse error: {0}")]
821    Parse(String),
822    /// A predicate carried an invalid regular expression.
823    #[error("invalid regex in schema '{name}': {error}")]
824    InvalidRegex { name: String, error: String },
825}
826
827/// A `{ field: ..., value: ... }` pair used by the `equals` and `matches`
828/// predicate forms.
829#[derive(Debug, Clone, Deserialize)]
830#[serde(deny_unknown_fields)]
831pub struct FieldValueConfig {
832    pub field: String,
833    pub value: String,
834}
835
836/// A `{ field: ..., value: <number> }` pair used by the numeric comparison
837/// predicate forms (`gt`, `gte`, `lt`, `lte`).
838#[derive(Debug, Clone, Deserialize)]
839#[serde(deny_unknown_fields)]
840pub struct FieldNumberConfig {
841    pub field: String,
842    pub value: f64,
843}
844
845/// A `{ field: ..., values: [...] }` pair used by the `in` predicate form.
846#[derive(Debug, Clone, Deserialize)]
847#[serde(deny_unknown_fields)]
848pub struct FieldValuesConfig {
849    pub field: String,
850    pub values: Vec<String>,
851}
852
853/// A `{ left: ..., right: ... }` pair used by the `field_equals_field` form.
854#[derive(Debug, Clone, Deserialize)]
855#[serde(deny_unknown_fields)]
856pub struct FieldPairConfig {
857    pub left: String,
858    pub right: String,
859}
860
861/// A predicate as written in YAML: a single-key map, for example
862/// `field_present: ecs.version` or `equals: { field: type, value: alert }`.
863/// Exactly one form must be set per list entry. The `not`/`any`/`all` group
864/// forms nest predicate lists to express OR and NOT within one signature.
865#[derive(Debug, Clone, Default, Deserialize)]
866#[serde(deny_unknown_fields)]
867pub struct SchemaPredicateConfig {
868    /// `field_present: <field>`
869    #[serde(default)]
870    pub field_present: Option<String>,
871    /// `field_absent: <field>`
872    #[serde(default)]
873    pub field_absent: Option<String>,
874    /// `any_of: [<field>, ...]`
875    #[serde(default)]
876    pub any_of: Option<Vec<String>>,
877    /// `equals: { field: <field>, value: <value> }`
878    #[serde(default)]
879    pub equals: Option<FieldValueConfig>,
880    /// `matches: { field: <field>, value: <regex> }`
881    #[serde(default)]
882    pub matches: Option<FieldValueConfig>,
883    /// `gt: { field: <field>, value: <number> }`
884    #[serde(default)]
885    pub gt: Option<FieldNumberConfig>,
886    /// `gte: { field: <field>, value: <number> }`
887    #[serde(default)]
888    pub gte: Option<FieldNumberConfig>,
889    /// `lt: { field: <field>, value: <number> }`
890    #[serde(default)]
891    pub lt: Option<FieldNumberConfig>,
892    /// `lte: { field: <field>, value: <number> }`
893    #[serde(default)]
894    pub lte: Option<FieldNumberConfig>,
895    /// `in: { field: <field>, values: [...] }`
896    #[serde(default, rename = "in")]
897    pub in_set: Option<FieldValuesConfig>,
898    /// `field_equals_field: { left: <field>, right: <field> }`
899    #[serde(default)]
900    pub field_equals_field: Option<FieldPairConfig>,
901    /// `not: <predicate>`
902    #[serde(default)]
903    pub not: Option<Box<SchemaPredicateConfig>>,
904    /// `any: [<predicate>, ...]`
905    #[serde(default)]
906    pub any: Option<Vec<SchemaPredicateConfig>>,
907    /// `all: [<predicate>, ...]`
908    #[serde(default)]
909    pub all: Option<Vec<SchemaPredicateConfig>>,
910}
911
912impl SchemaPredicateConfig {
913    fn build(self, schema_name: &str) -> Result<SchemaPredicate, SchemaError> {
914        let mut chosen: Option<SchemaPredicate> = None;
915        let mut set = 0u32;
916        if let Some(f) = self.field_present {
917            set += 1;
918            chosen = Some(SchemaPredicate::FieldPresent(f));
919        }
920        if let Some(f) = self.field_absent {
921            set += 1;
922            chosen = Some(SchemaPredicate::FieldAbsent(f));
923        }
924        if let Some(fields) = self.any_of {
925            set += 1;
926            chosen = Some(SchemaPredicate::AnyOf(fields));
927        }
928        if let Some(fv) = self.equals {
929            set += 1;
930            chosen = Some(SchemaPredicate::Equals {
931                field: fv.field,
932                value: fv.value,
933            });
934        }
935        if let Some(fv) = self.matches {
936            set += 1;
937            chosen = Some(SchemaPredicate::Matches {
938                field: fv.field,
939                regex: Regex::new(&fv.value).map_err(|e| SchemaError::InvalidRegex {
940                    name: schema_name.to_string(),
941                    error: e.to_string(),
942                })?,
943            });
944        }
945        for (op, cfg) in [
946            (CompareOp::Gt, self.gt),
947            (CompareOp::Gte, self.gte),
948            (CompareOp::Lt, self.lt),
949            (CompareOp::Lte, self.lte),
950        ] {
951            if let Some(fv) = cfg {
952                set += 1;
953                chosen = Some(SchemaPredicate::Compare {
954                    field: fv.field,
955                    op,
956                    value: fv.value,
957                });
958            }
959        }
960        if let Some(fv) = self.in_set {
961            set += 1;
962            chosen = Some(SchemaPredicate::In {
963                field: fv.field,
964                values: fv.values,
965            });
966        }
967        if let Some(fp) = self.field_equals_field {
968            set += 1;
969            chosen = Some(SchemaPredicate::FieldEqualsField {
970                left: fp.left,
971                right: fp.right,
972            });
973        }
974        if let Some(inner) = self.not {
975            set += 1;
976            chosen = Some(SchemaPredicate::Not(Box::new(inner.build(schema_name)?)));
977        }
978        if let Some(list) = self.any {
979            set += 1;
980            chosen = Some(SchemaPredicate::Any(build_group(list, schema_name, "any")?));
981        }
982        if let Some(list) = self.all {
983            set += 1;
984            chosen = Some(SchemaPredicate::All(build_group(list, schema_name, "all")?));
985        }
986        match (set, chosen) {
987            (1, Some(p)) => Ok(p),
988            (0, _) => Err(SchemaError::Parse(format!(
989                "schema '{schema_name}': a predicate has no condition (expected one of \
990                 field_present, field_absent, any_of, equals, matches, gt, gte, lt, lte, \
991                 in, field_equals_field, not, any, all)"
992            ))),
993            _ => Err(SchemaError::Parse(format!(
994                "schema '{schema_name}': a predicate sets multiple conditions; use one per list item"
995            ))),
996        }
997    }
998}
999
1000/// Build a non-empty list of sub-predicates for the `any`/`all` group forms.
1001fn build_group(
1002    list: Vec<SchemaPredicateConfig>,
1003    schema_name: &str,
1004    kind: &str,
1005) -> Result<Vec<SchemaPredicate>, SchemaError> {
1006    if list.is_empty() {
1007        return Err(SchemaError::Parse(format!(
1008            "schema '{schema_name}': '{kind}' needs at least one sub-predicate"
1009        )));
1010    }
1011    list.into_iter().map(|p| p.build(schema_name)).collect()
1012}
1013
1014/// A signature as written in YAML.
1015#[derive(Debug, Clone, Deserialize)]
1016pub struct SchemaSignatureConfig {
1017    /// Schema label reported on a match.
1018    pub name: String,
1019    /// Tie-breaking weight (default 50, above `generic_json` and below the
1020    /// strong built-ins by default).
1021    #[serde(default = "default_user_specificity")]
1022    pub specificity: u32,
1023    /// Conditions that must all hold.
1024    #[serde(default, rename = "match")]
1025    pub predicates: Vec<SchemaPredicateConfig>,
1026}
1027
1028fn default_user_specificity() -> u32 {
1029    50
1030}
1031
1032/// Top-level YAML document holding a `schemas:` list and an optional
1033/// `routing:` section.
1034#[derive(Debug, Clone, Default, Deserialize)]
1035pub struct SchemaSignaturesFile {
1036    #[serde(default)]
1037    pub schemas: Vec<SchemaSignatureConfig>,
1038    #[serde(default)]
1039    pub routing: Option<RoutingConfig>,
1040}
1041
1042impl SchemaSignatureConfig {
1043    fn build(self) -> Result<SchemaSignature, SchemaError> {
1044        let name = self.name;
1045        let predicates = self
1046            .predicates
1047            .into_iter()
1048            .map(|p| p.build(&name))
1049            .collect::<Result<Vec<_>, _>>()?;
1050        Ok(SchemaSignature {
1051            name,
1052            predicates,
1053            specificity: self.specificity,
1054        })
1055    }
1056}
1057
1058/// Parse user schema signatures from a YAML string.
1059pub fn parse_schema_signatures(yaml: &str) -> Result<Vec<SchemaSignature>, SchemaError> {
1060    let file: SchemaSignaturesFile =
1061        yaml_serde::from_str(yaml).map_err(|e| SchemaError::Parse(e.to_string()))?;
1062    file.schemas.into_iter().map(|s| s.build()).collect()
1063}
1064
1065/// Load user schema signatures from a YAML file path.
1066pub fn load_schema_signatures(path: &Path) -> Result<Vec<SchemaSignature>, SchemaError> {
1067    let content = fs::read_to_string(path).map_err(|e| SchemaError::Io {
1068        path: path.display().to_string(),
1069        source: e,
1070    })?;
1071    parse_schema_signatures(&content)
1072}
1073
1074/// Parse both the user signatures and the optional routing section from a YAML
1075/// string.
1076pub fn parse_schema_config(
1077    yaml: &str,
1078) -> Result<(Vec<SchemaSignature>, Option<RoutingConfig>), SchemaError> {
1079    let file: SchemaSignaturesFile =
1080        yaml_serde::from_str(yaml).map_err(|e| SchemaError::Parse(e.to_string()))?;
1081    let signatures = file
1082        .schemas
1083        .into_iter()
1084        .map(|s| s.build())
1085        .collect::<Result<Vec<_>, _>>()?;
1086    Ok((signatures, file.routing))
1087}
1088
1089/// Load both the user signatures and the optional routing section from a YAML
1090/// file path.
1091pub fn load_schema_config(
1092    path: &Path,
1093) -> Result<(Vec<SchemaSignature>, Option<RoutingConfig>), SchemaError> {
1094    let content = fs::read_to_string(path).map_err(|e| SchemaError::Io {
1095        path: path.display().to_string(),
1096        source: e,
1097    })?;
1098    parse_schema_config(&content)
1099}
1100
1101/// Validate a parsed schema config for common authoring mistakes, returning a
1102/// list of human-readable findings (empty means clean). Static checks only, no
1103/// event data:
1104///
1105/// - duplicate user signatures (same name and identical predicates);
1106/// - unreachable signatures shadowed by a strictly-higher-specificity
1107///   signature whose predicates are a subset (so the shadowed one can never be
1108///   the top match);
1109/// - routing bindings referencing a schema no signature can produce;
1110/// - duplicate routing bindings for the same schema.
1111///
1112/// Pipeline-name resolvability is checked by the caller (the CLI), which owns
1113/// pipeline resolution.
1114pub fn validate_schema_config(
1115    user_signatures: &[SchemaSignature],
1116    routing: Option<&RoutingConfig>,
1117) -> Vec<String> {
1118    let mut findings = Vec::new();
1119
1120    // The full effective signature set (built-ins plus user).
1121    let mut all = builtin_signatures();
1122    all.extend(user_signatures.iter().cloned());
1123    let preds = |s: &SchemaSignature| -> Vec<String> {
1124        s.predicates.iter().map(|p| p.describe()).collect()
1125    };
1126
1127    // Duplicate user signatures (same name, identical predicate set).
1128    for i in 0..user_signatures.len() {
1129        for j in (i + 1)..user_signatures.len() {
1130            if user_signatures[i].name == user_signatures[j].name
1131                && preds(&user_signatures[i]) == preds(&user_signatures[j])
1132            {
1133                findings.push(format!(
1134                    "duplicate signature '{}' with identical predicates",
1135                    user_signatures[i].name
1136                ));
1137            }
1138        }
1139    }
1140
1141    // Unreachable (shadowed) signatures.
1142    for b in &all {
1143        let b_preds = preds(b);
1144        for a in &all {
1145            if a.name != b.name
1146                && a.specificity > b.specificity
1147                && !a.predicates.is_empty()
1148                && preds(a).iter().all(|p| b_preds.contains(p))
1149            {
1150                findings.push(format!(
1151                    "signature '{}' (specificity {}) is unreachable: shadowed by '{}' (specificity {}) whose predicates are a subset",
1152                    b.name, b.specificity, a.name, a.specificity
1153                ));
1154                break;
1155            }
1156        }
1157    }
1158
1159    // Routing binding checks.
1160    if let Some(routing) = routing {
1161        let mut known: std::collections::HashSet<&str> =
1162            builtin_schema_names().into_iter().collect();
1163        for s in user_signatures {
1164            known.insert(s.name.as_str());
1165        }
1166        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
1167        for binding in &routing.bindings {
1168            if !known.contains(binding.schema.as_str()) {
1169                findings.push(format!(
1170                    "routing binding references unknown schema '{}' (no built-in or user signature produces it)",
1171                    binding.schema
1172                ));
1173            }
1174            if !seen.insert(binding.schema.as_str()) {
1175                findings.push(format!(
1176                    "duplicate routing binding for schema '{}'",
1177                    binding.schema
1178                ));
1179            }
1180        }
1181        for (alias, canonical) in &routing.aliases {
1182            if !known.contains(canonical.as_str()) {
1183                findings.push(format!(
1184                    "alias '{alias}' targets unknown schema '{canonical}' (no built-in or user signature produces it)"
1185                ));
1186            }
1187        }
1188    }
1189
1190    findings
1191}
1192
1193// =============================================================================
1194// Routing: schema -> pipeline-set bindings and the dispatch plan
1195// =============================================================================
1196
1197/// What to do with an event whose schema matched no signature.
1198#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
1199#[serde(rename_all = "snake_case")]
1200pub enum OnUnknown {
1201    /// Evaluate against the default pipeline-set and log a warning.
1202    #[default]
1203    Warn,
1204    /// Drop the event without evaluating.
1205    Drop,
1206    /// Evaluate against the default pipeline-set without logging.
1207    Passthrough,
1208    /// Drop the event and flag it as an error (non-zero exit / error counter).
1209    Error,
1210}
1211
1212/// The logsource a recognized schema implies, used to fill gaps in an event's
1213/// logsource for conflict-based pruning when the event carries no explicit
1214/// `product`/`service`/`category` field.
1215#[derive(Debug, Clone, Default, Deserialize)]
1216#[serde(deny_unknown_fields)]
1217pub struct SchemaLogsource {
1218    #[serde(default)]
1219    pub product: Option<String>,
1220    #[serde(default)]
1221    pub service: Option<String>,
1222    #[serde(default)]
1223    pub category: Option<String>,
1224    #[serde(default)]
1225    pub custom: HashMap<String, String>,
1226}
1227
1228impl SchemaLogsource {
1229    fn to_logsource(&self) -> LogSource {
1230        LogSource {
1231            product: self.product.clone(),
1232            service: self.service.clone(),
1233            category: self.category.clone(),
1234            custom: self.custom.clone(),
1235            ..LogSource::default()
1236        }
1237    }
1238}
1239
1240/// A `schema -> pipelines` binding: events recognized as `schema` are
1241/// evaluated against the engine built from `pipelines`.
1242#[derive(Debug, Clone, Deserialize)]
1243pub struct SchemaBinding {
1244    pub schema: String,
1245    /// Pipeline names or file paths, resolved by the caller.
1246    #[serde(default)]
1247    pub pipelines: Vec<String>,
1248    /// Optional logsource this schema implies. Overrides any built-in default
1249    /// for the schema and fills gaps in an event's logsource at pruning time.
1250    #[serde(default)]
1251    pub logsource: Option<SchemaLogsource>,
1252}
1253
1254/// Built-in schema-to-logsource defaults for the platform-locked schemas.
1255///
1256/// Only schemas that unambiguously imply a platform are listed. The plain
1257/// cross-platform schemas (`ecs`, `ocsf`, `cef`, `generic_json`) are omitted:
1258/// they must not imply a product, since doing so would prune correct rules for
1259/// the other platforms those schemas also carry. The `ecs_windows` and
1260/// `ecs_linux` specializations do carry a platform (and route as `ecs` via
1261/// `builtin_schema_aliases`).
1262/// Built-in schema-to-logsource mapping for schemas that carry an implied
1263/// product/service (or custom dimensions for off-taxonomy sources).
1264/// Testable via the public API: callers who need the map can inspect it
1265/// to verify that every signature name they recognize also has a logsource.
1266pub fn builtin_schema_logsource() -> HashMap<String, LogSource> {
1267    fn ls(product: &str, service: Option<&str>) -> LogSource {
1268        LogSource {
1269            product: Some(product.to_string()),
1270            service: service.map(str::to_string),
1271            ..LogSource::default()
1272        }
1273    }
1274    fn ls_custom(product: Option<&str>, custom: HashMap<&str, String>) -> LogSource {
1275        LogSource {
1276            product: product.map(str::to_string),
1277            service: None,
1278            category: None,
1279            custom: custom
1280                .into_iter()
1281                .map(|(k, v)| (k.to_string(), v))
1282                .collect(),
1283            ..LogSource::default()
1284        }
1285    }
1286    let mut map = HashMap::new();
1287
1288    // Windows (already shipped)
1289    map.insert("sysmon".to_string(), ls("windows", Some("sysmon")));
1290    map.insert("windows_eventlog".to_string(), ls("windows", None));
1291    map.insert("ecs_windows".to_string(), ls("windows", None));
1292    map.insert("ecs_linux".to_string(), ls("linux", None));
1293
1294    // AWS
1295    map.insert("aws_cloudtrail".to_string(), ls("aws", Some("cloudtrail")));
1296    // VPC Flow Logs: on-taxonomy AWS product + custom source dimension.
1297    map.insert(
1298        "aws_vpcflow".to_string(),
1299        ls_custom(
1300            Some("aws"),
1301            HashMap::from([("source", "vpcflow".to_string())]),
1302        ),
1303    );
1304
1305    // Azure (Entra / Microsoft 365 platform)
1306    map.insert(
1307        "azure_activitylogs".to_string(),
1308        ls("azure", Some("activitylogs")),
1309    );
1310    map.insert(
1311        "azure_auditlogs".to_string(),
1312        ls("azure", Some("auditlogs")),
1313    );
1314    map.insert(
1315        "azure_signinlogs".to_string(),
1316        ls("azure", Some("signinlogs")),
1317    );
1318
1319    // GCP
1320    map.insert("gcp_audit".to_string(), ls("gcp", Some("gcp.audit")));
1321
1322    // Microsoft 365 / Entra unified audit log
1323    map.insert("m365_audit".to_string(), ls("m365", Some("audit")));
1324
1325    // SaaS / Identity
1326    map.insert("github_audit".to_string(), ls("github", Some("audit")));
1327    map.insert("okta_system_log".to_string(), ls("okta", Some("okta")));
1328    map.insert(
1329        "onelogin_events".to_string(),
1330        ls("onelogin", Some("onelogin.events")),
1331    );
1332
1333    // Container / Endpoint (off-taxonomy — custom dimensions)
1334    map.insert(
1335        "k8s_audit".to_string(),
1336        ls_custom(
1337            None,
1338            HashMap::from([
1339                ("platform", "kubernetes".to_string()),
1340                ("source", "k8s.audit".to_string()),
1341            ]),
1342        ),
1343    );
1344    map.insert(
1345        "docker_events".to_string(),
1346        ls_custom(
1347            None,
1348            HashMap::from([
1349                ("platform", "docker".to_string()),
1350                ("source", "docker.events".to_string()),
1351            ]),
1352        ),
1353    );
1354    map.insert(
1355        "osquery_result".to_string(),
1356        ls_custom(
1357            None,
1358            HashMap::from([
1359                ("platform", "osquery".to_string()),
1360                ("source", "osquery.result".to_string()),
1361            ]),
1362        ),
1363    );
1364
1365    map
1366}
1367
1368/// The `routing:` section of a schema config file.
1369#[derive(Debug, Clone, Default, Deserialize)]
1370pub struct RoutingConfig {
1371    #[serde(default)]
1372    pub on_unknown: OnUnknown,
1373    #[serde(default)]
1374    pub bindings: Vec<SchemaBinding>,
1375    /// Pipelines applied to known-but-unbound schemas and to the
1376    /// unknown-fallback path. Empty means "rules with no pipeline".
1377    #[serde(default)]
1378    pub default_pipelines: Vec<String>,
1379    /// User-defined schema aliases (`schema -> canonical schema`): an event
1380    /// classified as an alias routes as though it were the canonical schema,
1381    /// so one binding covers a family of related schemas. Merged over the
1382    /// built-in `ecs_windows`/`ecs_linux` -> `ecs` aliases.
1383    #[serde(default)]
1384    pub aliases: HashMap<String, String>,
1385}
1386
1387/// The decision for one event, produced by [`RoutingPlan::decide`].
1388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1389pub enum RouteDecision {
1390    /// Evaluate against the pipeline-set at this index. `unknown` is true when
1391    /// the event matched no signature and fell through to the default set.
1392    Evaluate { set: usize, unknown: bool },
1393    /// Drop the event without evaluating (`on_unknown: drop`).
1394    Drop,
1395    /// Drop and flag as an error (`on_unknown: error`).
1396    Error,
1397}
1398
1399/// A resolved routing plan: the deduplicated pipeline-sets to build one engine
1400/// each, plus the schema-to-set mapping and the unknown-handling policy.
1401///
1402/// Pure data: it decides *which* pipeline-set an event routes to, leaving the
1403/// engine construction and dispatch to the caller. The default set (index 0)
1404/// is always present, so there is always a fallback target.
1405#[derive(Debug, Clone)]
1406pub struct RoutingPlan {
1407    /// Deduplicated pipeline-sets. Index 0 is always the default set.
1408    pipeline_sets: Vec<Vec<String>>,
1409    /// Recognized schema name -> pipeline-set index.
1410    schema_to_set: HashMap<String, usize>,
1411    /// Recognized schema name -> the logsource it implies (built-in defaults
1412    /// plus per-binding overrides). Used to fill gaps in an event's logsource
1413    /// for conflict-based pruning.
1414    schema_logsource: HashMap<String, LogSource>,
1415    /// Schema aliases (`schema -> canonical schema`): built-in ECS platform
1416    /// specializations plus any from the config. An aliased schema routes as
1417    /// its canonical when the canonical is bound and the alias itself is not.
1418    aliases: HashMap<String, String>,
1419    on_unknown: OnUnknown,
1420}
1421
1422impl RoutingPlan {
1423    /// Build a plan from a routing config, deduplicating identical
1424    /// pipeline-sets so the caller compiles each distinct set once.
1425    pub fn from_config(config: &RoutingConfig) -> Self {
1426        // Index 0 is always the default set.
1427        let mut pipeline_sets: Vec<Vec<String>> = vec![config.default_pipelines.clone()];
1428        let mut schema_to_set: HashMap<String, usize> = HashMap::new();
1429        // Seed the built-in platform-locked defaults, then let bindings
1430        // override or add per-schema logsources.
1431        let mut schema_logsource = builtin_schema_logsource();
1432        // Seed built-in aliases, then merge any from the config.
1433        let mut aliases = builtin_schema_aliases();
1434        for (alias, canonical) in &config.aliases {
1435            aliases.insert(alias.clone(), canonical.clone());
1436        }
1437
1438        for binding in &config.bindings {
1439            let idx = pipeline_sets
1440                .iter()
1441                .position(|s| s == &binding.pipelines)
1442                .unwrap_or_else(|| {
1443                    pipeline_sets.push(binding.pipelines.clone());
1444                    pipeline_sets.len() - 1
1445                });
1446            schema_to_set.insert(binding.schema.clone(), idx);
1447            if let Some(ls) = &binding.logsource {
1448                schema_logsource.insert(binding.schema.clone(), ls.to_logsource());
1449            }
1450        }
1451
1452        RoutingPlan {
1453            pipeline_sets,
1454            schema_to_set,
1455            schema_logsource,
1456            aliases,
1457            on_unknown: config.on_unknown,
1458        }
1459    }
1460
1461    /// The deduplicated pipeline-sets, in index order (set 0 is the default).
1462    /// The caller builds one engine per entry.
1463    pub fn pipeline_sets(&self) -> &[Vec<String>] {
1464        &self.pipeline_sets
1465    }
1466
1467    /// The configured unknown-handling policy.
1468    pub fn on_unknown(&self) -> OnUnknown {
1469        self.on_unknown
1470    }
1471
1472    /// The logsource a recognized schema implies, if any (built-in default or
1473    /// binding override). Used by the router to fill gaps in an event's
1474    /// logsource before conflict-based pruning.
1475    pub fn schema_logsource(&self, schema: &str) -> Option<&LogSource> {
1476        self.schema_logsource.get(schema)
1477    }
1478
1479    /// The recognized schema names that carry an implied logsource (built-in
1480    /// defaults plus binding overrides), sorted for deterministic output.
1481    pub fn schemas_with_logsource(&self) -> Vec<String> {
1482        let mut names: Vec<String> = self.schema_logsource.keys().cloned().collect();
1483        names.sort();
1484        names
1485    }
1486
1487    /// For each pipeline-set index, the set of lowercased products whose rules
1488    /// are safe to keep when partitioning per-schema engines, or `None` to keep
1489    /// the full ruleset.
1490    ///
1491    /// A set is partitionable only when every schema that can route to it
1492    /// (direct bindings plus aliases) implies a product; if any routing schema
1493    /// is product-less (cross-platform), the set keeps all rules. The default
1494    /// set (index 0) is never partitioned, because unbound and unknown events
1495    /// route there and could be any product. Callers still apply their own
1496    /// pipeline-safety check (a product-setting `change_logsource` disables
1497    /// partitioning for that set).
1498    pub fn set_product_partition(&self) -> Vec<Option<std::collections::HashSet<String>>> {
1499        use std::collections::HashSet;
1500        let n = self.pipeline_sets.len();
1501        let mut out: Vec<Option<HashSet<String>>> = (0..n).map(|_| Some(HashSet::new())).collect();
1502        if let Some(first) = out.get_mut(0) {
1503            *first = None;
1504        }
1505
1506        // (set index, schema) pairs: direct bindings, plus aliases whose
1507        // canonical is bound and which are not themselves directly bound.
1508        let mut routes: Vec<(usize, &str)> = self
1509            .schema_to_set
1510            .iter()
1511            .map(|(s, &set)| (set, s.as_str()))
1512            .collect();
1513        for (alias, canonical) in &self.aliases {
1514            if !self.schema_to_set.contains_key(alias)
1515                && let Some(&set) = self.schema_to_set.get(canonical)
1516            {
1517                routes.push((set, alias.as_str()));
1518            }
1519        }
1520
1521        for (set, schema) in routes {
1522            if set == 0 {
1523                continue;
1524            }
1525            let product = self
1526                .schema_logsource
1527                .get(schema)
1528                .and_then(|ls| ls.product.as_deref());
1529            let Some(slot) = out.get_mut(set) else {
1530                continue;
1531            };
1532            match product {
1533                Some(p) => {
1534                    if let Some(products) = slot {
1535                        products.insert(p.to_ascii_lowercase());
1536                    }
1537                }
1538                None => *slot = None,
1539            }
1540        }
1541        out
1542    }
1543
1544    /// Decide how to route an event given its classified schema (or `None`
1545    /// when nothing matched).
1546    pub fn decide(&self, schema: Option<&str>) -> RouteDecision {
1547        match schema {
1548            // Recognized and bound: its own set.
1549            Some(s) if self.schema_to_set.contains_key(s) => RouteDecision::Evaluate {
1550                set: self.schema_to_set[s],
1551                unknown: false,
1552            },
1553            // Recognized but unbound: route as the canonical schema if this is
1554            // an alias whose canonical is bound (for example `ecs_windows` ->
1555            // `ecs`), otherwise the default set. Not flagged unknown.
1556            Some(s)
1557                if self
1558                    .aliases
1559                    .get(s)
1560                    .and_then(|canonical| self.schema_to_set.get(canonical))
1561                    .is_some() =>
1562            {
1563                let canonical = &self.aliases[s];
1564                RouteDecision::Evaluate {
1565                    set: self.schema_to_set[canonical],
1566                    unknown: false,
1567                }
1568            }
1569            // Recognized but unbound: the default set, not flagged unknown.
1570            Some(_) => RouteDecision::Evaluate {
1571                set: 0,
1572                unknown: false,
1573            },
1574            // Unrecognized: per the unknown policy.
1575            None => match self.on_unknown {
1576                OnUnknown::Warn | OnUnknown::Passthrough => RouteDecision::Evaluate {
1577                    set: 0,
1578                    unknown: true,
1579                },
1580                OnUnknown::Drop => RouteDecision::Drop,
1581                OnUnknown::Error => RouteDecision::Error,
1582            },
1583        }
1584    }
1585}
1586
1587// =============================================================================
1588// SchemaObserver: opt-in per-schema counting for reporting
1589// =============================================================================
1590
1591/// One per-schema counter as exposed via [`SchemaObserver::snapshot`].
1592#[derive(Debug, Clone, PartialEq, Eq)]
1593pub struct SchemaCountEntry {
1594    /// Recognized schema name.
1595    pub schema: String,
1596    /// Number of events classified as this schema since the last reset.
1597    pub count: u64,
1598}
1599
1600/// A redacted field-key shape of unknown events, for signature authoring.
1601#[derive(Debug, Clone, PartialEq, Eq)]
1602pub struct UnknownShapeEntry {
1603    /// The sorted, deduplicated field keys of the unknown events (values are
1604    /// never captured, only key names).
1605    pub keys: Vec<String>,
1606    /// Number of unknown events with this exact key shape since the last reset.
1607    pub count: u64,
1608}
1609
1610/// Maximum distinct unknown-event shapes retained, to bound memory.
1611const UNKNOWN_SHAPE_CAP: usize = 200;
1612/// Maximum field keys kept per shape, to bound a single shape's size.
1613const UNKNOWN_SHAPE_MAX_KEYS: usize = 64;
1614
1615/// Immutable snapshot of a [`SchemaObserver`] at one moment.
1616#[derive(Debug, Clone, Default)]
1617pub struct SchemaObservation {
1618    /// Per-schema counts, sorted by descending count then ascending name.
1619    pub by_schema: Vec<SchemaCountEntry>,
1620    /// Events classified into a known schema since the last reset.
1621    pub classified: u64,
1622    /// Events that matched no signature since the last reset.
1623    pub unknown: u64,
1624    /// Events where two different-name signatures tied at the winning
1625    /// specificity since the last reset (the name tie-break decided routing).
1626    pub ambiguous: u64,
1627    /// Redacted field-key shapes of unknown events, most frequent first, to
1628    /// help author signatures for what is currently unrecognized.
1629    pub unknown_shapes: Vec<UnknownShapeEntry>,
1630    /// Redacted field-key shapes of discovery-unrecognized events (no match or
1631    /// `generic_json`), most frequent first. Populated only when the observer's
1632    /// discovery sampler is enabled; the input to schema signature discovery.
1633    pub unrecognized_shapes: Vec<UnknownShapeEntry>,
1634    /// Total events observed since the last reset (`classified + unknown`).
1635    pub events_observed: u64,
1636    /// Lifetime total of classified events, ignoring resets. Monotonic, so it
1637    /// can drive Prometheus counters across observer resets.
1638    pub lifetime_classified: u64,
1639    /// Lifetime total of unknown events, ignoring resets. Monotonic.
1640    pub lifetime_unknown: u64,
1641    /// Lifetime total of ambiguous classifications, ignoring resets. Monotonic.
1642    pub lifetime_ambiguous: u64,
1643    /// Seconds since the observer was created (or last reset).
1644    pub uptime_seconds: f64,
1645}
1646
1647/// Opt-in counter that classifies each observed event and tallies per-schema
1648/// (and unknown) totals. Mirrors the design of [`FieldObserver`](crate::FieldObserver):
1649/// shared behind an `Arc`, cheap repeated snapshots, monotonic lifetime
1650/// counters for a Prometheus bridge. The schema set is small and bounded, so
1651/// there is no key cap.
1652pub struct SchemaObserver {
1653    classifier: SchemaClassifier,
1654    counts: Mutex<HashMap<String, u64>>,
1655    unknown: AtomicU64,
1656    ambiguous: AtomicU64,
1657    /// Redacted field-key shapes of unknown (no-match) events (bounded by
1658    /// [`UNKNOWN_SHAPE_CAP`]).
1659    unknown_shapes: Mutex<HashMap<Vec<String>, u64>>,
1660    /// Opt-in: when set, also samples the redacted field-key shapes of events
1661    /// that are unrecognized *for discovery purposes* (no match OR the
1662    /// low-specificity `generic_json` catch-all) into
1663    /// [`unrecognized_shapes`](Self::unrecognized_shapes), the input to schema
1664    /// signature discovery. Kept separate from [`Self::unknown_shapes`] so the
1665    /// existing `unknown` semantics are unchanged.
1666    discovery_sampling: bool,
1667    /// Redacted field-key shapes of discovery-unrecognized events (no-match or
1668    /// `generic_json`), populated only when `discovery_sampling` is set.
1669    unrecognized_shapes: Mutex<HashMap<Vec<String>, u64>>,
1670    lifetime_classified: AtomicU64,
1671    lifetime_unknown: AtomicU64,
1672    lifetime_ambiguous: AtomicU64,
1673    start: Mutex<Instant>,
1674}
1675
1676impl SchemaObserver {
1677    /// Create an observer backed by the given classifier (discovery sampling
1678    /// off).
1679    pub fn new(classifier: SchemaClassifier) -> Self {
1680        Self::new_with_discovery(classifier, false)
1681    }
1682
1683    /// Create an observer, optionally enabling the discovery sampler that
1684    /// records redacted shapes of `generic_json` and no-match events for
1685    /// schema signature discovery.
1686    pub fn new_with_discovery(classifier: SchemaClassifier, discovery_sampling: bool) -> Self {
1687        Self {
1688            classifier,
1689            counts: Mutex::new(HashMap::new()),
1690            unknown: AtomicU64::new(0),
1691            ambiguous: AtomicU64::new(0),
1692            unknown_shapes: Mutex::new(HashMap::new()),
1693            discovery_sampling,
1694            unrecognized_shapes: Mutex::new(HashMap::new()),
1695            lifetime_classified: AtomicU64::new(0),
1696            lifetime_unknown: AtomicU64::new(0),
1697            lifetime_ambiguous: AtomicU64::new(0),
1698            start: Mutex::new(Instant::now()),
1699        }
1700    }
1701
1702    /// Whether the discovery sampler (recording unrecognized-event shapes into
1703    /// [`SchemaObservation::unrecognized_shapes`]) is on.
1704    pub fn discovery_sampling(&self) -> bool {
1705        self.discovery_sampling
1706    }
1707
1708    /// Create an observer using the built-in classifier.
1709    pub fn builtin() -> Self {
1710        Self::new(SchemaClassifier::builtin())
1711    }
1712
1713    /// Classify an event and update the counters. Takes `&self` so the
1714    /// observer can be shared behind an `Arc`.
1715    pub fn observe<E: Event + ?Sized>(&self, event: &E) {
1716        let (matched, ambiguous) = self.classifier.classify_with_ambiguity(event);
1717        if ambiguous {
1718            self.ambiguous.fetch_add(1, Ordering::Relaxed);
1719            self.lifetime_ambiguous.fetch_add(1, Ordering::Relaxed);
1720        }
1721        // Sample the shape for discovery when the event is unrecognized for
1722        // discovery purposes: it matched nothing, or only the low-specificity
1723        // `generic_json` catch-all (which is not a real schema).
1724        let discovery_unrecognized = match &matched {
1725            None => true,
1726            Some(m) => m.name == "generic_json",
1727        };
1728        if self.discovery_sampling && discovery_unrecognized {
1729            self.record_unrecognized_shape(event);
1730        }
1731
1732        match matched {
1733            Some(m) => {
1734                self.lifetime_classified.fetch_add(1, Ordering::Relaxed);
1735                let mut counts = self.counts.lock().expect("schema observer mutex poisoned");
1736                *counts.entry(m.name).or_insert(0) += 1;
1737            }
1738            None => {
1739                self.unknown.fetch_add(1, Ordering::Relaxed);
1740                self.lifetime_unknown.fetch_add(1, Ordering::Relaxed);
1741                self.record_unknown_shape(event);
1742            }
1743        }
1744    }
1745
1746    /// Record the redacted field-key shape of one unknown event, capped in both
1747    /// distinct-shape count and per-shape key count.
1748    fn record_unknown_shape<E: Event + ?Sized>(&self, event: &E) {
1749        let mut keys: Vec<String> = event.field_keys().iter().map(|k| k.to_string()).collect();
1750        keys.sort();
1751        keys.dedup();
1752        keys.truncate(UNKNOWN_SHAPE_MAX_KEYS);
1753        let mut shapes = self
1754            .unknown_shapes
1755            .lock()
1756            .expect("schema observer shapes mutex poisoned");
1757        // Only add a new shape when under the cap; always count a known one.
1758        if shapes.contains_key(&keys) || shapes.len() < UNKNOWN_SHAPE_CAP {
1759            *shapes.entry(keys).or_insert(0) += 1;
1760        }
1761    }
1762
1763    /// Record the redacted field-key shape of one discovery-unrecognized event
1764    /// (no match or `generic_json`) into the discovery sampler, capped the same
1765    /// way as [`Self::record_unknown_shape`].
1766    fn record_unrecognized_shape<E: Event + ?Sized>(&self, event: &E) {
1767        let mut keys: Vec<String> = event.field_keys().iter().map(|k| k.to_string()).collect();
1768        keys.sort();
1769        keys.dedup();
1770        keys.truncate(UNKNOWN_SHAPE_MAX_KEYS);
1771        if keys.is_empty() {
1772            return;
1773        }
1774        let mut shapes = self
1775            .unrecognized_shapes
1776            .lock()
1777            .expect("schema observer shapes mutex poisoned");
1778        if shapes.contains_key(&keys) || shapes.len() < UNKNOWN_SHAPE_CAP {
1779            *shapes.entry(keys).or_insert(0) += 1;
1780        }
1781    }
1782
1783    /// Snapshot the current counts, sorted by descending count then name.
1784    pub fn snapshot(&self) -> SchemaObservation {
1785        let counts = self.counts.lock().expect("schema observer mutex poisoned");
1786        let mut by_schema: Vec<SchemaCountEntry> = counts
1787            .iter()
1788            .map(|(schema, count)| SchemaCountEntry {
1789                schema: schema.clone(),
1790                count: *count,
1791            })
1792            .collect();
1793        let classified: u64 = counts.values().sum();
1794        drop(counts);
1795        by_schema.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.schema.cmp(&b.schema)));
1796
1797        let shapes = self
1798            .unknown_shapes
1799            .lock()
1800            .expect("schema observer shapes mutex poisoned");
1801        let mut unknown_shapes: Vec<UnknownShapeEntry> = shapes
1802            .iter()
1803            .map(|(keys, count)| UnknownShapeEntry {
1804                keys: keys.clone(),
1805                count: *count,
1806            })
1807            .collect();
1808        drop(shapes);
1809        unknown_shapes.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.keys.cmp(&b.keys)));
1810
1811        let unrec = self
1812            .unrecognized_shapes
1813            .lock()
1814            .expect("schema observer shapes mutex poisoned");
1815        let mut unrecognized_shapes: Vec<UnknownShapeEntry> = unrec
1816            .iter()
1817            .map(|(keys, count)| UnknownShapeEntry {
1818                keys: keys.clone(),
1819                count: *count,
1820            })
1821            .collect();
1822        drop(unrec);
1823        unrecognized_shapes.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.keys.cmp(&b.keys)));
1824
1825        let unknown = self.unknown.load(Ordering::Relaxed);
1826        SchemaObservation {
1827            by_schema,
1828            classified,
1829            unknown,
1830            ambiguous: self.ambiguous.load(Ordering::Relaxed),
1831            unknown_shapes,
1832            unrecognized_shapes,
1833            // Derived (not a separate counter) so every snapshot is internally
1834            // consistent: a reader that sees `events_observed == N` also sees
1835            // the `classified`/`unknown` reads that sum to N, since each
1836            // observed event increments exactly one of the two.
1837            events_observed: classified + unknown,
1838            lifetime_classified: self.lifetime_classified.load(Ordering::Relaxed),
1839            lifetime_unknown: self.lifetime_unknown.load(Ordering::Relaxed),
1840            lifetime_ambiguous: self.lifetime_ambiguous.load(Ordering::Relaxed),
1841            uptime_seconds: self
1842                .start
1843                .lock()
1844                .expect("schema observer start mutex poisoned")
1845                .elapsed()
1846                .as_secs_f64(),
1847        }
1848    }
1849
1850    /// Reset the since-last-reset counters (lifetime totals are preserved).
1851    /// Returns the previous `(classified, unknown)` pair.
1852    pub fn reset(&self) -> (u64, u64) {
1853        let mut counts = self.counts.lock().expect("schema observer mutex poisoned");
1854        let previous_classified: u64 = counts.values().sum();
1855        counts.clear();
1856        drop(counts);
1857        self.unknown_shapes
1858            .lock()
1859            .expect("schema observer shapes mutex poisoned")
1860            .clear();
1861        self.unrecognized_shapes
1862            .lock()
1863            .expect("schema observer shapes mutex poisoned")
1864            .clear();
1865        let previous_unknown = self.unknown.swap(0, Ordering::Relaxed);
1866        self.ambiguous.store(0, Ordering::Relaxed);
1867        *self
1868            .start
1869            .lock()
1870            .expect("schema observer start mutex poisoned") = Instant::now();
1871        (previous_classified, previous_unknown)
1872    }
1873
1874    /// Lifetime classified total, ignoring resets. Monotonic.
1875    pub fn lifetime_classified(&self) -> u64 {
1876        self.lifetime_classified.load(Ordering::Relaxed)
1877    }
1878
1879    /// Lifetime unknown total, ignoring resets. Monotonic.
1880    pub fn lifetime_unknown(&self) -> u64 {
1881        self.lifetime_unknown.load(Ordering::Relaxed)
1882    }
1883
1884    /// Lifetime ambiguous total, ignoring resets. Monotonic.
1885    pub fn lifetime_ambiguous(&self) -> u64 {
1886        self.lifetime_ambiguous.load(Ordering::Relaxed)
1887    }
1888}
1889
1890#[cfg(test)]
1891mod tests {
1892    use super::*;
1893    use crate::event::JsonEvent;
1894    use serde_json::json;
1895
1896    fn classify(value: &serde_json::Value) -> Option<String> {
1897        SchemaClassifier::builtin()
1898            .classify(&JsonEvent::borrow(value))
1899            .map(|m| m.name)
1900    }
1901
1902    #[test]
1903    fn recognizes_ecs_by_version_marker() {
1904        let v = json!({"ecs": {"version": "8.11.0"}, "process": {"command_line": "whoami"}});
1905        assert_eq!(classify(&v).as_deref(), Some("ecs"));
1906    }
1907
1908    #[test]
1909    fn recognizes_ecs_with_flattened_keys() {
1910        let v = json!({"ecs.version": "8.11.0", "process.command_line": "whoami"});
1911        assert_eq!(classify(&v).as_deref(), Some("ecs"));
1912    }
1913
1914    #[test]
1915    fn recognizes_ocsf_by_class_and_metadata() {
1916        let v = json!({"class_uid": 1001, "category_uid": 1, "metadata": {"version": "1.1.0"}});
1917        assert_eq!(classify(&v).as_deref(), Some("ocsf"));
1918    }
1919
1920    #[test]
1921    fn recognizes_rendered_windows_event_log() {
1922        let v = json!({"Event": {"System": {"EventID": 4688, "Provider": "Microsoft-Windows-Security-Auditing"}}});
1923        assert_eq!(classify(&v).as_deref(), Some("windows_eventlog"));
1924    }
1925
1926    #[test]
1927    fn recognizes_sysmon_by_channel() {
1928        let v = json!({"Channel": "Microsoft-Windows-Sysmon/Operational", "EventID": 1, "Image": "C:/cmd.exe"});
1929        assert_eq!(classify(&v).as_deref(), Some("sysmon"));
1930    }
1931
1932    #[test]
1933    fn recognizes_sysmon_by_provider() {
1934        let v = json!({"Provider_Name": "Microsoft-Windows-Sysmon", "EventID": 3});
1935        assert_eq!(classify(&v).as_deref(), Some("sysmon"));
1936    }
1937
1938    #[test]
1939    fn recognizes_flat_sysmon_by_field_shape() {
1940        let v = json!({"EventID": 1, "ProcessGuid": "{abc}", "CommandLine": "cmd /c whoami"});
1941        assert_eq!(classify(&v).as_deref(), Some("sysmon"));
1942    }
1943
1944    #[test]
1945    fn recognizes_cef_structured_fields() {
1946        let v = json!({"deviceVendor": "Security", "deviceProduct": "IDS", "signatureId": "100", "src": "10.0.0.1"});
1947        assert_eq!(classify(&v).as_deref(), Some("cef"));
1948    }
1949
1950    #[test]
1951    fn falls_back_to_generic_json_for_unrecognized_structured_events() {
1952        let v = json!({"some_vendor_field": "x", "another": 1});
1953        assert_eq!(classify(&v).as_deref(), Some("generic_json"));
1954    }
1955
1956    #[test]
1957    fn fieldless_events_are_unknown() {
1958        // Empty object: no fields, no signature matches (not even generic_json).
1959        assert_eq!(classify(&json!({})), None);
1960        // JSON scalar/array carries no named fields either.
1961        assert_eq!(classify(&json!("just a string")), None);
1962    }
1963
1964    #[test]
1965    fn specificity_prefers_specific_schema_over_generic() {
1966        // Carries both an ECS marker and arbitrary extra fields; ECS wins.
1967        let v = json!({"ecs.version": "8.0.0", "vendor_blob": {"x": 1}});
1968        let cls = SchemaClassifier::builtin();
1969        let m = cls.classify(&JsonEvent::borrow(&v)).unwrap();
1970        assert_eq!(m.name, "ecs");
1971        assert_eq!(m.specificity, 100);
1972        // generic_json is still a candidate, just lower priority.
1973        let all = cls.classify_all(&JsonEvent::borrow(&v));
1974        assert_eq!(all.first().map(String::as_str), Some("ecs"));
1975        assert!(all.iter().any(|n| n == "generic_json"));
1976    }
1977
1978    #[test]
1979    fn schema_names_lists_builtins_most_specific_first() {
1980        let classifier = SchemaClassifier::builtin();
1981        let names = classifier.schema_names();
1982        // The ECS platform specializations (specificity 105) sort ahead of
1983        // plain `ecs` (100); the two 105s tie-break by name (ecs_linux first).
1984        assert_eq!(names.first(), Some(&"ecs_linux"));
1985        assert!(names.contains(&"ecs_windows"));
1986        assert!(names.contains(&"ecs"));
1987        assert!(names.contains(&"generic_json"));
1988        // generic_json is the lowest-specificity, so it sorts last.
1989        assert_eq!(names.last(), Some(&"generic_json"));
1990    }
1991
1992    #[test]
1993    fn ecs_windows_specialization_classifies_and_aliases_to_ecs() {
1994        // An ECS event carrying a Windows marker classifies as the more
1995        // specific ecs_windows, not plain ecs.
1996        let v = json!({"ecs.version": "8.11.0", "winlog": {"channel": "Security"}});
1997        assert_eq!(classify(&v).as_deref(), Some("ecs_windows"));
1998        // A plain ECS event (no platform marker) stays ecs.
1999        let plain = json!({"ecs.version": "8.11.0", "process": {"command_line": "whoami"}});
2000        assert_eq!(classify(&plain).as_deref(), Some("ecs"));
2001
2002        // ecs_windows implies product: windows for pruning, and aliases to ecs
2003        // for routing, so an `ecs` binding matches an ecs_windows event.
2004        let config = RoutingConfig {
2005            on_unknown: OnUnknown::Warn,
2006            default_pipelines: vec![],
2007            aliases: HashMap::new(),
2008            bindings: vec![SchemaBinding {
2009                schema: "ecs".to_string(),
2010                pipelines: vec!["ecs_windows".to_string()],
2011                logsource: None,
2012            }],
2013        };
2014        let plan = RoutingPlan::from_config(&config);
2015        let ecs_set = match plan.decide(Some("ecs")) {
2016            RouteDecision::Evaluate { set, .. } => set,
2017            other => panic!("unexpected: {other:?}"),
2018        };
2019        // ecs_windows routes to the same set as ecs via the built-in alias.
2020        assert_eq!(plan.decide(Some("ecs_windows")), plan.decide(Some("ecs")));
2021        assert_ne!(ecs_set, 0, "ecs binding is a non-default set");
2022        assert_eq!(
2023            plan.schema_logsource("ecs_windows")
2024                .and_then(|l| l.product.as_deref()),
2025            Some("windows")
2026        );
2027    }
2028
2029    #[test]
2030    fn user_alias_routes_as_canonical() {
2031        let yaml = r#"
2032schemas:
2033  - name: my_win
2034    specificity: 70
2035    match:
2036      - field_present: vendor.win_marker
2037routing:
2038  aliases:
2039    my_win: ecs
2040  bindings:
2041    - schema: ecs
2042      pipelines: [ecs_windows]
2043"#;
2044        let (_sigs, routing) = parse_schema_config(yaml).unwrap();
2045        let plan = RoutingPlan::from_config(&routing.expect("routing"));
2046        // my_win aliases to ecs, so it routes to the ecs binding's set.
2047        assert_eq!(plan.decide(Some("my_win")), plan.decide(Some("ecs")));
2048        assert!(matches!(
2049            plan.decide(Some("my_win")),
2050            RouteDecision::Evaluate { unknown: false, .. }
2051        ));
2052    }
2053
2054    #[test]
2055    fn set_product_partition_only_for_platform_locked_sets() {
2056        let config = RoutingConfig {
2057            on_unknown: OnUnknown::Warn,
2058            default_pipelines: vec![],
2059            aliases: HashMap::new(),
2060            bindings: vec![
2061                SchemaBinding {
2062                    schema: "sysmon".to_string(),
2063                    pipelines: vec!["p_sysmon".to_string()],
2064                    logsource: None,
2065                },
2066                SchemaBinding {
2067                    schema: "ecs".to_string(),
2068                    pipelines: vec!["p_ecs".to_string()],
2069                    logsource: None,
2070                },
2071            ],
2072        };
2073        let plan = RoutingPlan::from_config(&config);
2074        let part = plan.set_product_partition();
2075        assert!(part[0].is_none(), "default set is never partitioned");
2076
2077        let set_of = |schema| match plan.decide(Some(schema)) {
2078            RouteDecision::Evaluate { set, .. } => set,
2079            other => panic!("unexpected: {other:?}"),
2080        };
2081        // sysmon set: only windows (platform-locked) -> partitionable.
2082        let sysmon_set = set_of("sysmon");
2083        assert_eq!(
2084            part[sysmon_set].as_ref().map(|s| s.contains("windows")),
2085            Some(true)
2086        );
2087        // ecs set: ecs is cross-platform (no implied product) -> keep all.
2088        assert!(part[set_of("ecs")].is_none());
2089    }
2090
2091    #[test]
2092    fn parses_user_signatures_from_yaml() {
2093        let yaml = r#"
2094schemas:
2095  - name: my_vendor
2096    specificity: 70
2097    match:
2098      - field_present: vendor.product
2099      - equals:
2100          field: event_type
2101          value: alert
2102      - any_of: [a, b]
2103"#;
2104        let sigs = parse_schema_signatures(yaml).expect("parse");
2105        assert_eq!(sigs.len(), 1);
2106        assert_eq!(sigs[0].name, "my_vendor");
2107        assert_eq!(sigs[0].specificity, 70);
2108        assert_eq!(sigs[0].predicates.len(), 3);
2109
2110        let cls = SchemaClassifier::with_user_signatures(sigs);
2111        let v = json!({"vendor": {"product": "X"}, "event_type": "ALERT", "a": 1});
2112        assert_eq!(
2113            cls.classify(&JsonEvent::borrow(&v))
2114                .map(|m| m.name)
2115                .as_deref(),
2116            Some("my_vendor")
2117        );
2118    }
2119
2120    #[test]
2121    fn user_signature_with_invalid_regex_is_rejected() {
2122        let yaml = r#"
2123schemas:
2124  - name: bad
2125    match:
2126      - matches:
2127          field: msg
2128          value: "([unclosed"
2129"#;
2130        let err = parse_schema_signatures(yaml).unwrap_err();
2131        assert!(matches!(err, SchemaError::InvalidRegex { .. }));
2132    }
2133
2134    #[test]
2135    fn user_regex_signature_matches_field_value() {
2136        let yaml = r#"
2137schemas:
2138  - name: cef_raw
2139    specificity: 60
2140    match:
2141      - matches:
2142          field: message
2143          value: "^CEF:\\d"
2144"#;
2145        let sigs = parse_schema_signatures(yaml).expect("parse");
2146        let cls = SchemaClassifier::with_user_signatures(sigs);
2147        let v = json!({"message": "CEF:0|Vendor|Product|1.0|100|Name|9|src=1.2.3.4"});
2148        assert_eq!(
2149            cls.classify(&JsonEvent::borrow(&v))
2150                .map(|m| m.name)
2151                .as_deref(),
2152            Some("cef_raw")
2153        );
2154    }
2155
2156    /// Build a single-signature classifier from a `match:` YAML body.
2157    fn classifier_from_match(match_body: &str) -> SchemaClassifier {
2158        let yaml = format!("schemas:\n  - name: t\n    specificity: 70\n    match:\n{match_body}");
2159        let sigs = parse_schema_signatures(&yaml).expect("parse");
2160        SchemaClassifier::new(sigs)
2161    }
2162
2163    fn matches_t(match_body: &str, event: &serde_json::Value) -> bool {
2164        classifier_from_match(match_body)
2165            .classify(&JsonEvent::borrow(event))
2166            .is_some()
2167    }
2168
2169    #[test]
2170    fn numeric_comparisons() {
2171        let body = "      - gte: { field: EventID, value: 4000 }\n";
2172        assert!(matches_t(body, &json!({"EventID": 4688})));
2173        assert!(matches_t(body, &json!({"EventID": 4000})));
2174        assert!(!matches_t(body, &json!({"EventID": 1})));
2175        // String-coercible numeric values work too.
2176        assert!(matches_t(body, &json!({"EventID": "4688"})));
2177        // A non-numeric field fails closed.
2178        assert!(!matches_t(body, &json!({"EventID": "not-a-number"})));
2179        // lt / gt / lte round out the operators.
2180        assert!(matches_t(
2181            "      - lt: { field: score, value: 10 }\n",
2182            &json!({"score": 9.5})
2183        ));
2184        assert!(matches_t(
2185            "      - gt: { field: score, value: 10 }\n",
2186            &json!({"score": 10.1})
2187        ));
2188    }
2189
2190    #[test]
2191    fn in_set_membership_is_case_insensitive() {
2192        let body = "      - in: { field: event_type, values: [alert, alarm] }\n";
2193        assert!(matches_t(body, &json!({"event_type": "ALERT"})));
2194        assert!(matches_t(body, &json!({"event_type": "alarm"})));
2195        assert!(!matches_t(body, &json!({"event_type": "info"})));
2196        assert!(!matches_t(body, &json!({})));
2197    }
2198
2199    #[test]
2200    fn field_equals_field_compares_two_fields() {
2201        let body = "      - field_equals_field: { left: a, right: b }\n";
2202        assert!(matches_t(body, &json!({"a": "X", "b": "x"})));
2203        assert!(!matches_t(body, &json!({"a": "X", "b": "y"})));
2204        // A missing side fails closed.
2205        assert!(!matches_t(body, &json!({"a": "X"})));
2206    }
2207
2208    #[test]
2209    fn recursive_not_any_all_groups() {
2210        // any: OR of two field-presence predicates.
2211        let any_body = "      - any:\n          - field_present: winlog.channel\n          - equals: { field: host.os.type, value: windows }\n";
2212        assert!(matches_t(
2213            any_body,
2214            &json!({"winlog": {"channel": "Security"}})
2215        ));
2216        assert!(matches_t(
2217            any_body,
2218            &json!({"host": {"os": {"type": "windows"}}})
2219        ));
2220        assert!(!matches_t(any_body, &json!({"unrelated": 1})));
2221
2222        // not: negation of a presence predicate.
2223        let not_body = "      - not: { field_present: ecs.version }\n";
2224        assert!(matches_t(not_body, &json!({"CommandLine": "whoami"})));
2225        assert!(!matches_t(not_body, &json!({"ecs.version": "8.0.0"})));
2226
2227        // all: nested AND, usable under not/any.
2228        let all_body = "      - all:\n          - field_present: a\n          - field_present: b\n";
2229        assert!(matches_t(all_body, &json!({"a": 1, "b": 2})));
2230        assert!(!matches_t(all_body, &json!({"a": 1})));
2231    }
2232
2233    #[test]
2234    fn empty_group_is_rejected() {
2235        let yaml = "schemas:\n  - name: t\n    match:\n      - any: []\n";
2236        let err = parse_schema_signatures(yaml).unwrap_err();
2237        assert!(
2238            matches!(&err, SchemaError::Parse(m) if m.contains("'any' needs at least one")),
2239            "got: {err}"
2240        );
2241    }
2242
2243    #[test]
2244    fn predicate_with_two_conditions_is_rejected() {
2245        let yaml = "schemas:\n  - name: t\n    match:\n      - field_present: a\n        field_absent: b\n";
2246        let err = parse_schema_signatures(yaml).unwrap_err();
2247        assert!(
2248            matches!(&err, SchemaError::Parse(m) if m.contains("multiple conditions")),
2249            "got: {err}"
2250        );
2251    }
2252
2253    #[test]
2254    fn explain_reports_matched_signature() {
2255        let cls = SchemaClassifier::builtin();
2256        let v = json!({"ecs.version": "8.0.0"});
2257        let ex = cls.explain(&JsonEvent::borrow(&v));
2258        assert_eq!(ex.matched.as_deref(), Some("ecs"));
2259        let sig = ex.signature.expect("signature");
2260        assert!(sig.predicates_matched);
2261        assert!(sig.predicates.iter().all(|p| p.matched));
2262    }
2263
2264    #[test]
2265    fn explain_reports_near_miss_for_unknown() {
2266        // Drop generic_json so a structured non-match is genuinely unknown.
2267        let sigs = builtin_signatures()
2268            .into_iter()
2269            .filter(|s| s.name != "generic_json")
2270            .collect();
2271        let cls = SchemaClassifier::new(sigs);
2272        // Sysmon-ish but missing ProcessGuid: unknown, near-miss is sysmon.
2273        let v = json!({"EventID": 1, "Image": "C:/cmd.exe"});
2274        let ex = cls.explain(&JsonEvent::borrow(&v));
2275        assert_eq!(ex.matched, None);
2276        let sig = ex.signature.expect("near-miss");
2277        assert_eq!(sig.name, "sysmon");
2278        assert!(!sig.predicates_matched);
2279        assert!(sig.predicates.iter().any(|p| !p.matched));
2280    }
2281
2282    #[test]
2283    fn validate_flags_unknown_binding_and_shadow() {
2284        let yaml = r#"
2285schemas:
2286  - name: shadowed
2287    specificity: 40
2288    match:
2289      - field_present: ecs.version
2290      - field_present: extra.marker
2291routing:
2292  bindings:
2293    - schema: ecs
2294      pipelines: [ecs_windows]
2295    - schema: nonexistent
2296      pipelines: [x]
2297"#;
2298        let (sigs, routing) = parse_schema_config(yaml).unwrap();
2299        let findings = validate_schema_config(&sigs, routing.as_ref());
2300        assert!(
2301            findings
2302                .iter()
2303                .any(|f| f.contains("unknown schema 'nonexistent'")),
2304            "findings: {findings:?}"
2305        );
2306        // `shadowed` needs ecs.version + extra.marker; the built-in ecs (spec
2307        // 100) needs only ecs.version (a subset), so `shadowed` is unreachable.
2308        assert!(
2309            findings
2310                .iter()
2311                .any(|f| f.contains("'shadowed'") && f.contains("unreachable")),
2312            "findings: {findings:?}"
2313        );
2314    }
2315
2316    #[test]
2317    fn observer_counts_per_schema_and_unknown() {
2318        let observer = SchemaObserver::builtin();
2319        observer.observe(&JsonEvent::borrow(&json!({"ecs.version": "8.0.0"})));
2320        observer.observe(&JsonEvent::borrow(&json!({"ecs.version": "8.1.0"})));
2321        observer.observe(&JsonEvent::borrow(
2322            &json!({"class_uid": 1001, "metadata": {"version": "1.1.0"}}),
2323        ));
2324        observer.observe(&JsonEvent::borrow(&json!({})));
2325
2326        let snap = observer.snapshot();
2327        assert_eq!(snap.events_observed, 4);
2328        assert_eq!(snap.classified, 3);
2329        assert_eq!(snap.unknown, 1);
2330        // Sorted by descending count, so ecs (2) comes first.
2331        assert_eq!(snap.by_schema[0].schema, "ecs");
2332        assert_eq!(snap.by_schema[0].count, 2);
2333        let ocsf = snap.by_schema.iter().find(|e| e.schema == "ocsf").unwrap();
2334        assert_eq!(ocsf.count, 1);
2335    }
2336
2337    #[test]
2338    fn routing_plan_dedups_pipeline_sets() {
2339        let config = RoutingConfig {
2340            on_unknown: OnUnknown::Warn,
2341            default_pipelines: vec![],
2342            aliases: HashMap::new(),
2343            bindings: vec![
2344                SchemaBinding {
2345                    schema: "ecs".to_string(),
2346                    pipelines: vec!["ecs_windows".to_string()],
2347                    logsource: None,
2348                },
2349                SchemaBinding {
2350                    schema: "winlogbeat".to_string(),
2351                    pipelines: vec!["ecs_windows".to_string()],
2352                    logsource: None,
2353                },
2354                SchemaBinding {
2355                    schema: "sysmon".to_string(),
2356                    pipelines: vec!["sysmon".to_string()],
2357                    logsource: None,
2358                },
2359            ],
2360        };
2361        let plan = RoutingPlan::from_config(&config);
2362        // Default set (0) + ecs_windows set + sysmon set = 3 distinct sets.
2363        assert_eq!(plan.pipeline_sets().len(), 3);
2364        // ecs and winlogbeat share the same deduped set.
2365        let ecs = plan.decide(Some("ecs"));
2366        let win = plan.decide(Some("winlogbeat"));
2367        assert_eq!(ecs, win);
2368        assert!(matches!(
2369            ecs,
2370            RouteDecision::Evaluate { unknown: false, .. }
2371        ));
2372        // sysmon is a different set.
2373        assert_ne!(plan.decide(Some("sysmon")), ecs);
2374    }
2375
2376    #[test]
2377    fn routing_decides_bound_unbound_and_unknown() {
2378        let config = RoutingConfig {
2379            on_unknown: OnUnknown::Warn,
2380            default_pipelines: vec![],
2381            aliases: HashMap::new(),
2382            bindings: vec![SchemaBinding {
2383                schema: "ecs".to_string(),
2384                pipelines: vec!["ecs_windows".to_string()],
2385                logsource: None,
2386            }],
2387        };
2388        let plan = RoutingPlan::from_config(&config);
2389        // Bound schema -> its own set, not flagged unknown.
2390        assert!(matches!(
2391            plan.decide(Some("ecs")),
2392            RouteDecision::Evaluate { unknown: false, .. }
2393        ));
2394        // Recognized but unbound -> default set (0), not flagged unknown.
2395        assert_eq!(
2396            plan.decide(Some("cef")),
2397            RouteDecision::Evaluate {
2398                set: 0,
2399                unknown: false
2400            }
2401        );
2402        // Unknown -> default set, flagged unknown (Warn).
2403        assert_eq!(
2404            plan.decide(None),
2405            RouteDecision::Evaluate {
2406                set: 0,
2407                unknown: true
2408            }
2409        );
2410    }
2411
2412    #[test]
2413    fn routing_on_unknown_policies() {
2414        let base = |policy| RoutingConfig {
2415            on_unknown: policy,
2416            default_pipelines: vec![],
2417            aliases: HashMap::new(),
2418            bindings: vec![],
2419        };
2420        assert_eq!(
2421            RoutingPlan::from_config(&base(OnUnknown::Drop)).decide(None),
2422            RouteDecision::Drop
2423        );
2424        assert_eq!(
2425            RoutingPlan::from_config(&base(OnUnknown::Error)).decide(None),
2426            RouteDecision::Error
2427        );
2428        assert_eq!(
2429            RoutingPlan::from_config(&base(OnUnknown::Passthrough)).decide(None),
2430            RouteDecision::Evaluate {
2431                set: 0,
2432                unknown: true
2433            }
2434        );
2435    }
2436
2437    #[test]
2438    fn parses_routing_section_from_yaml() {
2439        let yaml = r#"
2440schemas:
2441  - name: my_vendor
2442    match:
2443      - field_present: vendor.id
2444routing:
2445  on_unknown: drop
2446  default_pipelines: [base]
2447  bindings:
2448    - schema: ecs
2449      pipelines: [ecs_windows]
2450    - schema: my_vendor
2451      pipelines: [vendor_map, base]
2452"#;
2453        let (sigs, routing) = parse_schema_config(yaml).expect("parse");
2454        assert_eq!(sigs.len(), 1);
2455        let routing = routing.expect("routing present");
2456        assert_eq!(routing.on_unknown, OnUnknown::Drop);
2457        assert_eq!(routing.default_pipelines, vec!["base".to_string()]);
2458        assert_eq!(routing.bindings.len(), 2);
2459        let plan = RoutingPlan::from_config(&routing);
2460        // default [base], ecs [ecs_windows], my_vendor [vendor_map, base] = 3.
2461        assert_eq!(plan.pipeline_sets().len(), 3);
2462        assert_eq!(plan.decide(None), RouteDecision::Drop);
2463    }
2464
2465    #[test]
2466    fn schema_logsource_builtin_defaults_and_overrides() {
2467        // Built-in platform-locked defaults apply even without bindings.
2468        let plan = RoutingPlan::from_config(&RoutingConfig::default());
2469        let sysmon = plan.schema_logsource("sysmon").expect("sysmon default");
2470        assert_eq!(sysmon.product.as_deref(), Some("windows"));
2471        assert_eq!(sysmon.service.as_deref(), Some("sysmon"));
2472        assert_eq!(
2473            plan.schema_logsource("windows_eventlog")
2474                .and_then(|l| l.product.as_deref()),
2475            Some("windows")
2476        );
2477        // Cross-platform schemas imply nothing.
2478        assert!(plan.schema_logsource("ecs").is_none());
2479        assert!(plan.schema_logsource("cef").is_none());
2480
2481        // A binding can attach or override a schema's implied logsource.
2482        let yaml = r#"
2483schemas:
2484  - name: ecs_windows
2485    match:
2486      - field_present: ecs.version
2487      - field_present: winlog.channel
2488routing:
2489  bindings:
2490    - schema: ecs_windows
2491      pipelines: [ecs_windows]
2492      logsource:
2493        product: windows
2494    - schema: sysmon
2495      pipelines: [sysmon]
2496      logsource:
2497        product: windows
2498        service: sysmon
2499        custom:
2500          tenant: acme
2501"#;
2502        let (_sigs, routing) = parse_schema_config(yaml).expect("parse");
2503        let plan = RoutingPlan::from_config(&routing.expect("routing"));
2504        assert_eq!(
2505            plan.schema_logsource("ecs_windows")
2506                .and_then(|l| l.product.as_deref()),
2507            Some("windows")
2508        );
2509        let sysmon = plan.schema_logsource("sysmon").expect("sysmon override");
2510        assert_eq!(
2511            sysmon.custom.get("tenant").map(String::as_str),
2512            Some("acme")
2513        );
2514    }
2515
2516    #[test]
2517    fn observer_reset_preserves_lifetime_counters() {
2518        let observer = SchemaObserver::builtin();
2519        observer.observe(&JsonEvent::borrow(&json!({"ecs.version": "8.0.0"})));
2520        observer.observe(&JsonEvent::borrow(&json!({})));
2521        let (classified, unknown) = observer.reset();
2522        assert_eq!(classified, 1);
2523        assert_eq!(unknown, 1);
2524
2525        let snap = observer.snapshot();
2526        assert_eq!(snap.classified, 0);
2527        assert_eq!(snap.unknown, 0);
2528        assert_eq!(snap.events_observed, 0);
2529        // Lifetime totals survive the reset for the Prometheus bridge.
2530        assert_eq!(snap.lifetime_classified, 1);
2531        assert_eq!(snap.lifetime_unknown, 1);
2532    }
2533
2534    #[test]
2535    fn classify_with_ambiguity_flags_equal_specificity_ties() {
2536        // Two different-name signatures at the same specificity that both match.
2537        let sigs = vec![
2538            SchemaSignature {
2539                name: "alpha".to_string(),
2540                specificity: 70,
2541                predicates: vec![SchemaPredicate::FieldPresent("a".to_string())],
2542            },
2543            SchemaSignature {
2544                name: "beta".to_string(),
2545                specificity: 70,
2546                predicates: vec![SchemaPredicate::FieldPresent("a".to_string())],
2547            },
2548        ];
2549        let cls = SchemaClassifier::new(sigs);
2550        let (m, ambiguous) = cls.classify_with_ambiguity(&JsonEvent::borrow(&json!({"a": 1})));
2551        assert!(m.is_some());
2552        assert!(
2553            ambiguous,
2554            "equal-specificity different-name match is ambiguous"
2555        );
2556        // A single match is not ambiguous.
2557        let cls = SchemaClassifier::builtin();
2558        let (_, ambiguous) =
2559            cls.classify_with_ambiguity(&JsonEvent::borrow(&json!({"ecs.version": "8.0.0"})));
2560        assert!(!ambiguous);
2561    }
2562
2563    #[test]
2564    fn observer_records_ambiguity_and_unknown_shapes() {
2565        let sigs = vec![
2566            SchemaSignature {
2567                name: "alpha".to_string(),
2568                specificity: 70,
2569                predicates: vec![SchemaPredicate::FieldPresent("a".to_string())],
2570            },
2571            SchemaSignature {
2572                name: "beta".to_string(),
2573                specificity: 70,
2574                predicates: vec![SchemaPredicate::FieldPresent("a".to_string())],
2575            },
2576        ];
2577        let observer = SchemaObserver::new(SchemaClassifier::new(sigs));
2578        observer.observe(&JsonEvent::borrow(&json!({"a": 1}))); // ambiguous, classified
2579        observer.observe(&JsonEvent::borrow(&json!({"weird": 1, "shape": 2}))); // unknown
2580        observer.observe(&JsonEvent::borrow(&json!({"shape": 3, "weird": 4}))); // same shape
2581
2582        let snap = observer.snapshot();
2583        assert_eq!(snap.ambiguous, 1);
2584        assert_eq!(snap.unknown, 2);
2585        // Both unknown events share one redacted key shape [shape, weird].
2586        assert_eq!(snap.unknown_shapes.len(), 1);
2587        assert_eq!(snap.unknown_shapes[0].count, 2);
2588        assert_eq!(snap.unknown_shapes[0].keys, vec!["shape", "weird"]);
2589    }
2590
2591    // ─────────────────────────────────────────────────────────────────────
2592    // Cloud specificity ordering tests
2593    // ─────────────────────────────────────────────────────────────────────
2594
2595    #[test]
2596    fn cloud_signatures_have_higher_specificity_than_generic_json() {
2597        // Every cloud-specific signature must outrank generic_json (0).
2598        let cls = SchemaClassifier::builtin();
2599        let names = cls.schema_names();
2600        assert_eq!(names.first(), Some(&"ecs_linux"));
2601        // generic_json is the last (lowest specificity)
2602        assert_eq!(names.last(), Some(&"generic_json"));
2603        // The cloud schemas should appear before generic_json
2604        let cloud_order: Vec<&str> = vec![
2605            "gcp_audit",
2606            "aws_cloudtrail",
2607            "azure_signinlogs",
2608            "github_audit",
2609            "k8s_audit",
2610            "aws_vpcflow",
2611            "docker_events",
2612            "osquery_result",
2613        ];
2614        let cloud_indices: Vec<usize> = cloud_order
2615            .iter()
2616            .filter_map(|&n| names.iter().position(|&x| x == n))
2617            .collect();
2618        if cloud_indices.len() == cloud_order.len() {
2619            // generic_json (last index) must be after all cloud schemas
2620            let max_cloud = cloud_indices.iter().max().copied().unwrap_or(0);
2621            let generic_idx = names
2622                .last()
2623                .copied()
2624                .map(|n| names.iter().position(|&x| x == n).unwrap_or(0))
2625                .unwrap_or(0);
2626            assert!(
2627                generic_idx > max_cloud,
2628                "generic_json ({}) should be after all cloud schemas, but cloud max = {max_cloud}",
2629                names[generic_idx]
2630            );
2631        }
2632    }
2633
2634    #[test]
2635    fn cloud_signatures_dont_shadow_each_other() {
2636        // An event matching a more-specific cloud signature must not also be
2637        // classified as a less-specific sibling. Build events that exercise
2638        // each cloud signature and verify exact classification.
2639
2640        // GCP Audit: @type discriminator alone is sufficient.
2641        let gcp =
2642            json!({"protoPayload": {"@type": "type.googleapis.com/google.cloud.audit.AuditLog"}});
2643        assert_eq!(
2644            SchemaClassifier::builtin()
2645                .classify(&JsonEvent::borrow(&gcp))
2646                .as_ref()
2647                .map(|m| m.name.as_str()),
2648            Some("gcp_audit")
2649        );
2650
2651        // AWS CloudTrail: needs eventVersion + eventSource + eventID + userIdentity
2652        let cloudtrail = json!({"eventVersion": "1.05", "eventSource": "s3.amazonaws.com", "eventID": "abc", "userIdentity": {"type": "IAMUser"}});
2653        assert_eq!(
2654            SchemaClassifier::builtin()
2655                .classify(&JsonEvent::borrow(&cloudtrail))
2656                .as_ref()
2657                .map(|m| m.name.as_str()),
2658            Some("aws_cloudtrail")
2659        );
2660
2661        // GitHub Audit: needs action + actor + one of (org, repo) + one of (created_at, _document_id)
2662        let github = json!({"action": "repo.create", "actor": "admin", "org": {"id": 123}, "created_at": "2024-01-01T00:00:00Z"});
2663        assert_eq!(
2664            SchemaClassifier::builtin()
2665                .classify(&JsonEvent::borrow(&github))
2666                .as_ref()
2667                .map(|m| m.name.as_str()),
2668            Some("github_audit")
2669        );
2670
2671        // K8s Audit: kind + apiVersion regex + auditID
2672        let k8s = json!({"kind": "Event", "apiVersion": "audit.k8s.io/v1", "auditID": "abc"});
2673        assert_eq!(
2674            SchemaClassifier::builtin()
2675                .classify(&JsonEvent::borrow(&k8s))
2676                .as_ref()
2677                .map(|m| m.name.as_str()),
2678            Some("k8s_audit")
2679        );
2680
2681        // Azure ActivityLogs: category + resourceId + operationName
2682        let azure_act = json!({"category": "Administrative", "id": "/SUBSCRIPTIONS/abc", "operationName": {"value": "test"}});
2683        assert_eq!(
2684            SchemaClassifier::builtin()
2685                .classify(&JsonEvent::borrow(&azure_act))
2686                .as_ref()
2687                .map(|m| m.name.as_str()),
2688            Some("azure_activitylogs")
2689        );
2690
2691        // M365 unified audit log: RecordType + Operation + CreationTime + Workload
2692        let m365 = json!({"RecordType": 15, "Workload": "AzureActiveDirectory", "Operation": "UserLoggedIn", "CreationTime": "2024-01-01T00:00:00Z"});
2693        assert_eq!(
2694            SchemaClassifier::builtin()
2695                .classify(&JsonEvent::borrow(&m365))
2696                .as_ref()
2697                .map(|m| m.name.as_str()),
2698            Some("m365_audit")
2699        );
2700    }
2701
2702    #[test]
2703    fn off_taxonomy_signatures_use_custom_logsource() {
2704        // Off-taxonomy schemas (k8s, docker, osquery, vpcflow) must have
2705        // custom dimensions rather than a product/service pair.
2706        let map = builtin_schema_logsource();
2707
2708        for schema in [
2709            "k8s_audit",
2710            "docker_events",
2711            "osquery_result",
2712            "aws_vpcflow",
2713        ] {
2714            let ls = map
2715                .get(schema)
2716                .unwrap_or_else(|| panic!("logsource mapping for {schema}"));
2717            // For k8s, docker, osquery: no product
2718            if schema != "aws_vpcflow" {
2719                assert!(
2720                    ls.product.is_none(),
2721                    "{schema} must not have a product (off-taxonomy uses custom only)"
2722                );
2723            }
2724            // All should have custom dimensions
2725            assert!(
2726                !ls.custom.is_empty(),
2727                "{schema} must have custom dimensions for pruning"
2728            );
2729        }
2730
2731        // VPC flow: has product=aws + custom source=vpcflow
2732        let vpc = map.get("aws_vpcflow").expect("vpcflow mapping");
2733        assert_eq!(vpc.product.as_deref(), Some("aws"));
2734        assert_eq!(vpc.custom.get("source"), Some(&"vpcflow".to_string()));
2735    }
2736
2737    #[test]
2738    fn builtin_schema_names_match_signatures() {
2739        // builtin_schema_names() is a hand-maintained list; guard it against
2740        // drift from builtin_signatures() on both membership and ordering.
2741        use std::collections::{HashMap, HashSet};
2742
2743        // Effective specificity per name = the highest across its signatures
2744        // (matches classify's dedup, which keeps the highest-specificity hit).
2745        let mut spec_by_name: HashMap<String, u32> = HashMap::new();
2746        for sig in builtin_signatures() {
2747            let entry = spec_by_name
2748                .entry(sig.name.clone())
2749                .or_insert(sig.specificity);
2750            *entry = (*entry).max(sig.specificity);
2751        }
2752
2753        let names = builtin_schema_names();
2754
2755        // 1. Same set of names.
2756        let listed: HashSet<String> = names.iter().map(|s| s.to_string()).collect();
2757        let produced: HashSet<String> = spec_by_name.keys().cloned().collect();
2758        assert_eq!(
2759            listed, produced,
2760            "builtin_schema_names() is out of sync with builtin_signatures()"
2761        );
2762
2763        // 2. Non-increasing specificity in listed order.
2764        let mut prev = u32::MAX;
2765        for name in &names {
2766            let spec = spec_by_name[*name];
2767            assert!(
2768                spec <= prev,
2769                "builtin_schema_names() not ordered by non-increasing specificity at '{name}' ({spec} > {prev})"
2770            );
2771            prev = spec;
2772        }
2773    }
2774
2775    #[test]
2776    fn okta_and_onelogin_signatures() {
2777        // Okta: eventType + actor + published + outcome
2778        let okta = json!({
2779            "eventType": "user.lifecycle.activate.pre_auth",
2780            "actor": {"id": "abc"},
2781            "published": "2024-01-01T00:00:00Z",
2782            "outcome": {"result": "SUCCESS"}
2783        });
2784        let classifier = SchemaClassifier::builtin();
2785        assert_eq!(
2786            classifier
2787                .classify(&JsonEvent::borrow(&okta))
2788                .as_ref()
2789                .map(|m| m.name.as_str()),
2790            Some("okta_system_log")
2791        );
2792
2793        // OneLogin: event_type_id + account_id + any(user_id, actor_user_id)
2794        let onelogin = json!({
2795            "event_type_id": 123,
2796            "account_id": 456,
2797            "user_id": 789,
2798            "created_at": "2024-01-01T00:00:00Z"
2799        });
2800        assert_eq!(
2801            classifier
2802                .classify(&JsonEvent::borrow(&onelogin))
2803                .as_ref()
2804                .map(|m| m.name.as_str()),
2805            Some("onelogin_events")
2806        );
2807    }
2808
2809    #[test]
2810    fn m365_unified_audit_log_maps_to_audit_service() {
2811        // The Office 365 Management Activity common schema (any Workload)
2812        // classifies as the unified audit feed and maps to service: audit,
2813        // where SigmaHQ's native-field rules live. It outranks generic_json.
2814        let exchange = json!({
2815            "CreationTime": "2024-01-01T00:00:00Z",
2816            "RecordType": 1,
2817            "Workload": "Exchange",
2818            "Operation": "New-RemoteDomain"
2819        });
2820        let classifier = SchemaClassifier::builtin();
2821
2822        let m = classifier
2823            .classify(&JsonEvent::borrow(&exchange))
2824            .expect("matched");
2825        assert_eq!(m.name, "m365_audit");
2826
2827        let map = builtin_schema_logsource();
2828        let ls = map.get("m365_audit").expect("m365_audit mapping");
2829        assert_eq!(ls.product.as_deref(), Some("m365"));
2830        assert_eq!(ls.service.as_deref(), Some("audit"));
2831    }
2832
2833    #[test]
2834    fn docker_and_osquery_signatures() {
2835        // Docker events: Type + Action + Actor
2836        let docker = json!({"Type": "container", "Action": "start", "Actor": {"ID": "abc"}});
2837        let classifier = SchemaClassifier::builtin();
2838        assert_eq!(
2839            classifier
2840                .classify(&JsonEvent::borrow(&docker))
2841                .as_ref()
2842                .map(|m| m.name.as_str()),
2843            Some("docker_events")
2844        );
2845
2846        // osquery: name + action + columns/snapshot + hostIdentifier
2847        let osquery = json!({
2848            "name": "users",
2849            "action": "added",
2850            "columns": {"uid": "1000", "username": "admin"},
2851            "hostIdentifier": "workstation-01"
2852        });
2853        assert_eq!(
2854            classifier
2855                .classify(&JsonEvent::borrow(&osquery))
2856                .as_ref()
2857                .map(|m| m.name.as_str()),
2858            Some("osquery_result")
2859        );
2860    }
2861
2862    #[test]
2863    fn aws_vpcflow_classification() {
2864        // VPC Flow Logs: srcaddr + dstaddr + action ACCEPT/REJECT
2865        let vpc = json!({
2866            "version": 2,
2867            "srcaddr": "10.0.1.100",
2868            "dstaddr": "10.0.2.50",
2869            "srcport": 45678,
2870            "dstport": 443,
2871            "protocol": 6,
2872            "action": "ACCEPT",
2873            "log_status": "OK"
2874        });
2875        let classifier = SchemaClassifier::builtin();
2876        assert_eq!(
2877            classifier
2878                .classify(&JsonEvent::borrow(&vpc))
2879                .as_ref()
2880                .map(|m| m.name.as_str()),
2881            Some("aws_vpcflow")
2882        );
2883
2884        // VPC event should NOT match cloudtrail (no CloudTrail markers)
2885        let all = classifier.classify_all(&JsonEvent::borrow(&vpc));
2886        assert!(
2887            !all.iter().any(|s| s == "aws_cloudtrail"),
2888            "VPC flow event should not match CloudTrail"
2889        );
2890
2891        // But the vpcflow schema has custom source=vpcflow + product=aws
2892        let map = builtin_schema_logsource();
2893        let vpc_ls = map.get("aws_vpcflow").expect("vpcflow mapping");
2894        assert_eq!(vpc_ls.product.as_deref(), Some("aws"));
2895        assert_eq!(vpc_ls.custom.get("source"), Some(&"vpcflow".to_string()));
2896    }
2897}