Skip to content
Closed
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
10 changes: 10 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,16 @@ 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.
Network Activity records identify at least one observed endpoint. HTTP Activity
records contain a request or response; early rejections with only connection
context use Network Activity. Configuration diagnostics use Config State Change,
and monitor startup failures use Application Lifecycle. Unix socket relay and
relay-control notifications use Base Event when no network endpoint is available.
Producer regression tests check required fields and `at_least_one` constraints
against the vendored OCSF 1.8 schemas.
Shorthand logs retain diagnostic messages for network operational failures and
failed application lifecycle events, including when endpoint or component fields
are present.

## Policy Proposals

Expand Down
4 changes: 4 additions & 0 deletions crates/openshell-ocsf/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ rust-version.workspace = true
license.workspace = true
repository.workspace = true

[features]
# Share vendored-schema assertions with producer regression tests.
test-support = []

[dependencies]
chrono = { version = "0.4", features = ["serde"] }
serde = { workspace = true }
Expand Down
50 changes: 46 additions & 4 deletions crates/openshell-ocsf/src/builders/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ use crate::objects::Product;
pub struct AppLifecycleBuilder<'a> {
ctx: &'a EventContext,
activity: ActivityId,
app_name: Option<String>,
severity: SeverityId,
status: Option<StatusId>,
status_detail: Option<String>,
message: Option<String>,
}

Expand All @@ -24,12 +26,28 @@ impl<'a> AppLifecycleBuilder<'a> {
Self {
ctx,
activity: ActivityId::Unknown,
app_name: None,
severity: SeverityId::Informational,
status: None,
status_detail: None,
message: None,
}
}

/// Identify a supervisor component whose lifecycle is being reported.
#[must_use]
pub fn app_name(mut self, name: impl Into<String>) -> Self {
self.app_name = Some(name.into());
self
}

/// Set a machine-readable detail for the lifecycle status.
#[must_use]
pub fn status_detail(mut self, detail: impl Into<String>) -> Self {
self.status_detail = Some(detail.into());
self
}

#[must_use]
pub fn build(self) -> OcsfEvent {
let activity_name = self.activity.lifecycle_label().to_string();
Expand All @@ -43,13 +61,17 @@ impl<'a> AppLifecycleBuilder<'a> {
self.severity,
self.ctx.metadata(&["container", "host"]),
);
if let Some(detail) = self.status_detail {
base.set_status_detail(detail);
}
self.ctx
.apply_common_fields(&mut base, self.status, self.message);

OcsfEvent::ApplicationLifecycle(ApplicationLifecycleEvent {
base,
app: Product::openshell_sandbox(&self.ctx.product_version),
})
let mut app = Product::openshell_sandbox(&self.ctx.product_version);
if let Some(name) = self.app_name {
app.name = name;
}
OcsfEvent::ApplicationLifecycle(ApplicationLifecycleEvent { base, app })
}
}

Expand All @@ -61,6 +83,26 @@ mod tests {
use super::*;
use crate::builders::test_sandbox_context;

#[test]
fn component_start_failure_identifies_component() {
let ctx = test_sandbox_context();
let json = AppLifecycleBuilder::new(&ctx)
.app_name("OpenShell Bypass Monitor")
.activity(ActivityId::Reset)
.status(StatusId::Failure)
.build()
.to_json()
.unwrap();
assert_eq!(json["app"]["name"], "OpenShell Bypass Monitor");
assert_eq!(json["app"]["vendor_name"], "OpenShell");
assert_eq!(json["activity_name"], "Start");
assert_eq!(json["status"], "Failure");
crate::validation::validate_required_fields(
&json,
&crate::validation::load_class_schema("application_lifecycle"),
);
}

#[test]
fn test_app_lifecycle_builder() {
let ctx = test_sandbox_context();
Expand Down
11 changes: 10 additions & 1 deletion crates/openshell-ocsf/src/format/shorthand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,10 @@ impl OcsfEvent {
// policy-DNS mapping in the human-readable audit log.
let show_correlation_message =
e.base.status_detail.as_deref() == Some("transparent_tcp_allowed");
let show_failure_message =
e.base.status_detail.as_deref() == Some("proxy_accept_error");
let message_ctx = if show_correlation_message
|| show_failure_message
|| (detail.is_empty() && rule_ctx.is_empty() && reason_ctx.is_empty())
{
message_tag(&e.base)
Expand Down Expand Up @@ -440,7 +443,13 @@ impl OcsfEvent {
.map(|s| s.label().to_lowercase())
.unwrap_or_default();

format!("LIFECYCLE:{activity} {sev} {app} {status}")
let message_ctx =
if e.base.status_detail.as_deref() == Some("bypass_monitor_start_failure") {
message_tag(&e.base)
} else {
String::new()
};
format!("LIFECYCLE:{activity} {sev} {app} {status}{message_ctx}")
}

Self::DeviceConfigStateChange(e) => {
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-ocsf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub mod format;
pub mod objects;
pub mod tracing_layers;

#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub mod validation;

// --- Core event types ---
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-ocsf/src/validation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

//! Schema validation utilities for testing OCSF events against vendored schemas.
//!
//! These utilities are gated behind `#[cfg(test)]` — they are only available
//! in test builds.
//! Available in this crate's tests or through the `test-support` feature for
//! producer regression tests.

pub mod schema;

Expand Down
44 changes: 42 additions & 2 deletions crates/openshell-ocsf/src/validation/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub fn load_object_schema(object: &str) -> Value {
serde_json::from_str(&data).unwrap_or_else(|e| panic!("Invalid JSON in {path}: {e}"))
}

/// Validate that all required fields from the schema are present in the event JSON.
/// Validate required fields and the schema's `at_least_one` constraint.
///
/// The OCSF schema stores attributes as an object where each key is a field name
/// and the value contains a `requirement` field.
Expand All @@ -56,12 +56,26 @@ pub fn validate_required_fields(event: &Value, schema: &Value) {
_ => return,
};

if let Some(fields) = schema
.get("constraints")
.and_then(|constraints| constraints.get("at_least_one"))
.and_then(Value::as_array)
{
assert!(
fields
.iter()
.filter_map(Value::as_str)
.any(|field| { event.get(field).is_some_and(|value| !value.is_null()) }),
"Missing at_least_one field from {fields:?}"
);
}

for (name, def) in &attrs {
let is_required = def.get("requirement").and_then(|r| r.as_str()) == Some("required");
let is_profile_field = def.get("profile").is_some() || def.get("profiles").is_some();
if is_required && !is_profile_field {
assert!(
event.get(name).is_some(),
event.get(name).is_some_and(|value| !value.is_null()),
"Missing required field '{name}' in OCSF event. Event keys: {:?}",
event.as_object().map(|o| o.keys().collect::<Vec<_>>())
);
Expand Down Expand Up @@ -92,6 +106,32 @@ pub fn validate_enum_value(event: &Value, field: &str, schema: &Value) {
mod tests {
use super::*;

#[test]
fn network_and_http_require_at_least_one_non_null_field() {
for (class, fields) in [
("network_activity", ["src_endpoint", "dst_endpoint"]),
("http_activity", ["http_request", "http_response"]),
] {
let schema = load_class_schema(class);
let mut event = serde_json::json!({
"class_uid": schema["uid"], "severity_id": 1, "metadata": {},
"time": 12345, "type_uid": 0, "activity_id": 0, "category_uid": 4
});
assert!(
std::panic::catch_unwind(|| validate_required_fields(&event, &schema)).is_err()
);
for field in fields {
event[field] = Value::Null;
assert!(
std::panic::catch_unwind(|| validate_required_fields(&event, &schema)).is_err()
);
event[field] = serde_json::json!({});
validate_required_fields(&event, &schema);
event.as_object_mut().unwrap().remove(field);
}
}
}

#[test]
fn test_load_class_schemas() {
// These tests only pass when the vendored schemas are present
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-sandbox/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ telemetry = ["openshell-core/telemetry"]
bundled-ca-roots = ["openshell-supervisor-network/bundled-ca-roots"]

[dev-dependencies]
openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] }
tempfile = "3"
temp-env = "0.3"
tokio-tungstenite = { workspace = true }
Expand Down
84 changes: 69 additions & 15 deletions crates/openshell-sandbox/src/google_cloud_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ fn route_request(
) -> MetadataResponse {
if method != "GET" {
emit_metadata_event(
ActivityId::Refuse,
405,
SeverityId::Low,
StatusId::Failure,
&format!("metadata: unsupported method {method}"),
Expand All @@ -93,10 +93,13 @@ fn route_request(

if let Err(resp) = validate_metadata_headers(headers) {
emit_metadata_event(
ActivityId::Refuse,
resp.0,
SeverityId::Medium,
StatusId::Failure,
&format!("metadata: header validation failed for {path}"),
&format!(
"metadata: header validation failed for {}",
path.split('?').next().unwrap_or(path)
),
);
return resp;
}
Expand Down Expand Up @@ -133,7 +136,7 @@ fn route_request(
"/computeMetadata/v1/instance" => (200, "text/plain", "service-accounts/\n".to_string()),
_ => {
emit_metadata_event(
ActivityId::Refuse,
404,
SeverityId::Low,
StatusId::Failure,
&format!("metadata: unknown path {route}"),
Expand Down Expand Up @@ -161,7 +164,7 @@ fn handle_token(ctx: &MetadataContext) -> MetadataResponse {
"credentials_unavailable",
)
};
emit_metadata_event(ActivityId::Fail, SeverityId::Medium, StatusId::Failure, msg);
emit_metadata_event(503, SeverityId::Medium, StatusId::Failure, msg);
return (
503,
"application/json",
Expand All @@ -170,7 +173,7 @@ fn handle_token(ctx: &MetadataContext) -> MetadataResponse {
};

emit_metadata_event(
ActivityId::Open,
200,
SeverityId::Informational,
StatusId::Success,
"metadata: token placeholder served",
Expand Down Expand Up @@ -212,7 +215,7 @@ fn handle_service_account_recursive(ctx: &MetadataContext) -> MetadataResponse {
fn handle_env(ctx: &MetadataContext, env_key: &str) -> MetadataResponse {
let Some(resolver) = ctx.credentials.resolver() else {
emit_metadata_event(
ActivityId::Fail,
503,
SeverityId::Medium,
StatusId::Failure,
&format!("metadata: {env_key} request but no credentials configured"),
Expand All @@ -224,7 +227,7 @@ fn handle_env(ctx: &MetadataContext, env_key: &str) -> MetadataResponse {
resolver.resolve_placeholder(&placeholder).map_or_else(
|| {
emit_metadata_event(
ActivityId::Fail,
404,
SeverityId::Low,
StatusId::Failure,
&format!("metadata: {env_key} not configured"),
Expand Down Expand Up @@ -304,19 +307,30 @@ where
Ok(())
}

fn emit_metadata_event(
activity: ActivityId,
fn emit_metadata_event(response_code: u16, severity: SeverityId, status: StatusId, message: &str) {
ocsf_emit!(build_metadata_event(
response_code,
severity,
status,
message
));
}

fn build_metadata_event(
response_code: u16,
severity: SeverityId,
status: StatusId,
message: &str,
) {
let event = HttpActivityBuilder::new(crate::ocsf_ctx())
.activity(activity)
) -> openshell_ocsf::OcsfEvent {
HttpActivityBuilder::new(crate::ocsf_ctx())
.activity(ActivityId::Other)
.http_response(openshell_ocsf::HttpResponse {
code: response_code,
})
.severity(severity)
.status(status)
.message(message.to_string())
.build();
ocsf_emit!(event);
.build()
}

#[cfg(test)]
Expand All @@ -342,6 +356,46 @@ mod tests {
vec![("Metadata-Flavor".to_string(), "Google".to_string())]
}

#[test]
fn metadata_events_include_response_for_ocsf18() {
use openshell_ocsf::tracing_layers::OcsfJsonlLayer;
use openshell_ocsf::validation::{
load_class_schema, validate_enum_value, validate_required_fields,
};
use tracing_subscriber::prelude::*;

let schema = load_class_schema("http_activity");
for (method, path, headers, expected_code) in [
("GET", PATH_TOKEN, flavor_headers(), 200),
("GET", "/?token=secret-query", Vec::new(), 403),
("GET", "/unknown", flavor_headers(), 404),
("POST", PATH_TOKEN, flavor_headers(), 405),
("GET", PATH_TOKEN, flavor_headers(), 503),
("GET", PATH_EMAIL, flavor_headers(), 404),
] {
let env = if expected_code == 503 {
HashMap::new()
} else {
HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), "test-token".to_string())])
};
let ctx = make_context(env);
let log = tempfile::NamedTempFile::new().unwrap();
let subscriber =
tracing_subscriber::registry().with(OcsfJsonlLayer::new(log.reopen().unwrap()));
let response = tracing::subscriber::with_default(subscriber, || {
route_request(&ctx, method, path, &headers)
});
assert_eq!(response.0, expected_code);
let output = std::fs::read_to_string(log.path()).unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(json["http_response"]["code"], response.0);
assert!(!output.contains("secret-query"), "{output}");
assert!(json.get("http_request").is_none());
validate_required_fields(&json, &schema);
validate_enum_value(&json, "activity_id", &schema);
}
}

#[test]
fn token_returns_placeholder_not_real_value() {
let ctx = make_context(HashMap::from([(
Expand Down
Loading
Loading