Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/openshell-ocsf/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
serde_repr = "0.1"
tracing = { workspace = true }
uuid = { workspace = true }
tracing-subscriber = { workspace = true }

[dev-dependencies]
Expand Down
52 changes: 41 additions & 11 deletions crates/openshell-ocsf/src/builders/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,19 @@ use crate::enums::StatusId;
use crate::events::base_event::BaseEventData;
use crate::objects::{Container, Device, Endpoint, Image, Metadata, Product};

/// Which `OpenShell` component produced an event.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum EventOrigin {
/// The sandbox supervisor, running inside a sandbox container.
#[default]
Sandbox,
/// The gateway process, which has no sandbox or container of its own.
Gateway {
/// Operator-assigned gateway name (`[openshell.gateway] name`).
name: String,
},
}

/// Immutable context created once at sandbox startup.
///
/// Passed to every event builder to populate shared OCSF fields
Expand All @@ -195,37 +208,49 @@ pub struct SandboxContext {
pub proxy_ip: IpAddr,
/// Proxy listen port.
pub proxy_port: u16,
/// Which component is emitting.
pub origin: EventOrigin,
}

impl SandboxContext {
/// Build the OCSF `Metadata` object for any event.
#[must_use]
pub fn metadata(&self, profiles: &[&str]) -> Metadata {
let product = match self.origin {
EventOrigin::Sandbox => Product::openshell_sandbox(&self.product_version),
EventOrigin::Gateway { .. } => Product::openshell_gateway(&self.product_version),
};
Metadata {
version: OCSF_VERSION.to_string(),
product: Product::openshell_sandbox(&self.product_version),
product,
profiles: profiles.iter().map(|s| (*s).to_string()).collect(),
uid: Some(self.sandbox_id.clone()),
uid: Some(uuid::Uuid::new_v4().to_string()),
log_source: None,
}
}

/// Build the OCSF `Container` object.
/// Build the OCSF `Container` object when the event concerns a sandbox.
#[must_use]
pub fn container(&self) -> Container {
Container {
pub fn container(&self) -> Option<Container> {
if self.sandbox_id.is_empty() {
return None;
}
Some(Container {
name: self.sandbox_name.clone(),
uid: Some(self.sandbox_id.clone()),
image: Some(Image {
image: (!self.container_image.is_empty()).then(|| Image {
name: self.container_image.clone(),
}),
}
})
}

/// Build the OCSF `Device` object.
#[must_use]
pub fn device(&self) -> Device {
Device::linux(&self.hostname)
match &self.origin {
EventOrigin::Sandbox => Device::linux(&self.hostname),
EventOrigin::Gateway { name } => Device::gateway(&self.hostname, name),
}
}

/// Build the `proxy_endpoint` object for the Network Proxy profile.
Expand All @@ -249,7 +274,9 @@ impl SandboxContext {
base.set_message(m);
}
base.set_device(self.device());
base.set_container(self.container());
if let Some(container) = self.container() {
base.set_container(container);
}
}
}

Expand All @@ -263,6 +290,7 @@ pub(crate) fn test_sandbox_context() -> SandboxContext {
product_version: "0.1.0".to_string(),
proxy_ip: "10.42.0.1".parse().unwrap(),
proxy_port: 3128,
origin: EventOrigin::Sandbox,
}
}

Expand All @@ -277,13 +305,15 @@ mod tests {
assert_eq!(meta.version, "1.8.0");
assert_eq!(meta.product.name, "OpenShell Sandbox Supervisor");
assert_eq!(meta.profiles.len(), 2);
assert_eq!(meta.uid.as_deref(), Some("sandbox-abc123"));
let uid = meta.uid.as_deref().expect("uid is set");
assert!(!uid.is_empty());
assert_ne!(uid, "sandbox-abc123");
}

#[test]
fn test_sandbox_context_container() {
let ctx = test_sandbox_context();
let container = ctx.container();
let container = ctx.container().expect("sandbox context has a container");
assert_eq!(container.name, "my-sandbox");
assert_eq!(container.uid.as_deref(), Some("sandbox-abc123"));
}
Expand Down
3 changes: 2 additions & 1 deletion crates/openshell-ocsf/src/ctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! not been set (e.g. unit tests that exercise builders without booting the
//! sandbox).

use crate::SandboxContext;
use crate::{EventOrigin, SandboxContext};
use std::sync::{LazyLock, OnceLock};

static OCSF_CTX: OnceLock<SandboxContext> = OnceLock::new();
Expand All @@ -21,6 +21,7 @@ static OCSF_CTX_FALLBACK: LazyLock<SandboxContext> = LazyLock::new(|| SandboxCon
product_version: env!("CARGO_PKG_VERSION").to_string(),
proxy_ip: std::net::IpAddr::from([127, 0, 0, 1]),
proxy_port: 3128,
origin: EventOrigin::Sandbox,
});

/// Initialise the process-wide OCSF sandbox context.
Expand Down
57 changes: 57 additions & 0 deletions crates/openshell-ocsf/src/enums/device_type.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! OCSF `device.type_id` enum.

use serde_repr::{Deserialize_repr, Serialize_repr};

/// OCSF Device Type ID.
///
/// Only the values `OpenShell` can produce are modelled; the schema defines a
/// wider set (desktop, mobile, firewall, router, ...).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize_repr, Deserialize_repr)]
#[repr(u8)]
pub enum DeviceTypeId {
/// 0 — Unknown
Unknown = 0,
/// 1 — Server
Server = 1,
/// 99 — Other
Other = 99,
}

impl DeviceTypeId {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Unknown => "Unknown",
Self::Server => "Server",
Self::Other => "Other",
}
}

#[must_use]
pub fn as_u8(self) -> u8 {
self as u8
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn device_type_labels() {
assert_eq!(DeviceTypeId::Unknown.label(), "Unknown");
assert_eq!(DeviceTypeId::Server.label(), "Server");
assert_eq!(DeviceTypeId::Other.label(), "Other");
}

#[test]
fn device_type_json_roundtrip() {
let json = serde_json::to_value(DeviceTypeId::Server).unwrap();
assert_eq!(json, serde_json::json!(1));
let decoded: DeviceTypeId = serde_json::from_value(json).unwrap();
assert_eq!(decoded, DeviceTypeId::Server);
}
}
2 changes: 2 additions & 0 deletions crates/openshell-ocsf/src/enums/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
mod action;
mod activity;
mod auth;
mod device_type;
mod disposition;
mod http_method;
mod launch;
Expand All @@ -16,6 +17,7 @@ mod status;
pub use action::ActionId;
pub use activity::ActivityId;
pub use auth::AuthTypeId;
pub use device_type::DeviceTypeId;
pub use disposition::DispositionId;
pub use http_method::HttpMethod;
pub use launch::LaunchTypeId;
Expand Down
30 changes: 26 additions & 4 deletions crates/openshell-ocsf/src/events/api_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,16 @@ pub struct ApiActivityEvent {
#[serde(default)]
pub dst_endpoint: Option<Endpoint>,

/// Action taken (allowed, denied, etc.).
#[serde(default)]
/// Action taken (typed enum serialized as `action_id` + `action` label).
#[serde(rename = "action_id", default, skip_serializing_if = "Option::is_none")]
pub action: Option<ActionId>,

/// Disposition.
#[serde(default)]
/// Disposition (typed enum serialized as `disposition_id` + `disposition` label).
#[serde(
rename = "disposition_id",
default,
skip_serializing_if = "Option::is_none"
)]
pub disposition: Option<DispositionId>,
}

Expand Down Expand Up @@ -141,4 +145,22 @@ mod tests {
assert_eq!(deserialized.api.operation, "POST /v1/messages");
assert!(deserialized.base.ai_model.is_some());
}

#[test]
fn action_and_disposition_survive_a_roundtrip() {
// `insert_enum_pair!` writes `action_id` (u8) plus an `action` label, so
// the field must be renamed to read the id back rather than the label.
let mut event = test_api_activity();
event.action = Some(ActionId::Allowed);
event.disposition = Some(DispositionId::Allowed);

let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["action_id"], ActionId::Allowed.as_u8());
assert_eq!(json["action"], ActionId::Allowed.label());
assert_eq!(json["disposition_id"], DispositionId::Allowed.as_u8());

let deserialized: ApiActivityEvent = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.action, Some(ActionId::Allowed));
assert_eq!(deserialized.disposition, Some(DispositionId::Allowed));
}
}
8 changes: 4 additions & 4 deletions crates/openshell-ocsf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ pub use events::{

// --- Enum types ---
pub use enums::{
ActionId, ActivityId, AuthTypeId, ConfidenceId, DispositionId, HttpMethod, LaunchTypeId,
OcsfEnum, RiskLevelId, SecurityLevelId, SeverityId, StateId, StatusId,
ActionId, ActivityId, AuthTypeId, ConfidenceId, DeviceTypeId, DispositionId, HttpMethod,
LaunchTypeId, OcsfEnum, RiskLevelId, SecurityLevelId, SeverityId, StateId, StatusId,
};

// --- Object types ---
Expand All @@ -58,8 +58,8 @@ pub use objects::{
// --- Builders ---
pub use builders::{
ApiActivityBuilder, AppLifecycleBuilder, BaseEventBuilder, ConfigStateChangeBuilder,
DetectionFindingBuilder, HttpActivityBuilder, NetworkActivityBuilder, ProcessActivityBuilder,
SandboxContext, SshActivityBuilder,
DetectionFindingBuilder, EventOrigin, HttpActivityBuilder, NetworkActivityBuilder,
ProcessActivityBuilder, SandboxContext, SshActivityBuilder,
};

// --- Tracing layers ---
Expand Down
59 changes: 59 additions & 0 deletions crates/openshell-ocsf/src/objects/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,29 @@

use serde::{Deserialize, Serialize};

use crate::enums::DeviceTypeId;

/// OCSF Device object.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Device {
/// Device hostname.
pub hostname: String,

/// Administrator-assigned device name, when one exists.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,

/// Stable unique identifier for the device.
#[serde(skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,

/// Device type id. Required by the OCSF schema.
pub type_id: DeviceTypeId,

/// Sibling label for `type_id`.
#[serde(rename = "type")]
pub type_label: String,

/// Operating system info.
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<OsInfo>,
Expand All @@ -29,11 +46,26 @@ impl Device {
pub fn linux(hostname: &str) -> Self {
Self {
hostname: hostname.to_string(),
name: None,
uid: None,
type_id: DeviceTypeId::Server,
type_label: DeviceTypeId::Server.label().to_string(),
os: Some(OsInfo {
name: "Linux".to_string(),
}),
}
}

/// Create the device for a gateway replica.
#[must_use]
pub fn gateway(hostname: &str, name: &str) -> Self {
Self {
name: Some(name.to_string()),
// Keep the replica identity opaque rather than encoding multiple fields in the UID.
uid: Some(hostname.to_string()),
..Self::linux(hostname)
}
}
}

#[cfg(test)]
Expand All @@ -47,4 +79,31 @@ mod tests {
assert_eq!(json["hostname"], "sandbox-abc123");
assert_eq!(json["os"]["name"], "Linux");
}

#[test]
fn device_emits_the_schema_required_type_id() {
let json = serde_json::to_value(Device::linux("sandbox-abc123")).unwrap();
assert_eq!(json["type_id"], DeviceTypeId::Server.as_u8());
assert_eq!(json["type"], "Server");
}

#[test]
fn device_round_trips() {
let device = Device::linux("sandbox-abc123");
let json = serde_json::to_value(&device).unwrap();
let decoded: Device = serde_json::from_value(json.clone()).unwrap();
assert_eq!(decoded, device);
assert_eq!(serde_json::to_value(&decoded).unwrap(), json);
}

#[test]
fn gateway_replicas_have_distinct_device_uids() {
let first = Device::gateway("openshell-gateway-0", "production");
let second = Device::gateway("openshell-gateway-1", "production");

assert_ne!(
first.uid, second.uid,
"gateway replicas must have distinct OCSF device UIDs"
);
}
}
Loading
Loading