Skip to content

Commit 1bd4af9

Browse files
committed
test: backfill coverage for OCSF logging
Characterization tests for behavior that later work changes. No behavior change. - Pin the `openshell logs` output format, so a change to how that text is produced shows up as a diff rather than passing silently. - Cover the supervisor log push layer. - Assert OcsfEvent survives a JSON round trip. Serialization is written by hand and deserialization dispatches on class_uid into independent per-variant paths, so a field can serialize correctly and still be dropped or rejected on the way back in. Refs #1055 Signed-off-by: Kris Hicks <khicks@nvidia.com>
1 parent 4eaa105 commit 1bd4af9

3 files changed

Lines changed: 545 additions & 9 deletions

File tree

crates/openshell-cli/src/run.rs

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7117,6 +7117,10 @@ pub async fn sandbox_logs(
71177117
}
71187118

71197119
fn print_log_line(log: &openshell_core::proto::SandboxLogLine) {
7120+
println!("{}", format_log_line(log));
7121+
}
7122+
7123+
fn format_log_line(log: &openshell_core::proto::SandboxLogLine) -> String {
71207124
let source = if log.source.is_empty() {
71217125
"gateway"
71227126
} else {
@@ -7125,10 +7129,10 @@ fn print_log_line(log: &openshell_core::proto::SandboxLogLine) {
71257129
let secs = log.timestamp_ms / 1000;
71267130
let millis = log.timestamp_ms % 1000;
71277131
if log.fields.is_empty() {
7128-
println!(
7132+
format!(
71297133
"[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {}",
71307134
log.level, log.target, log.message
7131-
);
7135+
)
71327136
} else {
71337137
let mut fields_str = String::new();
71347138
let mut entries: Vec<_> = log.fields.iter().collect();
@@ -7141,10 +7145,10 @@ fn print_log_line(log: &openshell_core::proto::SandboxLogLine) {
71417145
fields_str.push('=');
71427146
fields_str.push_str(v);
71437147
}
7144-
println!(
7148+
format!(
71457149
"[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {} {}",
71467150
log.level, log.target, log.message, fields_str
7147-
);
7151+
)
71487152
}
71497153
}
71507154

@@ -7509,7 +7513,7 @@ fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String
75097513
mod tests {
75107514
use super::{
75117515
PolicyGetView, ProvisioningStep, build_sandbox_resource_limits,
7512-
dockerfile_sources_supported_for_gateway, format_endpoint,
7516+
dockerfile_sources_supported_for_gateway, format_endpoint, format_log_line,
75137517
format_provider_attachment_table, git_sync_files, has_main_process_result,
75147518
inferred_provider_type, parse_cli_setting_value, parse_credential_expiry_cli_value,
75157519
parse_credential_expiry_pairs, parse_credential_pairs, parse_driver_config_json,
@@ -8974,4 +8978,106 @@ mod tests {
89748978
assert!(json["revision"].is_null());
89758979
assert!(json["policy"].is_null());
89768980
}
8981+
8982+
fn log_line(
8983+
level: &str,
8984+
target: &str,
8985+
message: &str,
8986+
source: &str,
8987+
fields: &[(&str, &str)],
8988+
) -> openshell_core::proto::SandboxLogLine {
8989+
openshell_core::proto::SandboxLogLine {
8990+
sandbox_id: "sb-1".to_string(),
8991+
timestamp_ms: 1_234_567,
8992+
level: level.to_string(),
8993+
target: target.to_string(),
8994+
message: message.to_string(),
8995+
source: source.to_string(),
8996+
fields: fields
8997+
.iter()
8998+
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
8999+
.collect(),
9000+
}
9001+
}
9002+
9003+
#[test]
9004+
fn format_log_line_without_fields() {
9005+
let log = log_line("INFO", "openshell_server", "hello world", "sandbox", &[]);
9006+
assert_eq!(
9007+
format_log_line(&log),
9008+
"[1234.567] [sandbox] [INFO ] [openshell_server] hello world"
9009+
);
9010+
}
9011+
9012+
#[test]
9013+
fn format_log_line_empty_source_defaults_to_gateway() {
9014+
let log = log_line("WARN", "t", "msg", "", &[]);
9015+
assert_eq!(
9016+
format_log_line(&log),
9017+
"[1234.567] [gateway] [WARN ] [t] msg"
9018+
);
9019+
}
9020+
9021+
#[test]
9022+
fn format_log_line_pads_source_and_level() {
9023+
let log = log_line("OCSF", "ocsf", "NET:OPEN", "gateway", &[]);
9024+
assert_eq!(
9025+
format_log_line(&log),
9026+
"[1234.567] [gateway] [OCSF ] [ocsf] NET:OPEN"
9027+
);
9028+
9029+
let short_source = log_line("ERROR", "t", "m", "vm", &[]);
9030+
assert_eq!(
9031+
format_log_line(&short_source),
9032+
"[1234.567] [vm ] [ERROR] [t] m"
9033+
);
9034+
}
9035+
9036+
#[test]
9037+
fn format_log_line_sorts_fields_alphabetically() {
9038+
let log = log_line(
9039+
"INFO",
9040+
"ocsf",
9041+
"CONNECT",
9042+
"sandbox",
9043+
&[("dst_port", "443"), ("action", "allow"), ("dst_host", "x")],
9044+
);
9045+
assert_eq!(
9046+
format_log_line(&log),
9047+
"[1234.567] [sandbox] [INFO ] [ocsf] CONNECT action=allow dst_host=x dst_port=443"
9048+
);
9049+
}
9050+
9051+
#[test]
9052+
fn format_log_line_keeps_empty_field_values() {
9053+
let log = log_line("INFO", "t", "m", "sandbox", &[("a", ""), ("b", "1")]);
9054+
assert_eq!(
9055+
format_log_line(&log),
9056+
"[1234.567] [sandbox] [INFO ] [t] m a= b=1"
9057+
);
9058+
}
9059+
9060+
#[test]
9061+
fn format_log_line_renders_empty_target_as_empty_brackets() {
9062+
let log = log_line("INFO", "", "m", "sandbox", &[]);
9063+
assert_eq!(format_log_line(&log), "[1234.567] [sandbox] [INFO ] [] m");
9064+
}
9065+
9066+
#[test]
9067+
fn format_log_line_zero_pads_millis() {
9068+
let mut log = log_line("INFO", "t", "m", "sandbox", &[]);
9069+
log.timestamp_ms = 1_000_007;
9070+
assert_eq!(format_log_line(&log), "[1000.007] [sandbox] [INFO ] [t] m");
9071+
9072+
log.timestamp_ms = 0;
9073+
assert_eq!(format_log_line(&log), "[0.000] [sandbox] [INFO ] [t] m");
9074+
}
9075+
9076+
#[test]
9077+
fn format_log_line_preserves_message_verbatim() {
9078+
// OCSF shorthand must pass through unchanged.
9079+
let message = "NET:OPEN [MED] DENIED /usr/bin/curl(4711) -> api.example.com:443";
9080+
let log = log_line("OCSF", "ocsf", message, "sandbox", &[]);
9081+
assert!(format_log_line(&log).ends_with(message));
9082+
}
89779083
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! `OcsfEvent` JSON round-trip fidelity.
5+
//!
6+
//! `Serialize` is written by hand and `Deserialize` dispatches on `class_uid`
7+
//! into independent per-variant paths, so a field can serialize correctly and
8+
//! still be dropped or rejected on the way back in.
9+
10+
use std::net::{IpAddr, Ipv4Addr};
11+
12+
use openshell_ocsf::{
13+
ActionId, ActivityId, AiModel, ApiActivityBuilder, AppLifecycleBuilder, Attack, AuthTypeId,
14+
BaseEventBuilder, ConfidenceId, ConfigStateChangeBuilder, ConnectionInfo,
15+
DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, HttpActivityBuilder, HttpMethod,
16+
HttpRequest, HttpResponse, LaunchTypeId, NetworkActivityBuilder, OcsfEvent, Process,
17+
ProcessActivityBuilder, RiskLevelId, SandboxContext, SecurityLevelId, SeverityId,
18+
SshActivityBuilder, StateId, StatusId, Url,
19+
};
20+
21+
fn ctx() -> SandboxContext {
22+
SandboxContext {
23+
sandbox_id: "sb-7f3a9c2e14b8".to_string(),
24+
sandbox_name: "agent-workspace-01".to_string(),
25+
container_image: "ghcr.io/nvidia/openshell/sandbox:0.42.1".to_string(),
26+
hostname: "openshell-sb-7f3a9c2e14b8".to_string(),
27+
product_version: "0.42.1".to_string(),
28+
proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
29+
proxy_port: 8888,
30+
}
31+
}
32+
33+
/// Assert an event survives `to_json -> from_value -> to_json` unchanged.
34+
fn assert_round_trips(label: &str, event: &OcsfEvent) {
35+
let json = event.to_json().expect("serialize");
36+
37+
let decoded: OcsfEvent = serde_json::from_value(json.clone())
38+
.unwrap_or_else(|e| panic!("{label}: deserialize failed: {e}\njson: {json}"));
39+
40+
let reserialized = decoded.to_json().expect("re-serialize");
41+
assert_eq!(
42+
json, reserialized,
43+
"{label}: JSON changed across round trip"
44+
);
45+
}
46+
47+
#[test]
48+
fn network_activity_round_trips() {
49+
let event = NetworkActivityBuilder::new(&ctx())
50+
.activity(ActivityId::Open)
51+
.activity_name("Open")
52+
.action(ActionId::Denied)
53+
.disposition(DispositionId::Blocked)
54+
.severity(SeverityId::Medium)
55+
.status(StatusId::Failure)
56+
.dst_endpoint(Endpoint::from_domain("api.example.com", 443))
57+
.src_endpoint_addr(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 51234)
58+
.actor_process(
59+
Process::new("/usr/bin/curl", 4711)
60+
.with_cmd_line("curl -sS https://api.example.com")
61+
.with_parent(Process::new("/bin/bash", 4700)),
62+
)
63+
.firewall_rule("default-deny-egress", "opa")
64+
.connection_info(ConnectionInfo::new("tcp"))
65+
.observation_point(3)
66+
.status_detail("blocked by egress policy")
67+
.log_source("/dev/kmsg")
68+
.message("CONNECT denied api.example.com:443")
69+
.unmapped("policy_version", 42)
70+
.unmapped("engine", "opa")
71+
.build();
72+
73+
assert_round_trips("network_activity", &event);
74+
}
75+
76+
#[test]
77+
fn http_activity_round_trips() {
78+
let event = HttpActivityBuilder::new(&ctx())
79+
.activity(ActivityId::Open)
80+
.action(ActionId::Allowed)
81+
.disposition(DispositionId::Allowed)
82+
.severity(SeverityId::Informational)
83+
.status(StatusId::Success)
84+
.http_request(HttpRequest {
85+
http_method: HttpMethod::Post,
86+
url: Some(Url::new("https", "api.example.com", "/v1/items", 443)),
87+
})
88+
.http_response(HttpResponse { code: 201 })
89+
.src_endpoint(Endpoint::from_ip(
90+
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)),
91+
51235,
92+
))
93+
.dst_endpoint(Endpoint::from_domain("api.example.com", 443))
94+
.actor_process(Process::new("/usr/bin/node", 4712))
95+
.firewall_rule("allow-api", "l7")
96+
.status_detail("allowed by L7 rule")
97+
.message("POST /v1/items 201")
98+
.unmapped("l7_decision", "allow")
99+
.build();
100+
101+
assert_round_trips("http_activity", &event);
102+
}
103+
104+
#[test]
105+
fn ssh_activity_round_trips() {
106+
let event = SshActivityBuilder::new(&ctx())
107+
.activity(ActivityId::Open)
108+
.auth_type(AuthTypeId::Other, "publickey-nonce")
109+
.protocol_ver("SSH-2.0-OpenSSH_9.6")
110+
.severity(SeverityId::Informational)
111+
.status(StatusId::Success)
112+
.src_endpoint_addr(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 9)), 44321)
113+
.dst_endpoint(Endpoint::from_domain("sandbox.local", 22))
114+
.message("ssh session accepted")
115+
.build();
116+
117+
assert_round_trips("ssh_activity", &event);
118+
}
119+
120+
#[test]
121+
fn process_activity_round_trips() {
122+
let event = ProcessActivityBuilder::new(&ctx())
123+
.activity(ActivityId::Open)
124+
.process(
125+
Process::new("/usr/bin/python3", 4713)
126+
.with_cmd_line("python3 -m pytest")
127+
.with_parent(Process::new("/bin/sh", 4701)),
128+
)
129+
.launch_type(LaunchTypeId::Other)
130+
.exit_code(0)
131+
.severity(SeverityId::Informational)
132+
.status(StatusId::Success)
133+
.message("process started")
134+
.build();
135+
136+
assert_round_trips("process_activity", &event);
137+
}
138+
139+
#[test]
140+
fn detection_finding_round_trips() {
141+
let event = DetectionFindingBuilder::new(&ctx())
142+
.finding_info(
143+
FindingInfo::new("finding-001", "Sandbox bypass attempt")
144+
.with_desc("Process attempted to reach the host network directly"),
145+
)
146+
.severity(SeverityId::High)
147+
.is_alert(true)
148+
.confidence(ConfidenceId::High)
149+
.risk_level(RiskLevelId::High)
150+
.attack(Attack::mitre(
151+
"T1046",
152+
"Network Service Discovery",
153+
"TA0007",
154+
"Discovery",
155+
))
156+
.evidence_pairs(&[("dst_host", "169.254.169.254"), ("dst_port", "80")])
157+
.evidence("binary", "/usr/bin/curl")
158+
.remediation("Tighten the egress policy for this sandbox")
159+
.log_source("/dev/kmsg")
160+
.message("bypass attempt detected")
161+
.unmapped("detector", "bypass_monitor")
162+
.build();
163+
164+
assert_round_trips("detection_finding", &event);
165+
}
166+
167+
#[test]
168+
fn application_lifecycle_round_trips() {
169+
let event = AppLifecycleBuilder::new(&ctx())
170+
.activity(ActivityId::Open)
171+
.severity(SeverityId::Informational)
172+
.message("supervisor started")
173+
.build();
174+
175+
assert_round_trips("application_lifecycle", &event);
176+
}
177+
178+
#[test]
179+
fn device_config_state_change_round_trips() {
180+
let event = ConfigStateChangeBuilder::new(&ctx())
181+
.state(StateId::Other, "policy-loaded")
182+
.security_level(SecurityLevelId::Secure)
183+
.prev_security_level(SecurityLevelId::AtRisk)
184+
.severity(SeverityId::Informational)
185+
.status(StatusId::Success)
186+
.message("policy reloaded")
187+
.unmapped("policy_version", 7)
188+
.build();
189+
190+
assert_round_trips("device_config_state_change", &event);
191+
}
192+
193+
#[test]
194+
fn api_activity_round_trips() {
195+
let event = ApiActivityBuilder::new(&ctx(), "chat.completions")
196+
.severity(SeverityId::Informational)
197+
.status(StatusId::Success)
198+
.http_request(HttpRequest {
199+
http_method: HttpMethod::Post,
200+
url: Some(Url::new("https", "inference.local", "/v1/chat", 443)),
201+
})
202+
.dst_endpoint(Endpoint::from_domain("inference.local", 443))
203+
.ai_model(AiModel::new("llama-3.1-8b", "nvidia"))
204+
.message("inference request routed")
205+
.unmapped("route", "system")
206+
.build();
207+
208+
assert_round_trips("api_activity", &event);
209+
}
210+
211+
#[test]
212+
fn base_event_round_trips() {
213+
let event = BaseEventBuilder::new(&ctx())
214+
.activity_name("custom activity")
215+
.severity(SeverityId::Low)
216+
.message("base event")
217+
.unmapped("detail", "value")
218+
.build();
219+
220+
assert_round_trips("base_event", &event);
221+
}

0 commit comments

Comments
 (0)