Skip to content

Commit fea7613

Browse files
committed
fix(ocsf): route sandbox logs by trusted context identity
Signed-off-by: Louis Deng <[email protected]>
1 parent 0e08ac2 commit fea7613

15 files changed

Lines changed: 168 additions & 105 deletions

File tree

‎crates/openshell-ocsf/src/builders/api_activity.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ impl<'a> ApiActivityBuilder<'a> {
165165
}
166166

167167
#[must_use]
168-
pub fn build(mut self) -> OcsfEvent {
168+
pub fn build(self) -> OcsfEvent {
169169
// Only supervisors describe a container on the producing device.
170170
let mut profiles: Vec<&str> = match self.ctx.origin {
171171
EventOrigin::Gateway { .. } => vec!["host"],
@@ -201,8 +201,6 @@ impl<'a> ApiActivityBuilder<'a> {
201201
self.ctx
202202
.apply_common_fields(&mut base, self.status, self.message);
203203

204-
self.ctx.add_sandbox_resource(&mut self.resources);
205-
206204
OcsfEvent::ApiActivity(ApiActivityEvent {
207205
base,
208206
api: Api {

‎crates/openshell-ocsf/src/builders/authentication.rs‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,19 +104,15 @@ impl<'a> AuthenticationBuilder<'a> {
104104
self.severity,
105105
metadata,
106106
);
107-
if let Some(status) = self.status {
108-
base.set_status(status);
109-
}
107+
self.ctx
108+
.apply_common_fields(&mut base, self.status, self.message);
110109
if let Some(detail) = self.status_detail {
111110
base.set_status_detail(detail);
112111
}
113-
if let Some(message) = self.message {
114-
base.set_message(message);
115-
}
116112
// Authentication identifies the authenticating device, not a workload container.
117-
let mut device = self.ctx.device();
118-
device.container = None;
119-
base.set_device(device);
113+
if let Some(device) = base.device.as_mut() {
114+
device.container = None;
115+
}
120116

121117
OcsfEvent::Authentication(AuthenticationEvent {
122118
base,

‎crates/openshell-ocsf/src/builders/finding.rs‎

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::builders::{ContextRef, EventOrigin};
77
use crate::enums::{ActionId, ActivityId, ConfidenceId, DispositionId, RiskLevelId, SeverityId};
88
use crate::events::base_event::BaseEventData;
99
use crate::events::{DetectionFindingEvent, OcsfEvent};
10-
use crate::objects::{Attack, Evidence, FindingInfo, Remediation, ResourceDetails};
10+
use crate::objects::{Attack, Evidence, FindingInfo, Remediation};
1111

1212
/// Builder for Detection Finding [2004] events.
1313
pub struct DetectionFindingBuilder<'a> {
@@ -26,7 +26,6 @@ pub struct DetectionFindingBuilder<'a> {
2626
message: Option<String>,
2727
log_source: Option<String>,
2828
correlation_uid: Option<String>,
29-
resources: Vec<ResourceDetails>,
3029
unmapped: serde_json::Map<String, serde_json::Value>,
3130
}
3231

@@ -49,7 +48,6 @@ impl<'a> DetectionFindingBuilder<'a> {
4948
message: None,
5049
log_source: None,
5150
correlation_uid: None,
52-
resources: Vec::new(),
5351
unmapped: serde_json::Map::new(),
5452
}
5553
}
@@ -94,13 +92,6 @@ impl<'a> DetectionFindingBuilder<'a> {
9492
self
9593
}
9694

97-
/// Add a resource affected by the finding.
98-
#[must_use]
99-
pub fn resource(mut self, resource: ResourceDetails) -> Self {
100-
self.resources.push(resource);
101-
self
102-
}
103-
10495
/// Add a remediation description.
10596
#[must_use]
10697
pub fn remediation(mut self, desc: &str) -> Self {
@@ -130,7 +121,7 @@ impl<'a> DetectionFindingBuilder<'a> {
130121
}
131122

132123
#[must_use]
133-
pub fn build(mut self) -> OcsfEvent {
124+
pub fn build(self) -> OcsfEvent {
134125
let activity_name = self.activity.finding_label().to_string();
135126
// Only supervisors describe a container on the producing device.
136127
let profiles: &[&str] = match self.ctx.origin {
@@ -158,11 +149,8 @@ impl<'a> DetectionFindingBuilder<'a> {
158149
}
159150
self.ctx.apply_common_fields(&mut base, None, self.message);
160151

161-
self.ctx.add_sandbox_resource(&mut self.resources);
162-
163152
OcsfEvent::DetectionFinding(DetectionFindingEvent {
164153
base,
165-
resources: (!self.resources.is_empty()).then_some(self.resources),
166154
finding_info: self
167155
.finding_info
168156
.unwrap_or_else(|| FindingInfo::new("unknown", "Unknown Finding")),
@@ -196,7 +184,7 @@ mod tests {
196184
use crate::builders::{test_gateway_context, test_sandbox_context};
197185

198186
#[test]
199-
fn gateway_finding_identifies_sandbox_resources() {
187+
fn gateway_finding_identifies_context_sandbox_in_unmapped() {
200188
// Gateway-wide findings name no sandbox; MXC findings name the
201189
// sandbox they concern.
202190
for (sandbox_id, profiles) in [
@@ -221,10 +209,10 @@ mod tests {
221209
);
222210
assert_eq!(json["metadata"]["profiles"], profiles, "{sandbox_id:?}");
223211
assert!(json["device"].get("container").is_none());
212+
assert!(json.get("resources").is_none());
224213
assert_eq!(
225-
json.get("resources").is_some(),
226-
!sandbox_id.is_empty(),
227-
"{sandbox_id:?}"
214+
json["unmapped"]["sandbox_id"].as_str(),
215+
(!sandbox_id.is_empty()).then_some(sandbox_id)
228216
);
229217
}
230218
}

‎crates/openshell-ocsf/src/builders/mod.rs‎

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ use std::sync::Arc;
120120
use crate::OCSF_VERSION;
121121
use crate::enums::StatusId;
122122
use crate::events::base_event::BaseEventData;
123-
use crate::objects::{Container, Device, Endpoint, Image, Metadata, Product, ResourceDetails};
123+
use crate::objects::{Container, Device, Endpoint, Image, Metadata, Product};
124124

125125
/// Which `OpenShell` component produced an event.
126126
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -238,7 +238,8 @@ impl EventContext {
238238
.filter(|uid| !uid.trim().is_empty())
239239
.map(|uid| Image {
240240
uid: uid.clone(),
241-
name: self.container_image.clone(),
241+
name: (!self.container_image.trim().is_empty())
242+
.then(|| self.container_image.clone()),
242243
}),
243244
})
244245
}
@@ -255,25 +256,6 @@ impl EventContext {
255256
device
256257
}
257258

258-
/// Identify the affected sandbox without claiming it runs on the gateway device.
259-
pub(crate) fn add_sandbox_resource(&self, resources: &mut Vec<ResourceDetails>) {
260-
if self.sandbox_id.is_empty() || !matches!(self.origin, EventOrigin::Gateway { .. }) {
261-
return;
262-
}
263-
let existing = resources.iter().position(|r| {
264-
r.type_label.as_deref() == Some("sandbox") && r.uid.as_deref() == Some(&self.sandbox_id)
265-
});
266-
let mut resource = existing.map_or_else(
267-
|| ResourceDetails::new("sandbox", &self.sandbox_id),
268-
|index| resources.remove(index),
269-
);
270-
if resource.name.is_none() && !self.sandbox_name.is_empty() {
271-
resource.name = Some(self.sandbox_name.clone());
272-
}
273-
// Keep the context sandbox first when an event also names other targets.
274-
resources.insert(0, resource);
275-
}
276-
277259
/// Build the `proxy_endpoint` object for the Network Proxy profile.
278260
#[must_use]
279261
pub fn proxy_endpoint(&self) -> Endpoint {
@@ -295,14 +277,21 @@ impl EventContext {
295277
base.set_message(m);
296278
}
297279
base.set_device(self.device());
298-
// These classes have no resources attribute. Keep gateway attribution
299-
// in the standard extension object rather than on the gateway device.
300-
if matches!(self.origin, EventOrigin::Gateway { .. })
301-
&& !self.sandbox_id.is_empty()
302-
&& !matches!(base.class_uid, 6003 | 2004)
303-
{
280+
// Only trusted context identity may route an event to a sandbox.
281+
// Resources can name request-supplied targets, including denied ones.
282+
if matches!(self.origin, EventOrigin::Gateway { .. }) && !self.sandbox_id.is_empty() {
304283
base.add_unmapped("sandbox_id", self.sandbox_id.clone());
305-
base.add_unmapped("sandbox_name", self.sandbox_name.clone());
284+
if !self.sandbox_name.is_empty() {
285+
base.add_unmapped("sandbox_name", self.sandbox_name.clone());
286+
}
287+
}
288+
if self
289+
.container_image_uid
290+
.as_ref()
291+
.is_none_or(|uid| uid.trim().is_empty())
292+
&& !self.container_image.trim().is_empty()
293+
{
294+
base.add_unmapped("container_image", self.container_image.clone());
306295
}
307296
}
308297
}
@@ -451,6 +440,54 @@ pub(crate) fn test_gateway_context() -> EventContext {
451440
mod tests {
452441
use super::*;
453442

443+
#[test]
444+
fn image_identity_preserves_references_and_omits_missing_names() {
445+
for uid in [None, Some(""), Some(" "), Some("sha256:resolved-workload")] {
446+
for reference in ["", " ", "registry.example/workload:latest"] {
447+
let mut ctx = test_sandbox_context();
448+
ctx.container_image_uid = uid.map(str::to_string);
449+
ctx.container_image = reference.into();
450+
let resolved = uid.filter(|uid| !uid.trim().is_empty());
451+
for (class, event) in sample_events(&ctx) {
452+
let json = event.to_json().unwrap();
453+
openshell_ocsf_validate::validate_event(&json)
454+
.unwrap_or_else(|error| panic!("{class}: {error}"));
455+
let image = &json["device"]["container"]["image"];
456+
if class != "authentication" && resolved.is_some() {
457+
assert_eq!(image["uid"].as_str(), resolved);
458+
assert_eq!(
459+
image.get("name").and_then(serde_json::Value::as_str),
460+
(!reference.trim().is_empty()).then_some(reference)
461+
);
462+
} else {
463+
assert!(image.is_null());
464+
}
465+
assert_eq!(
466+
json["unmapped"]["container_image"].as_str(),
467+
(resolved.is_none() && !reference.trim().is_empty()).then_some(reference)
468+
);
469+
}
470+
}
471+
}
472+
}
473+
474+
#[test]
475+
fn every_gateway_class_routes_only_by_context_identity() {
476+
for sandbox_id in ["", "sb-context"] {
477+
let mut ctx = test_gateway_context();
478+
ctx.sandbox_id = sandbox_id.into();
479+
for (class, event) in sample_events(&ctx) {
480+
let json = event.to_json().unwrap();
481+
assert_eq!(
482+
event.sandbox_id(),
483+
(!sandbox_id.is_empty()).then_some(sandbox_id),
484+
"{class}"
485+
);
486+
assert!(json["unmapped"].get("sandbox_name").is_none(), "{class}");
487+
}
488+
}
489+
}
490+
454491
#[test]
455492
fn resolved_container_image_matches_schema() {
456493
let mut ctx = test_sandbox_context();

‎crates/openshell-ocsf/src/events/detection_finding.rs‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
77

88
use crate::enums::{ActionId, ConfidenceId, DispositionId, RiskLevelId};
99
use crate::events::base_event::BaseEventData;
10-
use crate::objects::{Attack, Evidence, FindingInfo, Remediation, ResourceDetails};
10+
use crate::objects::{Attack, Evidence, FindingInfo, Remediation};
1111

1212
/// OCSF Detection Finding Event [2004].
1313
///
@@ -21,10 +21,6 @@ pub struct DetectionFindingEvent {
2121
/// Finding details (required).
2222
pub finding_info: FindingInfo,
2323

24-
/// Resources affected by the finding.
25-
#[serde(default, skip_serializing_if = "Option::is_none")]
26-
pub resources: Option<Vec<ResourceDetails>>,
27-
2824
/// Evidence artifacts.
2925
#[serde(skip_serializing_if = "Option::is_none")]
3026
pub evidences: Option<Vec<Evidence>>,
@@ -76,7 +72,6 @@ impl Serialize for DetectionFindingEvent {
7672
.ok_or_else(|| serde::ser::Error::custom("expected object"))?;
7773

7874
insert_required!(obj, "finding_info", self.finding_info);
79-
insert_optional!(obj, "resources", self.resources);
8075
insert_optional!(obj, "evidences", self.evidences);
8176
insert_optional!(obj, "attacks", self.attacks);
8277
insert_optional!(obj, "remediation", self.remediation);
@@ -118,7 +113,6 @@ mod tests {
118113
),
119114
finding_info: FindingInfo::new("nssh1-replay-abc", "NSSH1 Nonce Replay Attack")
120115
.with_desc("A nonce was replayed."),
121-
resources: None,
122116
evidences: Some(vec![Evidence::from_pairs(&[
123117
("nonce", "0xdeadbeef"),
124118
("peer_ip", "10.42.0.1"),

‎crates/openshell-ocsf/src/events/mod.rs‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -140,25 +140,14 @@ impl OcsfEvent {
140140
}
141141
}
142142

143-
/// Sandbox attribution across supervisor and gateway event classes.
143+
/// Context-derived sandbox attribution, never request-supplied resources.
144144
#[must_use]
145145
pub fn sandbox_id(&self) -> Option<&str> {
146-
let resources = match self {
147-
Self::ApiActivity(event) => event.resources.as_deref(),
148-
Self::DetectionFinding(event) => event.resources.as_deref(),
149-
_ => None,
150-
};
151146
self.base()
152147
.device
153148
.as_ref()
154149
.and_then(|device| device.container.as_ref())
155150
.and_then(|container| container.uid.as_deref())
156-
.or_else(|| {
157-
resources?
158-
.iter()
159-
.find(|resource| resource.type_label.as_deref() == Some("sandbox"))
160-
.and_then(|resource| resource.uid.as_deref())
161-
})
162151
.or_else(|| self.base().unmapped.as_ref()?.get("sandbox_id")?.as_str())
163152
.filter(|id| !id.is_empty())
164153
}

‎crates/openshell-ocsf/src/format/shorthand.rs‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,7 +1245,6 @@ mod tests {
12451245
b
12461246
},
12471247
finding_info: FindingInfo::new("nssh1-replay-abc", "NSSH1 Nonce Replay Attack"),
1248-
resources: None,
12491248
evidences: None,
12501249
attacks: None,
12511250
remediation: None,
@@ -1271,7 +1270,6 @@ mod tests {
12711270
let event = OcsfEvent::DetectionFinding(DetectionFindingEvent {
12721271
base,
12731272
finding_info: FindingInfo::new("content_guard.match", "configured content matched"),
1274-
resources: None,
12751273
evidences: Some(vec![Evidence::from_pairs(&[(
12761274
"matched_content",
12771275
"must-not-appear",
@@ -1306,7 +1304,6 @@ mod tests {
13061304
"content_guard\nforged",
13071305
"matched \"value\"\nFINDING:FORGED",
13081306
),
1309-
resources: None,
13101307
evidences: None,
13111308
attacks: None,
13121309
remediation: None,

‎crates/openshell-ocsf/src/objects/container.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ pub struct Image {
2727
pub uid: String,
2828

2929
/// Image name (e.g., "ghcr.io/openshell/sandbox:latest").
30-
pub name: String,
30+
#[serde(skip_serializing_if = "Option::is_none")]
31+
pub name: Option<String>,
3132
}
3233

3334
#[cfg(test)]
@@ -41,7 +42,7 @@ mod tests {
4142
uid: Some("sandbox-abc123".to_string()),
4243
image: Some(Image {
4344
uid: "sha256:0123456789abcdef".to_string(),
44-
name: "ghcr.io/openshell/sandbox:latest".to_string(),
45+
name: Some("ghcr.io/openshell/sandbox:latest".to_string()),
4546
}),
4647
};
4748

0 commit comments

Comments
 (0)