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
1 change: 1 addition & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10027,6 +10027,7 @@ mod tests {
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
ocsf_json: Vec::new(),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ impl OpenShell for TestOpenShell {
message: message.to_string(),
source: "gateway".to_string(),
fields: HashMap::new(),
ocsf_json: Vec::new(),
})),
}))
.await;
Expand Down
76 changes: 76 additions & 0 deletions crates/openshell-server/src/tracing_bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ impl TracingLogBus {
if log.sandbox_id.is_empty() {
return;
}
let mut log = log;
if let Some(event) = decode_ocsf_event(&log) {
log.message = event.format_shorthand();
}
let evt = SandboxStreamEvent {
payload: Some(openshell_core::proto::sandbox_stream_event::Payload::Log(
log.clone(),
Expand Down Expand Up @@ -163,6 +167,26 @@ impl TracingLogBus {
}
}

/// Decode the structured OCSF event a sandbox line carries, if any.
///
/// A malformed payload is logged and ignored rather than dropping the line.
fn decode_ocsf_event(log: &SandboxLogLine) -> Option<openshell_ocsf::OcsfEvent> {
if log.ocsf_json.is_empty() {
return None;
}
match serde_json::from_slice(&log.ocsf_json) {
Ok(event) => Some(event),
Err(error) => {
tracing::warn!(
sandbox_id = %log.sandbox_id,
%error,
"discarding undecodable OCSF payload from sandbox log line"
);
None
}
}
}

#[derive(Debug, Clone)]
struct SandboxLogLayer {
bus: TracingLogBus,
Expand Down Expand Up @@ -208,6 +232,7 @@ where
message: msg,
source: "gateway".to_string(),
fields: HashMap::new(),
ocsf_json: Vec::new(),
};
let evt = SandboxStreamEvent {
payload: Some(openshell_core::proto::sandbox_stream_event::Payload::Log(
Expand Down Expand Up @@ -274,6 +299,7 @@ mod tests {
message: message.to_string(),
source: "gateway".to_string(),
fields: HashMap::new(),
ocsf_json: Vec::new(),
}
}

Expand Down Expand Up @@ -339,6 +365,56 @@ mod tests {
bus.remove("nonexistent");
}

#[test]
fn external_lines_render_shorthand_from_the_structured_event() {
use openshell_ocsf::{
ActionId, ActivityId, DispositionId, Endpoint, NetworkActivityBuilder, SeverityId,
StatusId,
};

let event = NetworkActivityBuilder::new(&ocsf_ctx("sb-wire"))
.activity(ActivityId::Open)
.action(ActionId::Denied)
.disposition(DispositionId::Blocked)
.severity(SeverityId::Medium)
.status(StatusId::Failure)
.dst_endpoint(Endpoint::from_domain("blocked.example.com", 443))
.message("CONNECT denied blocked.example.com:443")
.build();
let expected = event.format_shorthand();

let bus = TracingLogBus::new();
let mut line = make_log_event("sb-wire", "");
line.ocsf_json = event.to_json_line().unwrap().into_bytes();
bus.publish_external(line);

let tail = bus.tail("sb-wire", 10);
assert_eq!(tail.len(), 1);
assert_eq!(log_message(&tail[0]).message, expected);
}

#[test]
fn external_lines_without_ocsf_json_keep_their_message() {
let bus = TracingLogBus::new();
bus.publish_external(make_log_event("sb-plain", "already rendered"));

let tail = bus.tail("sb-plain", 10);
assert_eq!(log_message(&tail[0]).message, "already rendered");
}

#[test]
fn undecodable_ocsf_json_does_not_drop_the_line() {
let bus = TracingLogBus::new();
let mut line = make_log_event("sb-bad", "fallback text");
line.ocsf_json = b"{not valid json".to_vec();
bus.publish_external(line);

// A malformed payload must not silently swallow the record.
let tail = bus.tail("sb-bad", 10);
assert_eq!(tail.len(), 1);
assert_eq!(log_message(&tail[0]).message, "fallback text");
}

#[test]
fn publish_after_remove_does_not_resurrect_the_bus_entry() {
let bus = TracingLogBus::new();
Expand Down
135 changes: 135 additions & 0 deletions crates/openshell-server/tests/ocsf_wire_equivalence.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Gateway-rendered log text must match what the sandbox would have rendered.
//!
//! A decode gap corrupts `openshell logs` output rather than erroring.

use std::net::{IpAddr, Ipv4Addr};

use openshell_ocsf::{
ActionId, ActivityId, AppLifecycleBuilder, ConfigStateChangeBuilder, DetectionFindingBuilder,
DispositionId, Endpoint, EventOrigin, FindingInfo, HttpActivityBuilder, HttpMethod,
HttpRequest, HttpResponse, NetworkActivityBuilder, OcsfEvent, Process, ProcessActivityBuilder,
SandboxContext, SeverityId, SshActivityBuilder, StateId, StatusId, Url,
};

fn 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,
}
}

/// Serialize as the supervisor does, then decode as the gateway does.
fn across_the_wire(event: &OcsfEvent) -> OcsfEvent {
let bytes = event.to_json_line().expect("serialize").into_bytes();
serde_json::from_slice(&bytes).expect("gateway should decode the payload")
}

fn assert_renders_identically(label: &str, event: &OcsfEvent) {
assert_eq!(
across_the_wire(event).format_shorthand(),
event.format_shorthand(),
"{label}: gateway-rendered text differs from sandbox-rendered text"
);
}

#[test]
fn network_activity_renders_identically_across_the_wire() {
let event = NetworkActivityBuilder::new(&ctx())
.activity(ActivityId::Open)
.action(ActionId::Denied)
.disposition(DispositionId::Blocked)
.severity(SeverityId::Medium)
.status(StatusId::Failure)
.dst_endpoint(Endpoint::from_domain("api.example.com", 443))
.src_endpoint_addr(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 51234)
.actor_process(Process::new("/usr/bin/curl", 4711).with_cmd_line("curl -sS https://x"))
.firewall_rule("default-deny-egress", "opa")
.message("CONNECT denied api.example.com:443")
.build();
assert_renders_identically("network_activity", &event);
}

#[test]
fn http_activity_renders_identically_across_the_wire() {
let event = HttpActivityBuilder::new(&ctx())
.activity(ActivityId::Open)
.action(ActionId::Allowed)
.disposition(DispositionId::Allowed)
.severity(SeverityId::Informational)
.status(StatusId::Success)
.http_request(HttpRequest {
http_method: HttpMethod::Get,
url: Some(Url::new("https", "api.example.com", "/v1/items", 443)),
})
.http_response(HttpResponse { code: 200 })
.message("GET /v1/items 200")
.build();
assert_renders_identically("http_activity", &event);
}

#[test]
fn ssh_activity_renders_identically_across_the_wire() {
let event = SshActivityBuilder::new(&ctx())
.activity(ActivityId::Open)
.severity(SeverityId::Informational)
.status(StatusId::Success)
.dst_endpoint(Endpoint::from_domain("sandbox.local", 22))
.message("ssh session accepted")
.build();
assert_renders_identically("ssh_activity", &event);
}

#[test]
fn process_activity_renders_identically_across_the_wire() {
let event = ProcessActivityBuilder::new(&ctx())
.activity(ActivityId::Open)
.severity(SeverityId::Informational)
.status(StatusId::Success)
.process(Process::new("/usr/bin/python3", 4713).with_cmd_line("python3 -m pytest"))
.message("process started")
.build();
assert_renders_identically("process_activity", &event);
}

#[test]
fn detection_finding_renders_identically_across_the_wire() {
let event = DetectionFindingBuilder::new(&ctx())
.finding_info(FindingInfo::new("finding-1", "Sandbox bypass attempt"))
.severity(SeverityId::High)
.is_alert(true)
.evidence("dst_host", "169.254.169.254")
.message("bypass attempt detected")
.build();
assert_renders_identically("detection_finding", &event);
}

#[test]
fn config_state_change_renders_identically_across_the_wire() {
let event = ConfigStateChangeBuilder::new(&ctx())
.state(StateId::Other, "policy-loaded")
.severity(SeverityId::Informational)
.status(StatusId::Success)
.message("policy reloaded")
.unmapped("policy_version", 7)
.build();
assert_renders_identically("config_state_change", &event);
}

#[test]
fn application_lifecycle_renders_identically_across_the_wire() {
let event = AppLifecycleBuilder::new(&ctx())
.activity(ActivityId::Open)
.severity(SeverityId::Informational)
.message("supervisor started")
.build();
assert_renders_identically("application_lifecycle", &event);
}
Loading
Loading