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
2 changes: 1 addition & 1 deletion crates/openshell-ocsf-validate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>/` therefore holds one schema per event class and profile combination, named `<class>@<profile>+<profile>.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

Expand Down
14 changes: 5 additions & 9 deletions crates/openshell-ocsf-validate/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}

Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-ocsf/src/builders/api_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-ocsf/src/builders/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-ocsf/src/builders/finding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
);
Expand Down
14 changes: 4 additions & 10 deletions crates/openshell-ocsf/src/builders/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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,
Expand Down Expand Up @@ -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(),
}),
})
}

Expand All @@ -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,
Expand All @@ -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()));
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-ocsf/src/builders/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
15 changes: 4 additions & 11 deletions crates/openshell-ocsf/src/events/base_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,6 @@ pub struct BaseEventData {
#[serde(skip_serializing_if = "Option::is_none")]
pub device: Option<Device>,

/// Container info (Container profile).
#[serde(skip_serializing_if = "Option::is_none")]
pub container: Option<Container>,

/// AI model info (`ai_operation` profile, OCSF v1.8.0+).
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_model: Option<AiModel>,
Expand Down Expand Up @@ -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)?;
}
Expand Down Expand Up @@ -160,7 +153,6 @@ impl BaseEventData {
status_detail: None,
metadata,
device: None,
container: None,
ai_model: None,
unmapped: None,
}
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-ocsf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
19 changes: 1 addition & 18 deletions crates/openshell-ocsf/src/objects/container.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -14,17 +14,6 @@ pub struct Container {
/// Container unique identifier.
#[serde(skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,

/// Container image.
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<Image>,
}

/// 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)]
Expand All @@ -36,26 +25,20 @@ 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]
fn test_container_minimal() {
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());
}
}
36 changes: 24 additions & 12 deletions crates/openshell-ocsf/src/objects/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -31,6 +32,10 @@ pub struct Device {
/// Operating system info.
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<OsInfo>,

/// Sandbox the event concerns (`container` profile).
#[serde(skip_serializing_if = "Option::is_none")]
pub container: Option<Container>,
}

/// OCSF OS Info object.
Expand Down Expand Up @@ -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<Container>) -> Self {
self.container = container;
self
}

/// Create a device stamped with the OS this build is running on.
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-ocsf/src/objects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
15 changes: 9 additions & 6 deletions crates/openshell-ocsf/tests/event_identity.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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]
Expand Down
6 changes: 3 additions & 3 deletions crates/openshell-ocsf/tests/gateway_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
Expand All @@ -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());
}
3 changes: 1 addition & 2 deletions crates/openshell-otel/src/ocsf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
{
Expand Down
5 changes: 2 additions & 3 deletions crates/openshell-server/src/grpc/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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"],
Expand Down
Loading
Loading