diff --git a/Cargo.lock b/Cargo.lock index edc940ea68..b5864fdaba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4173,6 +4173,7 @@ dependencies = [ "serde_repr", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/crates/openshell-ocsf/Cargo.toml b/crates/openshell-ocsf/Cargo.toml index be91b1547a..69c7b7e0aa 100644 --- a/crates/openshell-ocsf/Cargo.toml +++ b/crates/openshell-ocsf/Cargo.toml @@ -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] diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index e63b2be88f..4a08652aa5 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -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 @@ -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 { + 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. @@ -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); + } } } @@ -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, } } @@ -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")); } diff --git a/crates/openshell-ocsf/src/ctx.rs b/crates/openshell-ocsf/src/ctx.rs index 6916c55215..8aaf71ddbb 100644 --- a/crates/openshell-ocsf/src/ctx.rs +++ b/crates/openshell-ocsf/src/ctx.rs @@ -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 = OnceLock::new(); @@ -21,6 +21,7 @@ static OCSF_CTX_FALLBACK: LazyLock = 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. diff --git a/crates/openshell-ocsf/src/enums/device_type.rs b/crates/openshell-ocsf/src/enums/device_type.rs new file mode 100644 index 0000000000..887950ec33 --- /dev/null +++ b/crates/openshell-ocsf/src/enums/device_type.rs @@ -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); + } +} diff --git a/crates/openshell-ocsf/src/enums/mod.rs b/crates/openshell-ocsf/src/enums/mod.rs index 96567f86d5..b9ae43229d 100644 --- a/crates/openshell-ocsf/src/enums/mod.rs +++ b/crates/openshell-ocsf/src/enums/mod.rs @@ -6,6 +6,7 @@ mod action; mod activity; mod auth; +mod device_type; mod disposition; mod http_method; mod launch; @@ -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; diff --git a/crates/openshell-ocsf/src/events/api_activity.rs b/crates/openshell-ocsf/src/events/api_activity.rs index 55c2f70d6d..cf7ed7580b 100644 --- a/crates/openshell-ocsf/src/events/api_activity.rs +++ b/crates/openshell-ocsf/src/events/api_activity.rs @@ -40,12 +40,16 @@ pub struct ApiActivityEvent { #[serde(default)] pub dst_endpoint: Option, - /// 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, - /// 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, } @@ -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)); + } } diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs index 345ea57175..fe1f60aa5a 100644 --- a/crates/openshell-ocsf/src/lib.rs +++ b/crates/openshell-ocsf/src/lib.rs @@ -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 --- @@ -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 --- diff --git a/crates/openshell-ocsf/src/objects/device.rs b/crates/openshell-ocsf/src/objects/device.rs index 4c42fb4a1f..8e6c7bff3e 100644 --- a/crates/openshell-ocsf/src/objects/device.rs +++ b/crates/openshell-ocsf/src/objects/device.rs @@ -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, + + /// Stable unique identifier for the device. + #[serde(skip_serializing_if = "Option::is_none")] + pub uid: Option, + + /// 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, @@ -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)] @@ -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" + ); + } } diff --git a/crates/openshell-ocsf/src/objects/metadata.rs b/crates/openshell-ocsf/src/objects/metadata.rs index 060f13ac6a..c580d0d840 100644 --- a/crates/openshell-ocsf/src/objects/metadata.rs +++ b/crates/openshell-ocsf/src/objects/metadata.rs @@ -18,7 +18,7 @@ pub struct Metadata { #[serde(skip_serializing_if = "Vec::is_empty")] pub profiles: Vec, - /// Unique event source identifier (sandbox ID). + /// Unique event identifier. #[serde(skip_serializing_if = "Option::is_none")] pub uid: Option, @@ -51,6 +51,16 @@ impl Product { version: Some(version.to_string()), } } + + /// Create the `OpenShell` Gateway product, for control-plane events. + #[must_use] + pub fn openshell_gateway(version: &str) -> Self { + Self { + name: "OpenShell Gateway".to_string(), + vendor_name: "OpenShell".to_string(), + version: Some(version.to_string()), + } + } } #[cfg(test)] diff --git a/crates/openshell-ocsf/tests/event_identity.rs b/crates/openshell-ocsf/tests/event_identity.rs new file mode 100644 index 0000000000..4e5ef2e7ef --- /dev/null +++ b/crates/openshell-ocsf/tests/event_identity.rs @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `metadata.uid` identifies the event; `container.uid` identifies the sandbox. + +use std::net::{IpAddr, Ipv4Addr}; + +use openshell_ocsf::{ + ActivityId, EventOrigin, NetworkActivityBuilder, OcsfEvent, SandboxContext, SeverityId, +}; + +fn sandbox_ctx() -> SandboxContext { + SandboxContext { + sandbox_id: "sb-1".to_string(), + sandbox_name: "agent-01".to_string(), + container_image: "ghcr.io/nvidia/openshell/sandbox:0.42.1".to_string(), + hostname: "openshell-sb-1".to_string(), + product_version: "0.42.1".to_string(), + proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + proxy_port: 8888, + origin: EventOrigin::Sandbox, + } +} + +fn gateway_ctx(sandbox_id: &str, sandbox_name: &str) -> SandboxContext { + SandboxContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: String::new(), + hostname: "openshell-gateway-0".to_string(), + product_version: "0.42.1".to_string(), + proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + proxy_port: 0, + origin: EventOrigin::Gateway { + name: "production-us-west".to_string(), + }, + } +} + +fn event(ctx: &SandboxContext) -> OcsfEvent { + NetworkActivityBuilder::new(ctx) + .activity(ActivityId::Open) + .severity(SeverityId::Medium) + .message("CONNECT api.example.com:443") + .build() +} + +#[test] +fn each_event_gets_its_own_metadata_uid() { + let ctx = sandbox_ctx(); + + let first = event(&ctx); + let second = event(&ctx); + + let first_uid = first.base().metadata.uid.clone().expect("uid is set"); + let second_uid = second.base().metadata.uid.clone().expect("uid is set"); + + assert!(!first_uid.is_empty()); + assert_ne!( + first_uid, second_uid, + "a shared uid invites a SIEM to dedup distinct events" + ); +} + +#[test] +fn metadata_uid_is_no_longer_the_sandbox_id() { + let event = event(&sandbox_ctx()); + + assert_ne!( + event.base().metadata.uid.as_deref(), + Some("sb-1"), + "metadata.uid is a per-event identifier, not a producer identifier" + ); +} + +#[test] +fn the_sandbox_id_is_carried_by_the_container() { + let json = event(&sandbox_ctx()).to_json().expect("serializes"); + + assert_eq!(json["container"]["uid"], "sb-1"); + assert_eq!(json["container"]["name"], "agent-01"); +} + +#[test] +fn a_gateway_event_about_a_sandbox_still_names_that_container() { + let json = event(&gateway_ctx("sb-7", "agent-07")) + .to_json() + .expect("serializes"); + + assert_eq!(json["container"]["uid"], "sb-7"); + assert_eq!(json["container"]["name"], "agent-07"); + assert_eq!(json["metadata"]["product"]["name"], "OpenShell Gateway"); +} + +#[test] +fn a_gateway_event_about_no_sandbox_omits_the_container() { + let json = event(&gateway_ctx("", "")).to_json().expect("serializes"); + + assert!( + json.get("container").is_none(), + "an event with no sandbox association has no container: {json}" + ); +} + +#[test] +fn a_container_without_an_image_omits_the_image() { + let json = event(&gateway_ctx("sb-7", "agent-07")) + .to_json() + .expect("serializes"); + + assert!( + json["container"].get("image").is_none(), + "an empty image reference is worse than none: {json}" + ); +} diff --git a/crates/openshell-ocsf/tests/gateway_context.rs b/crates/openshell-ocsf/tests/gateway_context.rs new file mode 100644 index 0000000000..a3fadb945b --- /dev/null +++ b/crates/openshell-ocsf/tests/gateway_context.rs @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway-origin events carry gateway identity, not sandbox identity. + +use std::net::{IpAddr, Ipv4Addr}; + +use openshell_ocsf::{ + ActivityId, AppLifecycleBuilder, ConfigStateChangeBuilder, EventOrigin, SandboxContext, + SeverityId, StateId, StatusId, +}; + +fn gateway_ctx() -> SandboxContext { + SandboxContext { + sandbox_id: String::new(), + sandbox_name: String::new(), + container_image: String::new(), + hostname: "openshell-gateway-0".to_string(), + product_version: "0.42.1".to_string(), + proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + proxy_port: 0, + origin: EventOrigin::Gateway { + name: "production-us-west".to_string(), + }, + } +} + +fn sandbox_ctx() -> SandboxContext { + SandboxContext { + sandbox_id: "sb-1".to_string(), + sandbox_name: "agent-01".to_string(), + container_image: "ghcr.io/nvidia/openshell/sandbox:0.42.1".to_string(), + hostname: "openshell-sb-1".to_string(), + product_version: "0.42.1".to_string(), + proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + proxy_port: 8888, + origin: EventOrigin::Sandbox, + } +} + +#[test] +fn gateway_events_report_the_gateway_product() { + let event = AppLifecycleBuilder::new(&gateway_ctx()) + .activity(ActivityId::Open) + .severity(SeverityId::Informational) + .message("gateway started") + .build(); + let json = event.to_json().unwrap(); + + assert_eq!(json["metadata"]["product"]["name"], "OpenShell Gateway"); + assert_eq!(json["metadata"]["product"]["vendor_name"], "OpenShell"); +} + +#[test] +fn sandbox_events_still_report_the_supervisor_product() { + let event = AppLifecycleBuilder::new(&sandbox_ctx()) + .activity(ActivityId::Open) + .severity(SeverityId::Informational) + .message("supervisor started") + .build(); + let json = event.to_json().unwrap(); + + assert_eq!( + json["metadata"]["product"]["name"], + "OpenShell Sandbox Supervisor" + ); +} + +#[test] +fn gateway_events_identify_the_device_by_operator_assigned_name() { + let event = ConfigStateChangeBuilder::new(&gateway_ctx()) + .state(StateId::Enabled, "reloaded") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message("TLS certificate config reloaded") + .build(); + let json = event.to_json().unwrap(); + + assert_eq!(json["device"]["name"], "production-us-west"); + assert_eq!(json["device"]["uid"], "openshell-gateway-0"); + assert_eq!(json["device"]["hostname"], "openshell-gateway-0"); +} + +#[test] +fn gateway_events_omit_the_container_object() { + let event = ConfigStateChangeBuilder::new(&gateway_ctx()) + .state(StateId::Enabled, "reloaded") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message("TLS certificate config reloaded") + .build(); + let json = event.to_json().unwrap(); + + assert!( + json.get("container").is_none(), + "a gateway event without a sandbox association should omit container: {json}" + ); +} + +#[test] +fn sandbox_events_still_carry_their_container() { + let event = ConfigStateChangeBuilder::new(&sandbox_ctx()) + .state(StateId::Enabled, "loaded") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message("policy loaded") + .build(); + let json = event.to_json().unwrap(); + + assert_eq!(json["container"]["name"], "agent-01"); + assert_eq!(json["container"]["uid"], "sb-1"); + assert!(json["device"].get("name").is_none()); +} diff --git a/crates/openshell-ocsf/tests/roundtrip.rs b/crates/openshell-ocsf/tests/roundtrip.rs index 1c8a8bb307..e3f3e095e0 100644 --- a/crates/openshell-ocsf/tests/roundtrip.rs +++ b/crates/openshell-ocsf/tests/roundtrip.rs @@ -12,10 +12,10 @@ use std::net::{IpAddr, Ipv4Addr}; use openshell_ocsf::{ ActionId, ActivityId, AiModel, ApiActivityBuilder, AppLifecycleBuilder, Attack, AuthTypeId, BaseEventBuilder, ConfidenceId, ConfigStateChangeBuilder, ConnectionInfo, - DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, HttpActivityBuilder, HttpMethod, - HttpRequest, HttpResponse, LaunchTypeId, NetworkActivityBuilder, OcsfEvent, Process, - ProcessActivityBuilder, RiskLevelId, SandboxContext, SecurityLevelId, SeverityId, - SshActivityBuilder, StateId, StatusId, Url, + DetectionFindingBuilder, DispositionId, Endpoint, EventOrigin, FindingInfo, + HttpActivityBuilder, HttpMethod, HttpRequest, HttpResponse, LaunchTypeId, + NetworkActivityBuilder, OcsfEvent, Process, ProcessActivityBuilder, RiskLevelId, + SandboxContext, SecurityLevelId, SeverityId, SshActivityBuilder, StateId, StatusId, Url, }; fn ctx() -> SandboxContext { @@ -27,6 +27,7 @@ fn ctx() -> SandboxContext { product_version: "0.42.1".to_string(), proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), proxy_port: 8888, + origin: EventOrigin::Sandbox, } } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 41c6ca3c94..4c34cb0922 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -150,6 +150,7 @@ pub async fn run_sandbox( product_version: openshell_core::VERSION.to_string(), proxy_ip: std::net::IpAddr::from([127, 0, 0, 1]), proxy_port: 3128, + origin: openshell_ocsf::EventOrigin::Sandbox, }) { debug!("OCSF context already initialized, keeping existing"); } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 33008774b9..7f85fcacd8 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -504,6 +504,15 @@ async fn run_from_args( ) -> Result<()> { let prepared = prepare_server_config_with_drivers(&mut args, &matches, &compute_drivers)?; + // Initialize OCSF identity before tracing can emit gateway events. + let gateway_identity = crate::gateway_ocsf::GatewayIdentity { + name: prepared.config.name.clone(), + hostname: crate::compute::lease::replica_id(), + }; + if !crate::gateway_ocsf::set_identity(gateway_identity) { + tracing::debug!("gateway OCSF identity already initialized, keeping existing"); + } + let tracing_log_bus = TracingLogBus::new(); let otlp_config = prepared .config_file diff --git a/crates/openshell-server/src/gateway_ocsf.rs b/crates/openshell-server/src/gateway_ocsf.rs new file mode 100644 index 0000000000..1e0c7de2af --- /dev/null +++ b/crates/openshell-server/src/gateway_ocsf.rs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Process-wide OCSF identity for gateway-origin events. +//! +//! Gateway events are emitted from places with no access to the server config +//! (the TLS reload watcher, the service router), so the identity is resolved +//! once at startup rather than threaded through all of them. + +use std::sync::OnceLock; + +use openshell_ocsf::{EventOrigin, SandboxContext}; + +/// Identity shared by every gateway-origin OCSF event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayIdentity { + /// Operator-assigned gateway name, shared across replicas of one install. + pub name: String, + /// Per-replica hostname (the pod name under Kubernetes). + pub hostname: String, +} + +static IDENTITY: OnceLock = OnceLock::new(); + +/// Initialise the process-wide gateway identity. +/// +/// Returns `false` if it was already set; the caller may log and continue. +pub fn set_identity(identity: GatewayIdentity) -> bool { + IDENTITY.set(identity).is_ok() +} + +/// Return the gateway identity, falling back to placeholders when unset (in +/// tests, and in any code path that runs before startup completes). +#[must_use] +pub fn identity() -> GatewayIdentity { + IDENTITY.get().cloned().unwrap_or_else(|| GatewayIdentity { + name: openshell_core::config::DEFAULT_GATEWAY_NAME.to_string(), + hostname: "openshell-gateway".to_string(), + }) +} + +/// Build the OCSF context for a gateway-origin event. +/// +/// `sandbox_id` and `sandbox_name` describe the sandbox the event is *about*, +/// and may be empty. The emitting device is always the gateway. +#[must_use] +pub fn context(sandbox_id: &str, sandbox_name: &str) -> SandboxContext { + let identity = identity(); + SandboxContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: String::new(), + hostname: identity.hostname, + product_version: openshell_core::VERSION.to_string(), + proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + proxy_port: 0, + origin: EventOrigin::Gateway { + name: identity.name, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_marks_events_as_gateway_origin() { + let ctx = context("sb-1", "agent-01"); + + assert!(matches!(ctx.origin, EventOrigin::Gateway { .. })); + assert_eq!(ctx.sandbox_id, "sb-1"); + assert_eq!(ctx.sandbox_name, "agent-01"); + } + + #[test] + fn gateway_context_produces_gateway_product_and_no_container() { + let ctx = context("", ""); + + assert_eq!(ctx.metadata(&[]).product.name, "OpenShell Gateway"); + assert!(ctx.container().is_none()); + } + + #[test] + fn identity_falls_back_when_unset() { + let identity = identity(); + assert!(!identity.name.is_empty()); + assert!(!identity.hostname.is_empty()); + } +} diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index e46b2ae8ae..681a4fd128 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -50,14 +50,11 @@ use openshell_core::telemetry::{ LifecycleOperation, LifecycleResource, PolicyDecisionOperation, TelemetryOutcome, }; use openshell_core::{ - VERSION, endpoint_path::EndpointPathPattern, host_pattern::{host_matches, host_patterns_overlap}, settings::{self, SettingValueKind}, }; -use openshell_ocsf::{ - ConfigStateChangeBuilder, OcsfEvent, SandboxContext, SeverityId, StateId, StatusId, -}; +use openshell_ocsf::{ConfigStateChangeBuilder, OcsfEvent, SeverityId, StateId, StatusId}; use openshell_policy::{ PolicyMergeOp, ProviderPolicyLayer, canonicalize_advisor_add_rule, compose_effective_policy, merge_policy, policy_covers_rule, serialize_sandbox_policy, strip_provider_rule_names, @@ -74,7 +71,7 @@ use openshell_prover::{ use prost::Message; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap, HashSet}; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::IpAddr; use std::sync::Arc; use tonic::{Request, Response, Status}; use tracing::{debug, info, warn}; @@ -217,15 +214,7 @@ fn build_gateway_policy_audit_event( policy_hash: &str, extra_fields: &[(&str, String)], ) -> OcsfEvent { - let ctx = SandboxContext { - sandbox_id: sandbox_id.to_string(), - sandbox_name: sandbox_name.to_string(), - container_image: "openshell/gateway".to_string(), - hostname: "openshell-gateway".to_string(), - product_version: VERSION.to_string(), - proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), - proxy_port: 0, - }; + let ctx = crate::gateway_ocsf::context(sandbox_id, sandbox_name); let mut builder = ConfigStateChangeBuilder::new(&ctx) .state(StateId::Other, state_label) .severity(SeverityId::Informational) diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index a8c8afdf08..3eec00540c 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -21,6 +21,7 @@ pub mod config_file; mod credentials; mod defaults; mod gateway_listener; +mod gateway_ocsf; mod grpc; mod http; mod inference; diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 1ca575c059..5ca97f9735 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -9,15 +9,14 @@ use axum::{ }; use http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header}; use hyper_util::rt::TokioIo; +use openshell_core::ObjectId; use openshell_core::config::ServiceRoutingConfig; use openshell_core::proto::{Sandbox, SandboxPhase, ServiceEndpoint, TcpRelayTarget, relay_open}; -use openshell_core::{ObjectId, VERSION}; use openshell_ocsf::{ ActionId, ActivityId, ConfigStateChangeBuilder, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, HttpResponse as OcsfHttpResponse, NetworkActivityBuilder, OcsfEvent, SandboxContext, SeverityId, StateId, StatusId, Url as OcsfUrl, }; -use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time::Duration; use tokio::io::AsyncWriteExt; @@ -796,15 +795,7 @@ fn emit_gateway_ocsf_event(event: OcsfEvent) { } fn gateway_ocsf_ctx(sandbox_id: &str, sandbox_name: &str) -> SandboxContext { - SandboxContext { - sandbox_id: sandbox_id.to_string(), - sandbox_name: sandbox_name.to_string(), - container_image: "openshell/gateway".to_string(), - hostname: "openshell-gateway".to_string(), - product_version: VERSION.to_string(), - proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), - proxy_port: 0, - } + crate::gateway_ocsf::context(sandbox_id, sandbox_name) } fn endpoint_name(endpoint: &ServiceEndpoint) -> String { @@ -1337,7 +1328,11 @@ mod tests { ); assert_eq!( - event.base().metadata.uid.as_deref(), + event + .base() + .container + .as_ref() + .and_then(|container| container.uid.as_deref()), Some("sandbox-1"), "resolved sandbox id should reach the event" ); diff --git a/crates/openshell-server/src/tls.rs b/crates/openshell-server/src/tls.rs index e3b48b9fbb..7b8323cf5c 100644 --- a/crates/openshell-server/src/tls.rs +++ b/crates/openshell-server/src/tls.rs @@ -464,15 +464,7 @@ fn load_key(path: &Path) -> Result> { /// Build an OCSF context for gateway-level (non-sandbox) events. fn tls_ocsf_ctx() -> SandboxContext { - SandboxContext { - sandbox_id: String::new(), - sandbox_name: String::new(), - container_image: "openshell/gateway".to_string(), - hostname: "openshell-gateway".to_string(), - product_version: openshell_core::VERSION.to_string(), - proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - proxy_port: 0, - } + crate::gateway_ocsf::context("", "") } #[cfg(test)] diff --git a/crates/openshell-server/src/tracing_bus.rs b/crates/openshell-server/src/tracing_bus.rs index 15c7908e34..3cf55dfbba 100644 --- a/crates/openshell-server/src/tracing_bus.rs +++ b/crates/openshell-server/src/tracing_bus.rs @@ -180,7 +180,7 @@ where let (visitor_sandbox_id, visitor_message) = if meta.target() == OCSF_TARGET { openshell_ocsf::clone_current_event().map_or((None, None), |ocsf_event| { ( - ocsf_event.base().metadata.uid.clone(), + ocsf_sandbox_id(&ocsf_event), Some(ocsf_event.format_shorthand()), ) }) @@ -218,6 +218,17 @@ where } } +/// The sandbox an event concerns, if any. +/// +/// `container.uid` rather than `metadata.uid`: the latter identifies the event. +fn ocsf_sandbox_id(event: &openshell_ocsf::OcsfEvent) -> Option { + event + .base() + .container + .as_ref() + .and_then(|container| container.uid.clone()) +} + #[derive(Debug, Default)] struct LogVisitor { sandbox_id: Option, @@ -353,6 +364,7 @@ mod tests { product_version: "0.0.0".to_string(), proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), proxy_port: 0, + origin: openshell_ocsf::EventOrigin::Sandbox, } } diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 6305653f6a..bd799a6ab9 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -1095,6 +1095,7 @@ mod tests { product_version: "0".into(), proxy_ip: [127, 0, 0, 1].into(), proxy_port: 3128, + origin: openshell_ocsf::EventOrigin::Sandbox, }; let eval = L7EvalContext { diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 2697fedb3c..e422cb4a42 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -2925,6 +2925,7 @@ mod tests { product_version: "0".into(), proxy_ip: [127, 0, 0, 1].into(), proxy_port: 3128, + origin: openshell_ocsf::EventOrigin::Sandbox, }; let eval = L7EvalContext { diff --git a/crates/openshell-supervisor-process/src/log_push.rs b/crates/openshell-supervisor-process/src/log_push.rs index 256fbff4a4..a80e2dee4e 100644 --- a/crates/openshell-supervisor-process/src/log_push.rs +++ b/crates/openshell-supervisor-process/src/log_push.rs @@ -331,6 +331,7 @@ mod tests { product_version: "0.0.0".to_string(), proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), proxy_port: 8888, + origin: openshell_ocsf::EventOrigin::Sandbox, } } diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index 98a3c0497b..7108e5c8e8 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -904,6 +904,7 @@ mod ocsf_event_tests { product_version: "0.0.1".into(), proxy_ip: "127.0.0.1".parse().unwrap(), proxy_port: 3128, + origin: openshell_ocsf::EventOrigin::Sandbox, } }