From c493338b7d75598b04a548b8a36dbf6276b03498 Mon Sep 17 00:00:00 2001 From: Kris Hicks Date: Fri, 28 Aug 2026 12:39:40 -0700 Subject: [PATCH] fix(ocsf): make exported events valid and attributable Exported OCSF records are now schema-valid and can be reliably attributed. Gateway-origin records identify the configured gateway and emitting replica, while sandbox identity remains attached to the affected container and every event receives its own unique ID. API activity records retain their action and disposition when consumed and re-exported. Events also include the required device classification and omit empty container or image objects instead of reporting misleading identities. Refs #1055 Signed-off-by: Kris Hicks --- Cargo.lock | 1 + crates/openshell-ocsf/Cargo.toml | 1 + crates/openshell-ocsf/src/builders/mod.rs | 52 ++++++-- crates/openshell-ocsf/src/ctx.rs | 3 +- .../openshell-ocsf/src/enums/device_type.rs | 57 +++++++++ crates/openshell-ocsf/src/enums/mod.rs | 2 + .../openshell-ocsf/src/events/api_activity.rs | 30 ++++- crates/openshell-ocsf/src/lib.rs | 8 +- crates/openshell-ocsf/src/objects/device.rs | 59 +++++++++ crates/openshell-ocsf/src/objects/metadata.rs | 12 +- crates/openshell-ocsf/tests/event_identity.rs | 115 ++++++++++++++++++ .../openshell-ocsf/tests/gateway_context.rs | 113 +++++++++++++++++ crates/openshell-ocsf/tests/roundtrip.rs | 9 +- crates/openshell-sandbox/src/lib.rs | 1 + crates/openshell-server/src/cli.rs | 9 ++ crates/openshell-server/src/gateway_ocsf.rs | 90 ++++++++++++++ crates/openshell-server/src/grpc/policy.rs | 17 +-- crates/openshell-server/src/lib.rs | 1 + .../openshell-server/src/service_routing.rs | 19 ++- crates/openshell-server/src/tls.rs | 10 +- crates/openshell-server/src/tracing_bus.rs | 14 ++- .../src/l7/middleware.rs | 1 + .../src/l7/relay.rs | 1 + .../src/log_push.rs | 1 + .../src/supervisor_session.rs | 1 + 25 files changed, 566 insertions(+), 61 deletions(-) create mode 100644 crates/openshell-ocsf/src/enums/device_type.rs create mode 100644 crates/openshell-ocsf/tests/event_identity.rs create mode 100644 crates/openshell-ocsf/tests/gateway_context.rs create mode 100644 crates/openshell-server/src/gateway_ocsf.rs 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, } }