feat(ocsf): create openshell-ocsf crate — standalone OCSF event types, formatters, and tracing layers#489
Conversation
… formatters, and tracing layers Closes #392 Standalone crate implementing the OCSF v1.7.0 event model for sandbox logging. Includes 8 event classes (Network Activity, HTTP Activity, SSH Activity, Process Activity, Detection Finding, Application Lifecycle, Device Config State Change, Base Event), 11 typed enum types, 20 object types, per-class builders with SandboxContext, dual formatters (shorthand single-line + JSONL), tracing layers (OcsfShorthandLayer, OcsfJsonlLayer), ocsf_emit! macro, and vendored OCSF v1.7.0 schemas with test validation utilities. 93 tests covering all event classes, formatters, builders, and schema validation.
|
This looks worse than it is 😓 |
There was a problem hiding this comment.
Bugs
- ActionId enum values don't match OCSF v1.7.0 schema (Medium)
crates/openshell-ocsf/src/enums/action.rs:17-20 — Values 3 and 4 are labeled "Alerted" and "Dropped" but the OCSF spec defines them as "Observed" and "Modified". Consumers parsing these events against the standard will misclassify actions. - OcsfEvent deserialization via #[serde(untagged)] is ambiguous (Medium)
crates/openshell-ocsf/src/events/mod.rs:28-29 — All variants share a flattened BaseEventData with optional class-specific fields, so serde will always match the first variant (NetworkActivityEvent). Round-tripping any other event type through JSON deserialization silently produces the wrong variant. Either dispatch on class_uid or document that deserialization is unsupported. - Thread-local event bridge only delivers to one layer (Medium)
crates/openshell-ocsf/src/tracing_layers/event_bridge.rs:27-29 — take_current_event() uses .take(), so the first tracing layer to call it consumes the event and the second layer gets None. If both OcsfShorthandLayer and OcsfJsonlLayer are registered (the intended dual-file setup), only one receives events. Should use .clone() instead of .take(). - format_ts panics on out-of-range timestamps (Low)
crates/openshell-ocsf/src/format/shorthand.rs:15 — timestamp_millis_opt(...).unwrap() panics on invalid/corrupt timestamps. Since this formats log output in the sandbox supervisor, a panic here could crash the process.
Rust Conventions
No unwraps/panics in non-test code
| File | Line | Code |
|---|---|---|
src/format/shorthand.rs |
15 | Utc.timestamp_millis_opt(time_ms).unwrap() |
src/format/jsonl.rs |
14 | .expect("OcsfEvent serialization should never fail") |
src/format/jsonl.rs |
21 | .expect("OcsfEvent serialization should never fail") |
The shorthand.rs unwrap can panic on out-of-range timestamps. The two expect calls in jsonl.rs should return Result instead — even if serialization is unlikely to fail, the convention is to propagate errors.
crate:: over super::
2 violations:
| File | Line | Code |
|---|---|---|
src/tracing_layers/jsonl_layer.rs |
13 | use super::event_bridge::{OCSF_TARGET, take_current_event}; |
src/tracing_layers/shorthand_layer.rs |
13 | use super::event_bridge::{OCSF_TARGET, take_current_event}; |
These are forced because event_bridge is a private module. Fix by making it pub(crate) mod event_bridge in tracing_layers/mod.rs, then use crate::tracing_layers::event_bridge::....
No global/static state
3 instances in src/tracing_layers/event_bridge.rs:
| Line | Code | Issue |
|---|---|---|
| 14-18 | thread_local! { static CURRENT_EVENT: RefCell<Option<OcsfEvent>> } |
Mutable thread-local state used to smuggle events through the tracing subscriber. Also unsafe across async task migrations between threads. Should be replaced with an explicit context struct passed through tracing span/event extensions. |
| 21 | pub static OCSF_TARGET: &str = "ocsf" |
Should be const instead of static. |
| 24 | static _OCSF_FIELD: OnceLock<&str> |
Dead code. Remove it. |
Prefer strong types over strings
3 clear violations — bare String for closed value sets:
| File | Field | Should be |
|---|---|---|
src/objects/http.rs:12 |
HttpRequest.http_method: String |
An HttpMethod enum (GET, POST, PUT, etc.) |
src/objects/connection.rs:12 |
ConnectionInfo.protocol_name: String |
A ProtocolName enum (tcp, udp, icmp, etc.) |
src/objects/firewall_rule.rs:16 |
FirewallRule.rule_type: String |
A RuleType enum (mechanistic, opa, iptables, etc.) |
Pervasive pattern issue — The crate defines typed enums (ActionId, DispositionId, SeverityId, etc.) with .label() methods, but event structs store both a _id: u8 and a redundant label: Option<String> for the same concept. This appears in action/disposition/status/severity/confidence/risk_level/security_level fields across all 8 event structs (~23 instances). This creates redundancy and inconsistency risk — the label can drift from the ID. The event structs should use the typed enums directly and derive labels at serialization time.
crates/openshell-ocsf/schemas/ocsf/v1.7.0/classes/application_lifecycle.json
Show resolved
Hide resolved
Rename Alerted(3) to Observed and Dropped(4) to Modified per the OCSF v1.7.0 action_id specification. The previous values were incorrectly sourced from disposition_id captions.
Replace #[serde(untagged)] with a custom Deserialize impl that reads class_uid first, then dispatches to the correct event variant. The untagged approach was ambiguous since all variants share flattened BaseEventData with optional fields, causing serde to always match the first variant. Add round-trip tests for all 8 event classes.
Replace take_current_event() with clone_current_event() so both OcsfShorthandLayer and OcsfJsonlLayer receive the event. The previous .take() approach consumed the event on the first layer call, starving the second layer in the dual-file output setup.
Replace .unwrap() in format_ts with pattern match that returns a placeholder string for invalid timestamps. Prevents a panic in the sandbox supervisor if a corrupt timestamp reaches the formatter.
Replace expect() calls with proper Result returns so callers can handle serialization errors instead of panicking. Updates the JSONL layer and all builder tests to propagate or unwrap the Result.
Make event_bridge module pub(crate) and update both layer files to use crate::tracing_layers::event_bridge:: paths instead of super::.
Flatten the nested if-let blocks in the JSONL layer's on_event handler into a single chained let expression.
Add .gitattributes rule so GitHub excludes the vendored OCSF JSON schemas from diffs, language stats, and code search.
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
Add inline comments explaining the intentional decision to keep these fields as String rather than typed enums: protocol_name is free-form per the OCSF spec, and rule_type is a project-specific extension with runtime-dynamic values from the policy engine.
These should be addressed in the commit train, including the larger refactor. |
|
Really glad to see OCSF integration landing — this is exactly the standardized event surface that behavioral security layers need to build on top of OpenShell. |
|
Congrats on getting this merged — looking forward to building on top of it. Will follow up on the evidence field question once I have Zetra's detection patterns running against the event stream. |
We already have |
|
Thanks — good to know evidence is already an array. The data JSON catch-all works for now; I can serialize the contributing event sequence there and revisit typed evidence fields later. |
Summary
Creates the standalone
openshell-ocsfcrate implementing OCSF v1.7.0 event types, dual formatters (shorthand + JSONL), ergonomic per-class builders, and tracing layers for sandbox log output. No sandbox code is modified — this crate is independently buildable and testable.Related Issue
Closes #392
Changes
New crate:
crates/openshell-ocsf/NetworkActivityEvent[4001],HttpActivityEvent[4002],SshActivityEvent[4007],ProcessActivityEvent[1007],DetectionFindingEvent[2004],ApplicationLifecycleEvent[6002],DeviceConfigStateChangeEvent[5019],BaseEvent[0]SeverityId,StatusId,ActionId,DispositionId,ActivityId,StateId,AuthTypeId,LaunchTypeId,SecurityLevelId,ConfidenceId,RiskLevelId— all serialize to integer values viaserde_reprMetadata,Product,Endpoint,Process,Actor,Container,Image,Device,OsInfo,FirewallRule,FindingInfo,Evidence,Remediation,HttpRequest,HttpResponse,Url,Attack,Technique,Tactic,ConnectionInfoNetworkActivityBuilder,HttpActivityBuilder,SshActivityBuilder,ProcessActivityBuilder,DetectionFindingBuilder,AppLifecycleBuilder,ConfigStateChangeBuilder,BaseEventBuilder— each takes&SandboxContextand provides chainable methodsformat_shorthand()for single-line human-readable output,to_json()/to_json_line()for OCSF-compliant JSONLOcsfShorthandLayer(writes shorthand to anyWriteimpl),OcsfJsonlLayer(writes JSONL), withemit_ocsf_event()bridge andocsf_emit!macrovalidate_required_fields(),validate_enum_value(),load_class_schema()— test-only (#[cfg(test)])Deviations from Plan
None — implemented as planned.
Testing
cargo check -p openshell-ocsfcompiles with zero errorscargo clippy -p openshell-ocsf --all-targets -- -D warnings— zero warningscargo fmt -p openshell-ocsf -- --check— no formatting issuesTests added:
Checklist
openshell-sandboxhas been modifiedOCSF_VERSIONconstant in code