Skip to main content

av_events/
validate.rs

1//! Structural validation for events, independent of (and cross-checked
2//! against) the shipped JSON Schema.
3
4use crate::model::OcsfEvent;
5
6/// A structural validation failure.
7#[derive(Debug, thiserror::Error, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum ValidationError {
10    /// A required string field was empty.
11    #[error("required field `{0}` is empty")]
12    EmptyField(&'static str),
13    /// `type_uid` doesn't match `class_uid * 100 + activity_id`.
14    #[error("type_uid {type_uid} != class_uid {class_uid} * 100 + activity_id {activity_id}")]
15    TypeUidMismatch {
16        /// Stated type uid.
17        type_uid: u64,
18        /// Stated class uid.
19        class_uid: u32,
20        /// Stated activity id.
21        activity_id: u8,
22    },
23    /// `class_uid` doesn't match the class enum.
24    #[error("class_uid {0} does not match class_name")]
25    ClassUidMismatch(u32),
26    /// Event is not in the OCSF Application Activity category.
27    #[error("category_uid {0} is not Application Activity (6)")]
28    BadCategory(u8),
29    /// Charter is not represented as an OCSF Regular File.
30    #[error("ai_agent.charter.type_id {0} is not Regular File (1)")]
31    BadCharterType(u8),
32    /// Severity outside 1–6.
33    #[error("severity_id {0} outside 1..=6")]
34    BadSeverity(u8),
35    /// Status outside 0–2.
36    #[error("status_id {0} outside 0..=2")]
37    BadStatus(u8),
38    /// Round-38 F3: activity_id must be < 100 or the
39    /// `class_uid × 100 + activity_id` bijection collapses:
40    /// `class_uid=9901, activity_id=100` produces the same
41    /// `type_uid = 990200` as `class_uid=9902, activity_id=0`,
42    /// so downstream SIEM pipelines routing/aggregating by
43    /// type_uid mis-classify. Not currently reachable via the
44    /// builder (defaults 1) but the field is `#[non_exhaustive]`
45    /// and constructable directly; enforce the bijection
46    /// invariant at validate time.
47    #[error("activity_id {0} outside 0..=99 (would collide type_uid namespaces)")]
48    BadActivityId(u8),
49    /// stop_reason caption present without id (or vice versa).
50    #[error("stop_reason and stop_reason_id must be present together")]
51    StopReasonPairMismatch,
52    /// Timestamp is zero.
53    #[error("time is zero")]
54    ZeroTime,
55}
56
57/// Validate structural invariants of an event. Returns *all* violations
58/// (collect-all-errors, matching the Harbor validator philosophy).
59pub fn validate_event(ev: &OcsfEvent) -> Result<(), Vec<ValidationError>> {
60    let mut errors = Vec::new();
61    if ev.metadata.uid.is_empty() {
62        errors.push(ValidationError::EmptyField("metadata.uid"));
63    }
64    if ev.metadata.version.is_empty() {
65        errors.push(ValidationError::EmptyField("metadata.version"));
66    }
67    if ev.metadata.product.name.is_empty() {
68        errors.push(ValidationError::EmptyField("metadata.product.name"));
69    }
70    if ev.metadata.product.vendor_name.is_empty() {
71        errors.push(ValidationError::EmptyField("metadata.product.vendor_name"));
72    }
73    if ev.metadata.product.version.is_empty() {
74        errors.push(ValidationError::EmptyField("metadata.product.version"));
75    }
76    if ev.session_uid.is_empty() {
77        errors.push(ValidationError::EmptyField("session_uid"));
78    }
79    if ev.ai_agent.version.is_empty() {
80        errors.push(ValidationError::EmptyField("ai_agent.version"));
81    }
82    if ev.ai_agent.charter.name.is_empty() {
83        errors.push(ValidationError::EmptyField("ai_agent.charter.name"));
84    }
85    if ev.ai_agent.charter.type_id != 1 {
86        errors.push(ValidationError::BadCharterType(ev.ai_agent.charter.type_id));
87    }
88    if ev.ai_agent.instance_uid.is_empty() {
89        errors.push(ValidationError::EmptyField("ai_agent.instance_uid"));
90    }
91    if ev.class_uid != ev.class_name.class_uid() {
92        errors.push(ValidationError::ClassUidMismatch(ev.class_uid));
93    }
94    if ev.category_uid != crate::model::CATEGORY_UID {
95        errors.push(ValidationError::BadCategory(ev.category_uid));
96    }
97    let expected_type = u64::from(ev.class_name.class_uid()) * 100 + u64::from(ev.activity_id);
98    if ev.type_uid != expected_type {
99        errors.push(ValidationError::TypeUidMismatch {
100            type_uid: ev.type_uid,
101            class_uid: ev.class_uid,
102            activity_id: ev.activity_id,
103        });
104    }
105    // Round-38 F3: the `class_uid × 100 + activity_id` invariant
106    // above is only a bijection when activity_id < 100. Enforce
107    // that at validate time so an adversarial event asserting
108    // `class_uid=9901, activity_id=100, type_uid=990200` (which
109    // is self-consistent per the mismatch check but collides
110    // with `class_uid=9902, activity_id=0`) cannot slip through
111    // and mis-route downstream SIEM aggregation by type_uid.
112    if ev.activity_id >= 100 {
113        errors.push(ValidationError::BadActivityId(ev.activity_id));
114    }
115    if !(1..=6).contains(&ev.severity_id) {
116        errors.push(ValidationError::BadSeverity(ev.severity_id));
117    }
118    if ev.status_id > 2 {
119        errors.push(ValidationError::BadStatus(ev.status_id));
120    }
121    if ev.stop_reason_id.is_some() != ev.stop_reason.is_some() {
122        errors.push(ValidationError::StopReasonPairMismatch);
123    }
124    if ev.time == 0 {
125        errors.push(ValidationError::ZeroTime);
126    }
127    if errors.is_empty() {
128        Ok(())
129    } else {
130        Err(errors)
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    #![allow(
137        clippy::unwrap_used,
138        clippy::expect_used,
139        clippy::panic,
140        clippy::indexing_slicing
141    )]
142
143    use super::*;
144    use crate::model::{AgentIdentity, EventClass, OcsfEventBuilder};
145    use crate::StopReason;
146
147    fn valid_event() -> OcsfEvent {
148        OcsfEventBuilder::new(
149            EventClass::ToolCall,
150            "sess",
151            AgentIdentity {
152                version: "1".into(),
153                charter: "c".into(),
154                instance_uid: "i".into(),
155                ttl_remaining_s: None,
156            },
157            1,
158        )
159        .stop_reason(StopReason::PolicyBlocked)
160        .build()
161        .unwrap()
162    }
163
164    #[test]
165    fn valid_event_passes() {
166        assert!(validate_event(&valid_event()).is_ok());
167    }
168
169    #[test]
170    fn empty_identity_fields_all_reported() {
171        let mut ev = valid_event();
172        ev.ai_agent.version.clear();
173        ev.ai_agent.charter.name.clear();
174        ev.ai_agent.instance_uid.clear();
175        let errs = validate_event(&ev).unwrap_err();
176        assert_eq!(errs.len(), 3, "collect-all: {errs:?}");
177    }
178
179    #[test]
180    fn tampered_type_uid_detected() {
181        let mut ev = valid_event();
182        ev.type_uid += 1;
183        assert!(matches!(
184            validate_event(&ev).unwrap_err().first(),
185            Some(ValidationError::TypeUidMismatch { .. })
186        ));
187    }
188
189    #[test]
190    fn orphan_stop_reason_caption_detected() {
191        let mut ev = valid_event();
192        ev.stop_reason_id = None; // caption still set
193        assert!(validate_event(&ev)
194            .unwrap_err()
195            .contains(&ValidationError::StopReasonPairMismatch));
196    }
197
198    #[test]
199    fn bad_severity_detected() {
200        let mut ev = valid_event();
201        ev.severity_id = 0;
202        assert!(validate_event(&ev)
203            .unwrap_err()
204            .contains(&ValidationError::BadSeverity(0)));
205        ev.severity_id = 7;
206        assert!(validate_event(&ev)
207            .unwrap_err()
208            .contains(&ValidationError::BadSeverity(7)));
209    }
210
211    #[test]
212    fn status_id_boundary_accepts_up_to_2_rejects_3() {
213        // Catches `> 2` vs `== 2` / `>= 2` mutations: status_id=2 (Failure)
214        // is VALID and must not be flagged; 3 is invalid and must be flagged.
215        let mut ev = valid_event();
216        for valid in [0u8, 1, 2] {
217            ev.status_id = valid;
218            let errs = validate_event(&ev).map(|_| Vec::new()).unwrap_or_else(|e| e);
219            assert!(
220                !errs.iter().any(|e| matches!(e, ValidationError::BadStatus(_))),
221                "status_id={valid} incorrectly flagged: {errs:?}"
222            );
223        }
224        ev.status_id = 3;
225        assert!(validate_event(&ev)
226            .unwrap_err()
227            .contains(&ValidationError::BadStatus(3)));
228    }
229
230    /// Round-38 F3: activity_id must be < 100 so
231    /// `class_uid × 100 + activity_id` is a bijection. Without this
232    /// check, `class_uid=9901, activity_id=100` produces the same
233    /// type_uid (990200) as `class_uid=9902, activity_id=0`, so
234    /// downstream SIEM pipelines routing by type_uid mis-classify.
235    /// Not currently reachable via the builder (defaults 1) but the
236    /// field is `#[non_exhaustive]` and constructable directly.
237    #[test]
238    fn activity_id_100_is_rejected_even_when_type_uid_matches() {
239        let mut ev = valid_event();
240        ev.activity_id = 100;
241        // Keep type_uid self-consistent so the mismatch check
242        // doesn't fire — this test isolates the bijection guard.
243        ev.type_uid = u64::from(ev.class_name.class_uid()) * 100 + 100;
244        let errs = validate_event(&ev).unwrap_err();
245        assert!(
246            errs.contains(&ValidationError::BadActivityId(100)),
247            "activity_id=100 must be flagged even when type_uid matches; got {errs:?}"
248        );
249        // Boundary: 99 must NOT be flagged.
250        ev.activity_id = 99;
251        ev.type_uid = u64::from(ev.class_name.class_uid()) * 100 + 99;
252        let errs = validate_event(&ev).map(|_| Vec::new()).unwrap_or_else(|e| e);
253        assert!(
254            !errs
255                .iter()
256                .any(|e| matches!(e, ValidationError::BadActivityId(_))),
257            "activity_id=99 must be valid; got {errs:?}"
258        );
259    }
260}