Skip to content

Commit 5e134ca

Browse files
committed
refactor(ocsf): replace redundant id+label pairs with typed enums
Store typed enum values (ActionId, DispositionId, SeverityId, etc.) directly in event structs instead of separate _id: u8 + label: String pairs. Labels are derived at serialization time via custom Serialize impls, eliminating the drift risk between ID and label fields. - Add OcsfEnum trait implemented by all 11 enum types - Add HttpMethod enum (9 OCSF-defined variants + Other) for HttpRequest - Refactor BaseEventData: severity and status use typed enums - Refactor all 6 event structs: 18 id+label pairs consolidated to single typed fields with derive(Deserialize) + custom Serialize - 2 custom-label fields (auth_type, state) use separate _custom_label field for the Other variant override - Simplify OcsfEvent Serialize to delegate directly to inner structs - Simplify all 8 builders: remove manual .as_u8()/.label() expansion - Add serde_helpers module with insert_enum_pair! macros
1 parent 2b883f0 commit 5e134ca

File tree

20 files changed

+657
-298
lines changed

20 files changed

+657
-298
lines changed

crates/openshell-ocsf/src/builders/config.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,10 @@ impl<'a> ConfigStateChangeBuilder<'a> {
106106

107107
OcsfEvent::DeviceConfigStateChange(DeviceConfigStateChangeEvent {
108108
base,
109-
state_id: self.state_id.map(StateId::as_u8),
110-
state: self.state_label,
111-
security_level_id: self.security_level.map(SecurityLevelId::as_u8),
112-
security_level: self.security_level.map(|s| s.label().to_string()),
113-
prev_security_level_id: self.prev_security_level.map(SecurityLevelId::as_u8),
114-
prev_security_level: self.prev_security_level.map(|s| s.label().to_string()),
109+
state: self.state_id,
110+
state_custom_label: self.state_label,
111+
security_level: self.security_level,
112+
prev_security_level: self.prev_security_level,
115113
})
116114
}
117115
}

crates/openshell-ocsf/src/builders/finding.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,14 +170,10 @@ impl<'a> DetectionFindingBuilder<'a> {
170170
},
171171
remediation: self.remediation,
172172
is_alert: self.is_alert,
173-
confidence_id: self.confidence.map(ConfidenceId::as_u8),
174-
confidence: self.confidence.map(|c| c.label().to_string()),
175-
risk_level_id: self.risk_level.map(RiskLevelId::as_u8),
176-
risk_level: self.risk_level.map(|r| r.label().to_string()),
177-
action_id: self.action.map(ActionId::as_u8),
178-
action: self.action.map(|a| a.label().to_string()),
179-
disposition_id: self.disposition.map(DispositionId::as_u8),
180-
disposition: self.disposition.map(|d| d.label().to_string()),
173+
confidence: self.confidence,
174+
risk_level: self.risk_level,
175+
action: self.action,
176+
disposition: self.disposition,
181177
})
182178
}
183179
}

crates/openshell-ocsf/src/builders/http.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,8 @@ impl<'a> HttpActivityBuilder<'a> {
139139
proxy_endpoint: Some(self.ctx.proxy_endpoint()),
140140
actor: self.actor,
141141
firewall_rule: self.firewall_rule,
142-
action_id: self.action.map(ActionId::as_u8),
143-
action: self.action.map(|a| a.label().to_string()),
144-
disposition_id: self.disposition.map(DispositionId::as_u8),
145-
disposition: self.disposition.map(|d| d.label().to_string()),
142+
action: self.action,
143+
disposition: self.disposition,
146144
observation_point_id: Some(2),
147145
is_src_dst_assignment_known: Some(true),
148146
})

crates/openshell-ocsf/src/builders/network.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,8 @@ impl<'a> NetworkActivityBuilder<'a> {
189189
actor: self.actor,
190190
firewall_rule: self.firewall_rule,
191191
connection_info: self.connection_info,
192-
action_id: self.action.map(ActionId::as_u8),
193-
action: self.action.map(|a| a.label().to_string()),
194-
disposition_id: self.disposition.map(DispositionId::as_u8),
195-
disposition: self.disposition.map(|d| d.label().to_string()),
192+
action: self.action,
193+
disposition: self.disposition,
196194
observation_point_id: self.observation_point_id,
197195
is_src_dst_assignment_known: Some(true),
198196
})

crates/openshell-ocsf/src/builders/process.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,10 @@ impl<'a> ProcessActivityBuilder<'a> {
120120
base,
121121
process: self.process.unwrap_or_else(|| Process::new("unknown", 0)),
122122
actor: self.actor,
123-
launch_type_id: self.launch_type.map(LaunchTypeId::as_u8),
124-
launch_type: self.launch_type.map(|lt| lt.label().to_string()),
123+
launch_type: self.launch_type,
125124
exit_code: self.exit_code,
126-
action_id: self.action.map(ActionId::as_u8),
127-
action: self.action.map(|a| a.label().to_string()),
128-
disposition_id: self.disposition.map(DispositionId::as_u8),
129-
disposition: self.disposition.map(|d| d.label().to_string()),
125+
action: self.action,
126+
disposition: self.disposition,
130127
})
131128
}
132129
}

crates/openshell-ocsf/src/builders/ssh.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,11 @@ impl<'a> SshActivityBuilder<'a> {
136136
src_endpoint: self.src_endpoint,
137137
dst_endpoint: self.dst_endpoint,
138138
actor: self.actor,
139-
auth_type_id: self.auth_type_id.map(AuthTypeId::as_u8),
140-
auth_type: self.auth_type_label,
139+
auth_type: self.auth_type_id,
140+
auth_type_custom_label: self.auth_type_label,
141141
protocol_ver: self.protocol_ver,
142-
action_id: self.action.map(ActionId::as_u8),
143-
action: self.action.map(|a| a.label().to_string()),
144-
disposition_id: self.disposition.map(DispositionId::as_u8),
145-
disposition: self.disposition.map(|d| d.label().to_string()),
142+
action: self.action,
143+
disposition: self.disposition,
146144
})
147145
}
148146
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! OCSF `http_method` enum — the 9 OCSF-defined HTTP methods.
5+
6+
use serde::{Deserialize, Serialize};
7+
8+
/// HTTP method as defined in the OCSF v1.7.0 `http_request` object schema.
9+
///
10+
/// The 9 standard methods are typed variants. Non-standard methods use
11+
/// `Other(String)`.
12+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13+
pub enum HttpMethod {
14+
/// OPTIONS
15+
Options,
16+
/// GET
17+
Get,
18+
/// HEAD
19+
Head,
20+
/// POST
21+
Post,
22+
/// PUT
23+
Put,
24+
/// DELETE
25+
Delete,
26+
/// TRACE
27+
Trace,
28+
/// CONNECT
29+
Connect,
30+
/// PATCH
31+
Patch,
32+
/// Non-standard method.
33+
Other(String),
34+
}
35+
36+
impl HttpMethod {
37+
/// Return the canonical uppercase string representation.
38+
#[must_use]
39+
pub fn as_str(&self) -> &str {
40+
match self {
41+
Self::Options => "OPTIONS",
42+
Self::Get => "GET",
43+
Self::Head => "HEAD",
44+
Self::Post => "POST",
45+
Self::Put => "PUT",
46+
Self::Delete => "DELETE",
47+
Self::Trace => "TRACE",
48+
Self::Connect => "CONNECT",
49+
Self::Patch => "PATCH",
50+
Self::Other(s) => s,
51+
}
52+
}
53+
}
54+
55+
impl std::str::FromStr for HttpMethod {
56+
type Err = std::convert::Infallible;
57+
58+
/// Parse a method string into a typed variant (case-insensitive).
59+
fn from_str(s: &str) -> Result<Self, Self::Err> {
60+
Ok(match s.to_uppercase().as_str() {
61+
"OPTIONS" => Self::Options,
62+
"GET" => Self::Get,
63+
"HEAD" => Self::Head,
64+
"POST" => Self::Post,
65+
"PUT" => Self::Put,
66+
"DELETE" => Self::Delete,
67+
"TRACE" => Self::Trace,
68+
"CONNECT" => Self::Connect,
69+
"PATCH" => Self::Patch,
70+
_ => Self::Other(s.to_string()),
71+
})
72+
}
73+
}
74+
75+
impl Serialize for HttpMethod {
76+
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
77+
serializer.serialize_str(self.as_str())
78+
}
79+
}
80+
81+
impl<'de> Deserialize<'de> for HttpMethod {
82+
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
83+
let s = String::deserialize(deserializer)?;
84+
Ok(s.parse().unwrap())
85+
}
86+
}
87+
88+
impl std::fmt::Display for HttpMethod {
89+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90+
f.write_str(self.as_str())
91+
}
92+
}
93+
94+
#[cfg(test)]
95+
mod tests {
96+
use super::*;
97+
98+
#[test]
99+
fn test_from_str_standard_methods() {
100+
assert_eq!("GET".parse::<HttpMethod>().unwrap(), HttpMethod::Get);
101+
assert_eq!("get".parse::<HttpMethod>().unwrap(), HttpMethod::Get);
102+
assert_eq!("Post".parse::<HttpMethod>().unwrap(), HttpMethod::Post);
103+
assert_eq!("DELETE".parse::<HttpMethod>().unwrap(), HttpMethod::Delete);
104+
assert_eq!(
105+
"CONNECT".parse::<HttpMethod>().unwrap(),
106+
HttpMethod::Connect
107+
);
108+
assert_eq!("PATCH".parse::<HttpMethod>().unwrap(), HttpMethod::Patch);
109+
}
110+
111+
#[test]
112+
fn test_from_str_non_standard() {
113+
let method: HttpMethod = "PROPFIND".parse().unwrap();
114+
assert_eq!(method, HttpMethod::Other("PROPFIND".to_string()));
115+
assert_eq!(method.as_str(), "PROPFIND");
116+
}
117+
118+
#[test]
119+
fn test_json_roundtrip() {
120+
let method = HttpMethod::Get;
121+
let json = serde_json::to_value(&method).unwrap();
122+
assert_eq!(json, serde_json::json!("GET"));
123+
124+
let deserialized: HttpMethod = serde_json::from_value(json).unwrap();
125+
assert_eq!(deserialized, HttpMethod::Get);
126+
}
127+
128+
#[test]
129+
fn test_json_roundtrip_other() {
130+
let method = HttpMethod::Other("PROPFIND".to_string());
131+
let json = serde_json::to_value(&method).unwrap();
132+
assert_eq!(json, serde_json::json!("PROPFIND"));
133+
134+
let deserialized: HttpMethod = serde_json::from_value(json).unwrap();
135+
assert_eq!(deserialized, HttpMethod::Other("PROPFIND".to_string()));
136+
}
137+
}

crates/openshell-ocsf/src/enums/mod.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod action;
77
mod activity;
88
mod auth;
99
mod disposition;
10+
mod http_method;
1011
mod launch;
1112
mod security;
1213
mod severity;
@@ -16,7 +17,45 @@ pub use action::ActionId;
1617
pub use activity::ActivityId;
1718
pub use auth::AuthTypeId;
1819
pub use disposition::DispositionId;
20+
pub use http_method::HttpMethod;
1921
pub use launch::LaunchTypeId;
2022
pub use security::{ConfidenceId, RiskLevelId, SecurityLevelId};
2123
pub use severity::SeverityId;
2224
pub use status::{StateId, StatusId};
25+
26+
/// Trait for OCSF enum types that have an integer ID and a string label.
27+
///
28+
/// All OCSF "sibling pair" enums implement this trait, enabling generic
29+
/// serialization of `_id` + label field pairs.
30+
pub trait OcsfEnum: Copy + Clone + PartialEq + Eq + std::fmt::Debug {
31+
/// Return the integer representation for JSON serialization.
32+
fn as_u8(self) -> u8;
33+
34+
/// Return the OCSF string label for this value.
35+
fn label(self) -> &'static str;
36+
}
37+
38+
/// Implement [`OcsfEnum`] for a type that already has `as_u8()` and `label()` methods.
39+
macro_rules! impl_ocsf_enum {
40+
($($ty:ty),+ $(,)?) => {
41+
$(
42+
impl OcsfEnum for $ty {
43+
fn as_u8(self) -> u8 { self.as_u8() }
44+
fn label(self) -> &'static str { self.label() }
45+
}
46+
)+
47+
};
48+
}
49+
50+
impl_ocsf_enum!(
51+
ActionId,
52+
AuthTypeId,
53+
ConfidenceId,
54+
DispositionId,
55+
LaunchTypeId,
56+
RiskLevelId,
57+
SecurityLevelId,
58+
SeverityId,
59+
StateId,
60+
StatusId,
61+
);

0 commit comments

Comments
 (0)