diff --git a/crates/openshell-ocsf-validate/README.md b/crates/openshell-ocsf-validate/README.md index 306e09aa69..3246e1b743 100644 --- a/crates/openshell-ocsf-validate/README.md +++ b/crates/openshell-ocsf-validate/README.md @@ -4,7 +4,7 @@ Validates OCSF events against JSON Schemas exported by the OCSF schema server. OCSF validity depends on the profiles an event declares in `metadata.profiles`, and an exported JSON Schema covers one fixed set of profiles. `schemas/v/` therefore holds one schema per event class and profile combination, named `@+.json` with profiles sorted. `validate_event` picks the schema from the event's `metadata.version`, `class_uid`, and declared profiles, and fails when none is vendored. -The schemas check required attributes, `at_least_one` constraints, undefined attributes, types, and enum values, including the class and category identifiers. The schemas type `type_uid` only as an integer, so `validate_event` also checks that it equals `class_uid * 100 + activity_id`. The top-level `container` object is accepted although OCSF does not define it at the event level (NVIDIA/OpenShell#4915). +The schemas check required attributes, `at_least_one` constraints, undefined attributes, types, and enum values, including the class and category identifiers. The schemas type `type_uid` only as an integer, so `validate_event` also checks that it equals `class_uid * 100 + activity_id`. ## Updating diff --git a/crates/openshell-ocsf-validate/src/lib.rs b/crates/openshell-ocsf-validate/src/lib.rs index 7c6d7bf49a..a71f883177 100644 --- a/crates/openshell-ocsf-validate/src/lib.rs +++ b/crates/openshell-ocsf-validate/src/lib.rs @@ -68,12 +68,8 @@ struct Schema { impl Schema { fn validator(&self) -> &jsonschema::Validator { - self.validator.get_or_init(|| { - let mut json = self.json.clone(); - // OCSF defines no top-level `container`; OpenShell emits it (NVIDIA/OpenShell#4915). - json["properties"]["container"] = Value::Bool(true); - jsonschema::draft7::new(&json).expect("vendored schema compiles") - }) + self.validator + .get_or_init(|| jsonschema::draft7::new(&self.json).expect("vendored schema compiles")) } } @@ -319,10 +315,10 @@ mod tests { } #[test] - fn accepts_top_level_container() { + fn rejects_top_level_container() { let mut event = base_event(); - event["container"] = json!({"name": "sandbox", "image": {"name": "example/sandbox:test"}}); - validate_event(&event).unwrap(); + event["container"] = json!({"name": "sandbox"}); + assert_violation(&event, "", "container"); } #[test] diff --git a/crates/openshell-ocsf/src/builders/api_activity.rs b/crates/openshell-ocsf/src/builders/api_activity.rs index 4cbe5b2b86..8c866f6b4a 100644 --- a/crates/openshell-ocsf/src/builders/api_activity.rs +++ b/crates/openshell-ocsf/src/builders/api_activity.rs @@ -295,7 +295,7 @@ mod tests { json["metadata"]["profiles"], serde_json::json!(["host", "security_control"]) ); - assert!(json.get("container").is_none()); + assert!(json["device"].get("container").is_none()); } #[test] diff --git a/crates/openshell-ocsf/src/builders/authentication.rs b/crates/openshell-ocsf/src/builders/authentication.rs index 3916cb95b8..a959ee592d 100644 --- a/crates/openshell-ocsf/src/builders/authentication.rs +++ b/crates/openshell-ocsf/src/builders/authentication.rs @@ -201,7 +201,7 @@ mod tests { json["metadata"]["profiles"], serde_json::json!(["security_control", "host"]) ); - assert!(json.get("container").is_none()); + assert!(json["device"].get("container").is_none()); assert!(json["metadata"].get("correlation_uid").is_none()); } diff --git a/crates/openshell-ocsf/src/builders/finding.rs b/crates/openshell-ocsf/src/builders/finding.rs index ccb45d29a8..182bbf277d 100644 --- a/crates/openshell-ocsf/src/builders/finding.rs +++ b/crates/openshell-ocsf/src/builders/finding.rs @@ -214,7 +214,7 @@ mod tests { ); assert_eq!(json["metadata"]["profiles"], profiles, "{sandbox_id:?}"); assert_eq!( - json.get("container").is_some(), + json["device"].get("container").is_some(), !sandbox_id.is_empty(), "{sandbox_id:?}" ); diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index b86e39a10a..e46f53b480 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -120,7 +120,7 @@ use std::sync::Arc; use crate::OCSF_VERSION; use crate::enums::StatusId; use crate::events::base_event::BaseEventData; -use crate::objects::{Container, Device, Endpoint, Image, Metadata, Product}; +use crate::objects::{Container, Device, Endpoint, Metadata, Product}; /// Which `OpenShell` component produced an event. #[derive(Debug, Clone, PartialEq, Eq)] @@ -144,7 +144,7 @@ pub struct EventContext { pub sandbox_id: String, /// Sandbox display name. pub sandbox_name: String, - /// Container image reference. + /// Container image reference. Not emitted: OCSF requires an image `uid`. pub container_image: String, /// Device hostname. pub hostname: String, @@ -225,9 +225,6 @@ impl EventContext { Some(Container { name: self.sandbox_name.clone(), uid: Some(self.sandbox_id.clone()), - image: (!self.container_image.is_empty()).then(|| Image { - name: self.container_image.clone(), - }), }) } @@ -248,7 +245,7 @@ impl EventContext { } /// Apply the fields common to every builder's `build()` method onto `base`: - /// optional status, optional message, device info, and container info. + /// optional status, optional message, and device info with its container. pub fn apply_common_fields( &self, base: &mut BaseEventData, @@ -261,10 +258,7 @@ impl EventContext { if let Some(m) = message { base.set_message(m); } - base.set_device(self.device()); - if let Some(container) = self.container() { - base.set_container(container); - } + base.set_device(self.device().with_container(self.container())); } } diff --git a/crates/openshell-ocsf/src/builders/network.rs b/crates/openshell-ocsf/src/builders/network.rs index 1e182af674..950cc7e1c8 100644 --- a/crates/openshell-ocsf/src/builders/network.rs +++ b/crates/openshell-ocsf/src/builders/network.rs @@ -289,7 +289,7 @@ mod tests { assert_eq!(json["dst_endpoint"]["domain"], "api.example.com"); assert_eq!(json["actor"]["process"]["name"], "python3"); assert_eq!(json["firewall_rule"]["name"], "default-egress"); - assert_eq!(json["container"]["name"], "my-sandbox"); + assert_eq!(json["device"]["container"]["name"], "my-sandbox"); assert_eq!(json["device"]["hostname"], "sandbox-abc123"); assert_eq!(json["is_src_dst_assignment_known"], true); } diff --git a/crates/openshell-ocsf/src/events/base_event.rs b/crates/openshell-ocsf/src/events/base_event.rs index 78bd927612..03a2b6bdd3 100644 --- a/crates/openshell-ocsf/src/events/base_event.rs +++ b/crates/openshell-ocsf/src/events/base_event.rs @@ -63,10 +63,6 @@ pub struct BaseEventData { #[serde(skip_serializing_if = "Option::is_none")] pub device: Option, - /// Container info (Container profile). - #[serde(skip_serializing_if = "Option::is_none")] - pub container: Option, - /// AI model info (`ai_operation` profile, OCSF v1.8.0+). #[serde(skip_serializing_if = "Option::is_none")] pub ai_model: Option, @@ -113,9 +109,6 @@ impl Serialize for BaseEventData { if let Some(ref device) = self.device { map.serialize_entry("device", device)?; } - if let Some(ref container) = self.container { - map.serialize_entry("container", container)?; - } if let Some(ref ai_model) = self.ai_model { map.serialize_entry("ai_model", ai_model)?; } @@ -160,7 +153,6 @@ impl BaseEventData { status_detail: None, metadata, device: None, - container: None, ai_model: None, unmapped: None, } @@ -191,9 +183,10 @@ impl BaseEventData { self.device = Some(device); } - /// Set container info. - pub fn set_container(&mut self, container: Container) { - self.container = Some(container); + /// The sandbox the event concerns, carried by `device.container`. + #[must_use] + pub fn container(&self) -> Option<&Container> { + self.device.as_ref()?.container.as_ref() } /// Set AI model info (`ai_operation` profile). diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs index 661b4c6f5a..09dc2a1338 100644 --- a/crates/openshell-ocsf/src/lib.rs +++ b/crates/openshell-ocsf/src/lib.rs @@ -53,8 +53,8 @@ pub use enums::{ // --- Object types --- pub use objects::{ Actor, AiModel, Api, Attack, ConnectionInfo, Container, Device, Endpoint, Evidence, - FindingInfo, FirewallRule, HttpRequest, HttpResponse, Image, Metadata, OsInfo, Process, - Product, Remediation, ResourceDetails, Service, Tactic, Technique, Url, User, + FindingInfo, FirewallRule, HttpRequest, HttpResponse, Metadata, OsInfo, Process, Product, + Remediation, ResourceDetails, Service, Tactic, Technique, Url, User, }; // --- Sanitizers --- diff --git a/crates/openshell-ocsf/src/objects/container.rs b/crates/openshell-ocsf/src/objects/container.rs index 7d55224579..efe5ed122c 100644 --- a/crates/openshell-ocsf/src/objects/container.rs +++ b/crates/openshell-ocsf/src/objects/container.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! OCSF `container` and `image` objects. +//! OCSF `container` object. use serde::{Deserialize, Serialize}; @@ -14,17 +14,6 @@ pub struct Container { /// Container unique identifier. #[serde(skip_serializing_if = "Option::is_none")] pub uid: Option, - - /// Container image. - #[serde(skip_serializing_if = "Option::is_none")] - pub image: Option, -} - -/// OCSF Image object. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Image { - /// Image name (e.g., "ghcr.io/openshell/sandbox:latest"). - pub name: String, } #[cfg(test)] @@ -36,15 +25,11 @@ mod tests { let container = Container { name: "my-sandbox".to_string(), uid: Some("sandbox-abc123".to_string()), - image: Some(Image { - name: "ghcr.io/openshell/sandbox:latest".to_string(), - }), }; let json = serde_json::to_value(&container).unwrap(); assert_eq!(json["name"], "my-sandbox"); assert_eq!(json["uid"], "sandbox-abc123"); - assert_eq!(json["image"]["name"], "ghcr.io/openshell/sandbox:latest"); } #[test] @@ -52,10 +37,8 @@ mod tests { let container = Container { name: "test".to_string(), uid: None, - image: None, }; let json = serde_json::to_value(&container).unwrap(); assert!(json.get("uid").is_none()); - assert!(json.get("image").is_none()); } } diff --git a/crates/openshell-ocsf/src/objects/device.rs b/crates/openshell-ocsf/src/objects/device.rs index 85bb24065d..9b7e2ec606 100644 --- a/crates/openshell-ocsf/src/objects/device.rs +++ b/crates/openshell-ocsf/src/objects/device.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::enums::{DeviceTypeId, OsTypeId}; +use crate::objects::Container; /// OCSF Device object. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -31,6 +32,10 @@ pub struct Device { /// Operating system info. #[serde(skip_serializing_if = "Option::is_none")] pub os: Option, + + /// Sandbox the event concerns (`container` profile). + #[serde(skip_serializing_if = "Option::is_none")] + pub container: Option, } /// OCSF OS Info object. @@ -78,30 +83,36 @@ impl OsInfo { } impl Device { - /// Create a Linux sandbox device with the given hostname. - #[must_use] - pub fn linux(hostname: &str) -> Self { + /// Create a sandbox device with the given hostname for a `std::env::consts::OS` value. + fn sandbox(hostname: &str, os: &str) -> Self { Self { hostname: hostname.to_string(), name: None, uid: None, type_id: DeviceTypeId::Other, type_label: "Sandbox".to_string(), - os: Some(OsInfo::for_target("linux")), + os: Some(OsInfo::for_target(os)), + container: None, } } + /// Create a Linux sandbox device with the given hostname. + #[must_use] + pub fn linux(hostname: &str) -> Self { + Self::sandbox(hostname, "linux") + } + /// Create a Windows device with the given hostname. #[must_use] pub fn windows(hostname: &str) -> Self { - Self { - hostname: hostname.to_string(), - name: None, - uid: None, - type_id: DeviceTypeId::Other, - type_label: "Sandbox".to_string(), - os: Some(OsInfo::for_target("windows")), - } + Self::sandbox(hostname, "windows") + } + + /// Attach the sandbox the event concerns. + #[must_use] + pub fn with_container(mut self, container: Option) -> Self { + self.container = container; + self } /// Create a device stamped with the OS this build is running on. @@ -132,6 +143,7 @@ impl Device { // Operators assign a unique name per installation; replicas share this UID. uid: Some(name.to_string()), os: Some(OsInfo::for_target(std::env::consts::OS)), + container: None, } } } diff --git a/crates/openshell-ocsf/src/objects/mod.rs b/crates/openshell-ocsf/src/objects/mod.rs index 9025834bfe..9a6c8fc539 100644 --- a/crates/openshell-ocsf/src/objects/mod.rs +++ b/crates/openshell-ocsf/src/objects/mod.rs @@ -23,7 +23,7 @@ pub use ai_model::AiModel; pub use api::Api; pub use attack::{Attack, Tactic, Technique}; pub use connection::ConnectionInfo; -pub use container::{Container, Image}; +pub use container::Container; pub use device::{Device, OsInfo}; pub use endpoint::Endpoint; pub use finding::{Evidence, FindingInfo, Remediation}; diff --git a/crates/openshell-ocsf/tests/event_identity.rs b/crates/openshell-ocsf/tests/event_identity.rs index f876d3c2ee..4471ec9be8 100644 --- a/crates/openshell-ocsf/tests/event_identity.rs +++ b/crates/openshell-ocsf/tests/event_identity.rs @@ -1,7 +1,7 @@ // 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. +//! `metadata.uid` identifies the event; `device.container.uid` identifies the sandbox. use std::net::{IpAddr, Ipv4Addr}; @@ -56,14 +56,17 @@ fn the_sandbox_id_is_carried_by_the_container() { .to_json() .expect("serializes"); - assert_eq!(json["container"]["uid"], "sb-1"); - assert_eq!(json["container"]["name"], "agent-01"); + assert_eq!(json["device"]["container"]["uid"], "sb-1"); + assert_eq!(json["device"]["container"]["name"], "agent-01"); + assert!(json.get("container").is_none()); } #[test] -fn a_container_without_an_image_omits_the_image() { - let json = event(&sandbox_ctx("")).to_json().expect("serializes"); - assert!(json["container"].get("image").is_none()); +fn the_container_omits_the_image_which_ocsf_requires_an_id_for() { + let json = event(&sandbox_ctx("ghcr.io/nvidia/openshell/sandbox:0.42.1")) + .to_json() + .expect("serializes"); + assert!(json["device"]["container"].get("image").is_none()); } #[test] diff --git a/crates/openshell-ocsf/tests/gateway_context.rs b/crates/openshell-ocsf/tests/gateway_context.rs index f96f4f3cef..d60000d417 100644 --- a/crates/openshell-ocsf/tests/gateway_context.rs +++ b/crates/openshell-ocsf/tests/gateway_context.rs @@ -127,7 +127,7 @@ fn gateway_events_omit_the_container_object() { let json = event.to_json().unwrap(); assert!( - json.get("container").is_none(), + json["device"].get("container").is_none(), "a gateway event without a sandbox association should omit container: {json}" ); } @@ -142,7 +142,7 @@ fn supervisor_events_still_carry_their_sandbox_container() { .build(); let json = event.to_json().unwrap(); - assert_eq!(json["container"]["name"], "agent-01"); - assert_eq!(json["container"]["uid"], "sb-1"); + assert_eq!(json["device"]["container"]["name"], "agent-01"); + assert_eq!(json["device"]["container"]["uid"], "sb-1"); assert!(json["device"].get("name").is_none()); } diff --git a/crates/openshell-otel/src/ocsf.rs b/crates/openshell-otel/src/ocsf.rs index 680b4c1e3e..ae40269ebe 100644 --- a/crates/openshell-otel/src/ocsf.rs +++ b/crates/openshell-otel/src/ocsf.rs @@ -391,8 +391,7 @@ fn log_record( record.add_attribute("ocsf.metadata.uid", uid.clone()); } if let Some(sandbox_id) = base - .container - .as_ref() + .container() .and_then(|container| container.uid.clone()) .filter(|id| !id.is_empty()) { diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 279329c666..050f976dee 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -21557,8 +21557,7 @@ mod tests { assert_eq!(events.len(), 2); for event in &events { assert_eq!(event["metadata"]["product"]["name"], "OpenShell Gateway"); - assert_eq!(event["container"]["uid"], "sb-123"); - assert!(event["container"].get("image").is_none()); + assert_eq!(event["device"]["container"]["uid"], "sb-123"); } assert_eq!(events[0]["unmapped"]["policy_hash"], "sha256:manual"); assert!(events[0]["unmapped"].get("auto").is_none()); @@ -21669,7 +21668,7 @@ mod tests { let rejected = rejected_events(&events); assert_eq!(rejected.len(), 1, "events: {events:?}"); let event = rejected[0]; - assert_eq!(event["container"]["uid"], "sb-reject-audit"); + assert_eq!(event["device"]["container"]["uid"], "sb-reject-audit"); assert_eq!(event["unmapped"]["chunk_id"], chunk_id); assert_eq!( event["unmapped"]["rejection_reason"], diff --git a/crates/openshell-server/src/ocsf_log.rs b/crates/openshell-server/src/ocsf_log.rs index 6ecf9a790e..fd34f71daf 100644 --- a/crates/openshell-server/src/ocsf_log.rs +++ b/crates/openshell-server/src/ocsf_log.rs @@ -513,7 +513,7 @@ mod tests { "TLS certificate config reloaded successfully" ); assert_eq!(event["metadata"]["product"]["name"], "OpenShell Gateway"); - assert!(event.get("container").is_none()); + assert!(event["device"].get("container").is_none()); } #[test] diff --git a/crates/openshell-server/src/tracing_bus.rs b/crates/openshell-server/src/tracing_bus.rs index 522a004965..5d61fe4aee 100644 --- a/crates/openshell-server/src/tracing_bus.rs +++ b/crates/openshell-server/src/tracing_bus.rs @@ -353,8 +353,7 @@ where // affected sandbox, not by the gateway that produced the event. visitor.sandbox_id = ocsf .base() - .container - .as_ref() + .container() .and_then(|container| container.uid.clone()) .filter(|id| !id.is_empty()); visitor.message = Some(ocsf.format_shorthand()); diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index f6c5d306c8..dca2a8e92f 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -145,7 +145,7 @@ Gateway-produced records use the `OpenShell Gateway` product identity; superviso For gateway-produced records, `device.os.name` and `device.os.type_id` identify the OS running the gateway process: Linux (200), Windows (100), or macOS (300). Linux supervisor records continue to report Linux, regardless of the gateway's OS. A native macOS gateway therefore reports macOS while its Linux sandboxes report Linux; a gateway running inside a Linux container reports Linux, even on a Mac host. -`metadata.uid` uniquely identifies an event and stays unchanged when that record is serialized again. Use `container.uid` to associate the event with its sandbox, not `metadata.uid`. Records without a sandbox association omit the container; unknown images are omitted rather than represented by an empty image name. +`metadata.uid` uniquely identifies an event and stays unchanged when that record is serialized again. Use `device.container.uid` to associate the event with its sandbox, not `metadata.uid`. The sandbox name is `device.container.name`. Records without a sandbox association omit the container. The container carries no image, because OCSF requires an image ID that OpenShell does not know. Some fields depend on the event class: @@ -153,7 +153,7 @@ Some fields depend on the event class: - Process Activity records from the sandbox name the `openshell-sandbox` launcher as `actor.process`. - `connection_info` carries `direction_id` and `direction`. OpenShell reports Unknown (`0`) where it does not track the direction. -Each JSONL file line, or the payload after the stderr marker, is a complete OCSF v1.8.0 JSON object. Records validate against OCSF v1.8.0 except for the top-level `container` object, which OCSF does not define at the event level. Here is an example of a network connection event: +Each JSONL file line, or the payload after the stderr marker, is a complete OCSF v1.8.0 JSON object. Records validate against OCSF v1.8.0. Here is an example of a network connection event: ```json { @@ -246,12 +246,12 @@ And a denied connection, produced when `curl https://github.com` runs in a sandb "name": "Linux", "type_id": 200, "type": "Linux" + }, + "container": { + "name": "docs-deny", + "uid": "3949f6fc-2b84-45b2-a631-46debe41d84f" } }, - "container": { - "name": "docs-deny", - "uid": "3949f6fc-2b84-45b2-a631-46debe41d84f" - }, "dst_endpoint": { "domain": "github.com", "ip": "198.18.0.2", diff --git a/e2e/rust/tests/ocsf_console.rs b/e2e/rust/tests/ocsf_console.rs index 9ce14a753c..4a62be40c9 100644 --- a/e2e/rust/tests/ocsf_console.rs +++ b/e2e/rust/tests/ocsf_console.rs @@ -126,7 +126,7 @@ async fn network_events_are_json_on_supervisor_stderr_only() { .as_str() .is_some_and(|uid| !uid.is_empty()) ); - assert_eq!(record["container"]["name"], sandbox.name); + assert_eq!(record["device"]["container"]["name"], sandbox.name); } // Allow the existing best-effort push batch to reach the gateway. sleep(Duration::from_secs(2)).await;