mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
fix(ocsf): emit schema-valid event identities (#3247)
Previously, every event from a sandbox reused the sandbox ID as its event ID. Consumers deduplicating security records could mistake separate events for the same record, and missing device types or empty image objects could prevent schema validation. Give each event its own ID, retain the sandbox association separately, and classify the environment as Other/Sandbox while keeping the OS separate. Omit unknown container details instead of emitting empty objects. Security tooling can now distinguish events from the same sandbox and read their identity consistently after serialization. Refs #1055 Signed-off-by: Kris Hicks <khicks@nvidia.com>
This commit is contained in:
Generated
+1
@@ -4314,6 +4314,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -455,6 +455,8 @@ sandbox workload directly. The relay supports:
|
||||
Sandbox logs are emitted locally and can also be pushed back to the gateway.
|
||||
Security-relevant sandbox behavior uses OCSF structured events; internal
|
||||
diagnostics use ordinary tracing.
|
||||
The OCSF device describes the sandbox environment, with type ID Other and type
|
||||
label `Sandbox`; its operating system is a separate attribute.
|
||||
|
||||
## Policy Proposals
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -205,21 +205,24 @@ impl SandboxContext {
|
||||
version: OCSF_VERSION.to_string(),
|
||||
product: Product::openshell_sandbox(&self.product_version),
|
||||
profiles: profiles.iter().map(|s| (*s).to_string()).collect(),
|
||||
uid: Some(self.sandbox_id.clone()),
|
||||
uid: Some(uuid::Uuid::new_v4().to_string()),
|
||||
log_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the OCSF `Container` object.
|
||||
/// Build the OCSF `Container` object when the event concerns a sandbox.
|
||||
#[must_use]
|
||||
pub fn container(&self) -> Container {
|
||||
Container {
|
||||
pub fn container(&self) -> Option<Container> {
|
||||
if self.sandbox_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Container {
|
||||
name: self.sandbox_name.clone(),
|
||||
uid: Some(self.sandbox_id.clone()),
|
||||
image: Some(Image {
|
||||
image: (!self.container_image.is_empty()).then(|| Image {
|
||||
name: self.container_image.clone(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the OCSF `Device` object.
|
||||
@@ -249,7 +252,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,13 +282,27 @@ 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_emitted_device_matches_vendored_schema() {
|
||||
use crate::validation::schema::{
|
||||
load_object_schema, validate_enum_value, validate_required_fields,
|
||||
};
|
||||
|
||||
let device = serde_json::to_value(test_sandbox_context().device()).unwrap();
|
||||
let schema = load_object_schema("device");
|
||||
validate_required_fields(&device, &schema);
|
||||
validate_enum_value(&device, "type_id", &schema);
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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, ...).
|
||||
/// Values come from the OCSF device `type_id` enumeration.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize_repr, Deserialize_repr)]
|
||||
#[repr(u8)]
|
||||
pub enum DeviceTypeId {
|
||||
/// 0 — Unknown
|
||||
Unknown = 0,
|
||||
/// 99 — Other
|
||||
Other = 99,
|
||||
}
|
||||
|
||||
impl DeviceTypeId {
|
||||
#[must_use]
|
||||
pub fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DeviceTypeId {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::Unknown => "Unknown",
|
||||
Self::Other => "Other",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn device_type_display_uses_schema_labels() {
|
||||
for (device_type, expected) in [
|
||||
(DeviceTypeId::Unknown, "Unknown"),
|
||||
(DeviceTypeId::Other, "Other"),
|
||||
] {
|
||||
assert_eq!(device_type.to_string(), expected);
|
||||
assert_eq!(format!("{device_type}"), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_type_json_roundtrip() {
|
||||
for (device_type, expected) in [(DeviceTypeId::Unknown, 0), (DeviceTypeId::Other, 99)] {
|
||||
let json = serde_json::to_value(device_type).unwrap();
|
||||
assert_eq!(json, serde_json::json!(expected));
|
||||
let decoded: DeviceTypeId = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(decoded, device_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -5,12 +5,29 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::enums::DeviceTypeId;
|
||||
|
||||
/// OCSF Device object.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
/// Device hostname.
|
||||
pub hostname: String,
|
||||
|
||||
/// Administrator-assigned device name, when one exists.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Stable unique identifier for the device.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uid: Option<String>,
|
||||
|
||||
/// Device type id. Required by the OCSF schema.
|
||||
pub type_id: DeviceTypeId,
|
||||
|
||||
/// Sibling label for `type_id`.
|
||||
#[serde(rename = "type")]
|
||||
pub type_label: String,
|
||||
|
||||
/// Operating system info.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub os: Option<OsInfo>,
|
||||
@@ -24,11 +41,15 @@ pub struct OsInfo {
|
||||
}
|
||||
|
||||
impl Device {
|
||||
/// Create a Linux device with the given hostname.
|
||||
/// Create a Linux sandbox device with the given hostname.
|
||||
#[must_use]
|
||||
pub fn linux(hostname: &str) -> Self {
|
||||
Self {
|
||||
hostname: hostname.to_string(),
|
||||
name: None,
|
||||
uid: None,
|
||||
type_id: DeviceTypeId::Other,
|
||||
type_label: "Sandbox".to_string(),
|
||||
os: Some(OsInfo {
|
||||
name: "Linux".to_string(),
|
||||
}),
|
||||
@@ -47,4 +68,21 @@ mod tests {
|
||||
assert_eq!(json["hostname"], "sandbox-abc123");
|
||||
assert_eq!(json["os"]["name"], "Linux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_device_type_is_independent_of_its_os() {
|
||||
let json = serde_json::to_value(Device::linux("sandbox-abc123")).unwrap();
|
||||
assert_eq!(json["type_id"], DeviceTypeId::Other.as_u8());
|
||||
assert_eq!(json["type"], "Sandbox");
|
||||
assert_eq!(json["os"]["name"], "Linux");
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct Metadata {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub profiles: Vec<String>,
|
||||
|
||||
/// Unique event source identifier (sandbox ID).
|
||||
/// Unique event identifier.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uid: Option<String>,
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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, NetworkActivityBuilder, OcsfEvent, SandboxContext, SeverityId};
|
||||
|
||||
fn sandbox_ctx(container_image: &str) -> SandboxContext {
|
||||
SandboxContext {
|
||||
sandbox_id: "sb-1".to_string(),
|
||||
sandbox_name: "agent-01".to_string(),
|
||||
container_image: container_image.to_string(),
|
||||
hostname: "openshell-sb-1".to_string(),
|
||||
product_version: "0.42.1".to_string(),
|
||||
proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
proxy_port: 8888,
|
||||
}
|
||||
}
|
||||
|
||||
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("ghcr.io/nvidia/openshell/sandbox:0.42.1");
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_uid_is_no_longer_the_sandbox_id() {
|
||||
let event = event(&sandbox_ctx("ghcr.io/nvidia/openshell/sandbox:0.42.1"));
|
||||
assert_ne!(event.base().metadata.uid.as_deref(), Some("sb-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sandbox_id_is_carried_by_the_container() {
|
||||
let json = event(&sandbox_ctx("ghcr.io/nvidia/openshell/sandbox:0.42.1"))
|
||||
.to_json()
|
||||
.expect("serializes");
|
||||
|
||||
assert_eq!(json["container"]["uid"], "sb-1");
|
||||
assert_eq!(json["container"]["name"], "agent-01");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_identifies_the_sandbox_environment() {
|
||||
let json = event(&sandbox_ctx("image:1")).to_json().unwrap();
|
||||
|
||||
assert_eq!(json["device"]["type_id"], 99);
|
||||
assert_eq!(json["device"]["type"], "Sandbox");
|
||||
assert_eq!(json["device"]["os"]["name"], "Linux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_identity_survives_serialization() {
|
||||
let original = event(&sandbox_ctx("image:1"));
|
||||
let decoded: OcsfEvent = serde_json::from_value(original.to_json().unwrap()).unwrap();
|
||||
assert_eq!(decoded.base().metadata.uid, original.base().metadata.uid);
|
||||
}
|
||||
@@ -39,6 +39,8 @@ When enabled, OCSF JSON records are written to `/var/log/openshell-ocsf.YYYY-MM-
|
||||
|
||||
## JSON Record Structure
|
||||
|
||||
`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.
|
||||
|
||||
Each line is a complete OCSF v1.8.0 JSON object. Here is an example of a network connection event:
|
||||
|
||||
```json
|
||||
@@ -213,6 +215,8 @@ cat /var/log/openshell-ocsf.2026-04-01.log | \
|
||||
|
||||
## Relationship to Shorthand Logs
|
||||
|
||||
Sandbox events identify the sandbox environment with `device.type_id: 99` (Other) and `device.type: "Sandbox"`. The operating system is reported separately in `device.os.name`; the device type does not classify the underlying host or compute backend.
|
||||
|
||||
The shorthand format in `openshell.YYYY-MM-DD.log` and the JSON format in `openshell-ocsf.YYYY-MM-DD.log` are derived from the same OCSF events. The shorthand is a human-readable projection; the JSON is the full structured record when no schema downgrade is configured. When `ocsf_schema_version` is set, the JSON export is a lossy projection of the internal event model. Both formats are generated at the same time from the same event data.
|
||||
|
||||
The shorthand log is always active. The JSON export is opt-in through `ocsf_json_enabled`.
|
||||
|
||||
Reference in New Issue
Block a user