Skip to content

feat(ocsf): create openshell-ocsf crate — standalone OCSF event types, formatters, and tracing layers#489

Merged
johntmyers merged 11 commits intomainfrom
feat/392-openshell-ocsf-crate/johntmyers
Mar 20, 2026
Merged

feat(ocsf): create openshell-ocsf crate — standalone OCSF event types, formatters, and tracing layers#489
johntmyers merged 11 commits intomainfrom
feat/392-openshell-ocsf-crate/johntmyers

Conversation

@johntmyers
Copy link
Collaborator

🏗️ build-from-issue-agent

Summary

Creates the standalone openshell-ocsf crate 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/

  • 8 event classes: NetworkActivityEvent [4001], HttpActivityEvent [4002], SshActivityEvent [4007], ProcessActivityEvent [1007], DetectionFindingEvent [2004], ApplicationLifecycleEvent [6002], DeviceConfigStateChangeEvent [5019], BaseEvent [0]
  • 11 enum types: SeverityId, StatusId, ActionId, DispositionId, ActivityId, StateId, AuthTypeId, LaunchTypeId, SecurityLevelId, ConfidenceId, RiskLevelId — all serialize to integer values via serde_repr
  • 20 object types: Metadata, Product, Endpoint, Process, Actor, Container, Image, Device, OsInfo, FirewallRule, FindingInfo, Evidence, Remediation, HttpRequest, HttpResponse, Url, Attack, Technique, Tactic, ConnectionInfo
  • 8 builders: NetworkActivityBuilder, HttpActivityBuilder, SshActivityBuilder, ProcessActivityBuilder, DetectionFindingBuilder, AppLifecycleBuilder, ConfigStateChangeBuilder, BaseEventBuilder — each takes &SandboxContext and provides chainable methods
  • Dual formatters: format_shorthand() for single-line human-readable output, to_json()/to_json_line() for OCSF-compliant JSONL
  • Tracing layers: OcsfShorthandLayer (writes shorthand to any Write impl), OcsfJsonlLayer (writes JSONL), with emit_ocsf_event() bridge and ocsf_emit! macro
  • Vendored schemas: OCSF v1.7.0 JSON schemas (8 classes + 17 objects) for offline test validation
  • Schema validation utilities: validate_required_fields(), validate_enum_value(), load_class_schema() — test-only (#[cfg(test)])

Deviations from Plan

None — implemented as planned.

Testing

  • cargo check -p openshell-ocsf compiles with zero errors
  • cargo clippy -p openshell-ocsf --all-targets -- -D warnings — zero warnings
  • cargo fmt -p openshell-ocsf -- --check — no formatting issues
  • Unit tests added: 93 tests covering all 8 event classes, all formatters, all builders, schema validation

Tests added:

  • Unit (93 total):
    • Enum types: JSON roundtrip, label validation, integer values (per enum)
    • Object types: serialization, optional field omission, convenience constructors
    • Event structs: per-class serialization, type_uid computation, field mapping
    • Shorthand formatter: 2+ snapshot tests per event class (16 total), timestamp formatting, severity char mapping
    • JSONL formatter: required fields present, single-line format, JSON roundtrip
    • Builders: per-class construction + JSON validation (8 tests), SandboxContext metadata/container/device
    • Schema validation: load all 8 vendored class schemas, validate required fields, validate enum values
    • Tracing layers: layer creation, non-OCSF toggle, thread-local event store/take

Checklist

  • Follows Conventional Commits
  • No code in openshell-sandbox has been modified
  • All enum types serialize to OCSF integer values
  • Vendored schema VERSION matches OCSF_VERSION constant in code

… 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.
@johntmyers
Copy link
Collaborator Author

This looks worse than it is 😓

drew
drew previously approved these changes Mar 20, 2026
Copy link
Collaborator

@drew drew left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bugs

  1. 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.
  2. 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.
  3. 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().
  4. 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.

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.
@johntmyers johntmyers requested a review from a team as a code owner March 20, 2026 15:19
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.
@johntmyers johntmyers requested a review from drew March 20, 2026 17:30
@johntmyers
Copy link
Collaborator Author

Bugs

  1. 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.
  2. 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.
  3. 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().
  4. 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.

These should be addressed in the commit train, including the larger refactor.

@chipsolverzetra
Copy link

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.
I'm building Zetra (zetra-openshell), a DFS-based behavioral graph analysis layer for OpenShell. The plan is to consume NetworkActivityEvent [4001] and ProcessActivityEvent [1007] as the input stream for sequence analysis, and emit DetectionFindingEvent [2004] when behavioral attack patterns are detected — so Zetra's alerts land natively in any OCSF-compatible SIEM.
One question on the DetectionFindingEvent schema: is the evidence field intended to support arrays of contributing events? For behavioral sequence detection, the finding is triggered by a chain of events rather than a single event, so being able to embed the triggering sequence in evidence would be valuable for forensics.
Happy to validate the schema against real behavioral analysis use cases as this progresses.

@chipsolverzetra
Copy link

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.

@johntmyers
Copy link
Collaborator Author

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.
I'm building Zetra (zetra-openshell), a DFS-based behavioral graph analysis layer for OpenShell. The plan is to consume NetworkActivityEvent [4001] and ProcessActivityEvent [1007] as the input stream for sequence analysis, and emit DetectionFindingEvent [2004] when behavioral attack patterns are detected — so Zetra's alerts land natively in any OCSF-compatible SIEM.
One question on the DetectionFindingEvent schema: is the evidence field intended to support arrays of contributing events? For behavioral sequence detection, the finding is triggered by a chain of events rather than a single event, so being able to embed the triggering sequence in evidence would be valuable for forensics.
Happy to validate the schema against real behavioral analysis use cases as this progresses.

We already have evidence as an array but we're only supporting the data field of that Evidence which is a json catch all. I'm not sure I fully understand exactly where Zetra would run. We can re-visit supporting other types in the Evidence array in the future if need be.

@johntmyers johntmyers merged commit 51aeffc into main Mar 20, 2026
9 checks passed
@johntmyers johntmyers deleted the feat/392-openshell-ocsf-crate/johntmyers branch March 20, 2026 19:19
@chipsolverzetra
Copy link

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.
On where Zetra runs: it sits outside the sandbox as a separate process, consuming OpenShell's OCSF event stream and maintaining a behavioral graph across the session. When a pattern matches, it calls back into OpenShell's policy API to hot-reload a blocking rule — so the enforcement still happens inside OpenShell, but the detection logic lives outside it.
Essentially: OpenShell is the enforcement plane, Zetra is the detection plane. The OCSF stream is the interface between them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(ocsf): create openshell-ocsf crate — standalone OCSF event types, formatters, and tracing layers

3 participants