diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 7be66e97e6..ad1e9e95a2 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -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 diff --git a/crates/openshell-ocsf/Cargo.toml b/crates/openshell-ocsf/Cargo.toml index 69c7b7e0aa..af387c88cf 100644 --- a/crates/openshell-ocsf/Cargo.toml +++ b/crates/openshell-ocsf/Cargo.toml @@ -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 } diff --git a/crates/openshell-ocsf/src/builders/lifecycle.rs b/crates/openshell-ocsf/src/builders/lifecycle.rs index 8fc648ee37..51b8a91ad3 100644 --- a/crates/openshell-ocsf/src/builders/lifecycle.rs +++ b/crates/openshell-ocsf/src/builders/lifecycle.rs @@ -13,8 +13,10 @@ use crate::objects::Product; pub struct AppLifecycleBuilder<'a> { ctx: &'a EventContext, activity: ActivityId, + app_name: Option, severity: SeverityId, status: Option, + status_detail: Option, message: Option, } @@ -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) -> 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) -> Self { + self.status_detail = Some(detail.into()); + self + } + #[must_use] pub fn build(self) -> OcsfEvent { let activity_name = self.activity.lifecycle_label().to_string(); @@ -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 }) } } @@ -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(); diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 77e6751b10..3d16573cf3 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -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) @@ -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) => { diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs index e2bb0e8f3d..e65301e0c1 100644 --- a/crates/openshell-ocsf/src/lib.rs +++ b/crates/openshell-ocsf/src/lib.rs @@ -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 --- diff --git a/crates/openshell-ocsf/src/validation/mod.rs b/crates/openshell-ocsf/src/validation/mod.rs index 28545337b0..8df2832faa 100644 --- a/crates/openshell-ocsf/src/validation/mod.rs +++ b/crates/openshell-ocsf/src/validation/mod.rs @@ -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; diff --git a/crates/openshell-ocsf/src/validation/schema.rs b/crates/openshell-ocsf/src/validation/schema.rs index ee21619ba0..fe679c165d 100644 --- a/crates/openshell-ocsf/src/validation/schema.rs +++ b/crates/openshell-ocsf/src/validation/schema.rs @@ -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. @@ -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::>()) ); @@ -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 diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 3463f03767..c24825cf78 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -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 } diff --git a/crates/openshell-sandbox/src/google_cloud_metadata.rs b/crates/openshell-sandbox/src/google_cloud_metadata.rs index 9e1e179872..f9e208cb0e 100644 --- a/crates/openshell-sandbox/src/google_cloud_metadata.rs +++ b/crates/openshell-sandbox/src/google_cloud_metadata.rs @@ -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}"), @@ -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; } @@ -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}"), @@ -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", @@ -170,7 +173,7 @@ fn handle_token(ctx: &MetadataContext) -> MetadataResponse { }; emit_metadata_event( - ActivityId::Open, + 200, SeverityId::Informational, StatusId::Success, "metadata: token placeholder served", @@ -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"), @@ -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"), @@ -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)] @@ -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([( diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index edc5658c51..c76f38cebe 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -55,6 +55,7 @@ default = ["bundled-ca-roots"] bundled-ca-roots = ["dep:webpki-roots"] [dev-dependencies] +openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] } openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } tempfile = "3" tonic = { workspace = true } diff --git a/crates/openshell-supervisor-network/src/l7/graphql.rs b/crates/openshell-supervisor-network/src/l7/graphql.rs index 994d101314..04f05d7217 100644 --- a/crates/openshell-supervisor-network/src/l7/graphql.rs +++ b/crates/openshell-supervisor-network/src/l7/graphql.rs @@ -498,7 +498,7 @@ async fn read_chunked_body_for_inspection( .unwrap_or_default(); let chunk_size = usize::from_str_radix(size_token, 16) .into_diagnostic() - .map_err(|_| miette!("Invalid GraphQL chunk size token: {size_token:?}"))?; + .map_err(|_| miette!("Invalid GraphQL chunk size token"))?; pos = size_line_end + 2; if decoded.len().saturating_add(chunk_size) > max_body_bytes { @@ -734,6 +734,31 @@ mod tests { assert!(req.raw_header.ends_with(body)); } + #[tokio::test] + async fn invalid_chunk_size_does_not_echo_request_data() { + let sentinel = "graphql-chunk-secret"; + let mut req = L7Request { + action: "POST".to_string(), + target: "/graphql".to_string(), + query_params: HashMap::new(), + raw_header: format!( + "POST /graphql HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n{sentinel}\r\n" + ) + .into_bytes(), + body_length: BodyLength::Chunked, + }; + let error = + inspect_graphql_request(&mut tokio::io::empty(), &mut req, DEFAULT_MAX_BODY_BYTES) + .await + .expect_err("invalid chunk size must be rejected"); + assert!( + error + .to_string() + .contains("Invalid GraphQL chunk size token") + ); + assert!(!error.to_string().contains(sentinel)); + } + #[tokio::test] async fn absolute_form_chunked_graphql_post_classifies_after_inspection() { let body = br#"{"query":"query Viewer { viewer { login } }"}"#; diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 77e4497d48..502b3a839c 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -186,8 +186,7 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { let tls = match get_object_str(val, "tls").as_deref() { Some("skip") => TlsMode::Skip, Some("terminate") => { - let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Other) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::Medium) .message( "'tls: terminate' is deprecated; TLS termination is now automatic. \ @@ -198,8 +197,7 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { TlsMode::Auto } Some("passthrough") => { - let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Other) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::Medium) .message( "'tls: passthrough' is deprecated; TLS termination is now automatic. \ @@ -248,8 +246,7 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { Some("sigv4:body") => CredentialSigning::SigV4Body, Some("sigv4:no_body") => CredentialSigning::SigV4NoBody, Some(other) if !other.is_empty() => { - let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Other) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::High) .message(format!( "rejecting endpoint: unrecognized credential_signing value {other:?}" @@ -265,8 +262,7 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { let signing_region = get_object_str(val, "signing_region").unwrap_or_default(); if credential_signing.is_sigv4() && signing_service.is_empty() { - let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Other) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::High) .message("rejecting endpoint: credential_signing requires signing_service".to_string()) .build(); diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index aa036dee94..2ac9881d0d 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -255,7 +255,7 @@ where } fn build_request_authority_mismatch_event(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -295,7 +295,7 @@ fn build_credential_resolution_event( ctx: &L7EvalContext, endpoint_mismatch: bool, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -3090,6 +3090,31 @@ mod tests { (state, resolver) } + #[test] + fn early_rejections_use_network_class_with_known_destination() { + use openshell_ocsf::validation::{ + load_class_schema, validate_enum_value, validate_required_fields, + }; + let ctx = L7EvalContext { + host: "example.com".into(), + port: 443, + ..Default::default() + }; + let schema = load_class_schema("network_activity"); + for event in [ + build_request_authority_mismatch_event(&ctx), + build_credential_resolution_event(&ctx, true), + build_credential_resolution_event(&ctx, false), + ] { + let json = event.to_json().unwrap(); + assert_eq!(json["class_uid"], 4001); + assert_eq!(json["dst_endpoint"]["domain"], "example.com"); + assert_eq!(json["action_id"], 2); + validate_required_fields(&json, &schema); + validate_enum_value(&json, "activity_id", &schema); + } + } + #[test] fn websocket_preflight_input_carries_real_sandbox_name() { let sandbox = openshell_ocsf::EventContext { diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index e3a481941c..561bf72ce8 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -66,8 +66,21 @@ const FORWARD_ENCODED_SLASH_REJECTION_DETAIL: &str = #[cfg(target_os = "linux")] const SIDECAR_SUPERVISOR_TOPOLOGY: &str = "sidecar"; +fn build_connection_error_event( + peer_addr: SocketAddr, + message: String, +) -> openshell_ocsf::OcsfEvent { + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) + .message(message) + .build() +} + fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -272,7 +285,7 @@ impl ProxyHandle { let mut consecutive_unknown_errors: u32 = 0; loop { match listener.accept().await { - Ok((stream, _addr)) => { + Ok((stream, peer_addr)) => { consecutive_resource_errors = 0; consecutive_unknown_errors = 0; set_tcp_nodelay_best_effort(&stream); @@ -315,45 +328,24 @@ impl ProxyHandle { ) .await { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .message(format!("Proxy connection error: {err}")) - .build(); + let event = build_connection_error_event( + peer_addr, + format!("Proxy connection error: {err}"), + ); ocsf_emit!(event); } }); } Err(err) => { - match classify_accept_error( + let action = classify_accept_error( &err, &mut consecutive_resource_errors, &mut consecutive_unknown_errors, - ) { - AcceptAction::Terminal => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::High) - .status(StatusId::Failure) - .message(format!( - "Proxy accept loop exiting on terminal error: {err}", - )) - .build(); - ocsf_emit!(event); - break; - } - AcceptAction::Retry { backoff, severity } => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(severity) - .status(StatusId::Failure) - .message(format!( - "Proxy accept error (retrying in {}ms): {err}", - backoff.as_millis(), - )) - .build(); - ocsf_emit!(event); + ); + ocsf_emit!(build_accept_error_event(local_addr, &err, &action)); + match action { + AcceptAction::Terminal => break, + AcceptAction::Retry { backoff, .. } => { tokio::time::sleep(backoff).await; } } @@ -434,7 +426,7 @@ impl TransparentTcpHandle { ); } loop { - let Ok((stream, _)) = listener.accept().await else { + let Ok((stream, peer_addr)) = listener.accept().await else { break; }; set_tcp_nodelay_best_effort(&stream); @@ -461,12 +453,7 @@ impl TransparentTcpHandle { .await { ocsf_emit!( - NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .message(format!("Transparent TCP connection error: {error}")) - .build() + build_connection_error_event(peer_addr, format!("Transparent TCP connection error: {error}")) ); } }); @@ -905,6 +892,34 @@ enum AcceptAction { }, } +fn build_accept_error_event( + local_addr: SocketAddr, + err: &std::io::Error, + action: &AcceptAction, +) -> openshell_ocsf::OcsfEvent { + let (severity, message) = match action { + AcceptAction::Terminal => ( + SeverityId::High, + format!("Proxy accept loop exiting on terminal error: {err}"), + ), + AcceptAction::Retry { backoff, severity } => ( + *severity, + format!( + "Proxy accept error (retrying in {}ms): {err}", + backoff.as_millis() + ), + ), + }; + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .dst_endpoint(Endpoint::from_ip(local_addr.ip(), local_addr.port())) + .severity(severity) + .status(StatusId::Failure) + .status_detail("proxy_accept_error") + .message(message) + .build() +} + fn classify_accept_error( err: &std::io::Error, consecutive_resource_errors: &mut u32, @@ -1322,15 +1337,60 @@ fn build_forward_allow_ocsf_event( .build() } -fn build_forward_parse_error_ocsf_event(path: &str) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) +fn build_forward_parse_error_ocsf_event( + peer_addr: SocketAddr, + path: &str, +) -> openshell_ocsf::OcsfEvent { + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) + .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) .severity(SeverityId::Low) .status(StatusId::Failure) .message(format!("FORWARD parse error for {path}")) .build() } +/// Build the rejection event for an absolute-form request whose scheme is not +/// supported by the forward proxy. `path` must already be query-free and have +/// credential-reference syntax redacted by [`forward_telemetry_path`]. +fn build_forward_unsupported_scheme_ocsf_event( + method: &str, + scheme: &str, + host: &str, + port: u16, + path: &str, +) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .http_request(HttpRequest::new( + method, + OcsfUrl::new(scheme, host, path, port), + )) + .action(ActionId::Denied) + .disposition(DispositionId::Rejected) + .severity(SeverityId::Informational) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(host, port)) + .message(format!( + "FORWARD rejected: unsupported scheme {scheme} for {host}:{port}" + )) + .build() +} + +fn build_forward_graphql_inspection_failure_ocsf_event( + host: &str, + port: u16, +) -> openshell_ocsf::OcsfEvent { + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(host, port)) + .message("FORWARD_GRAPHQL_L7 request rejected during inspection") + .status_detail("graphql_request_inspection_failed") + .build() +} + #[allow(clippy::too_many_arguments)] fn build_forward_l7_parse_rejection_ocsf_event( peer_addr: SocketAddr, @@ -3454,8 +3514,7 @@ fn parse_allowed_ips(raw: &[String]) -> std::result::Result, S } if n.prefix_len() < MIN_SAFE_PREFIX_LEN { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Other) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(SeverityId::Medium) .message(format!( "allowed_ips entry has a very broad CIDR {n} (/{}) < /{MIN_SAFE_PREFIX_LEN}; \ @@ -4097,12 +4156,16 @@ async fn handle_forward_proxy( denial_tx: Option<&mpsc::UnboundedSender>, activity_tx: Option<&ActivitySender>, ) -> Result<()> { + let workload_addr = client.peer_addr().into_diagnostic()?; let mut telemetry_path = forward_telemetry_path(target_uri); // 1. Parse the absolute-form URI. Every external forward target is // canonicalized below before credential binding, policy-path evaluation, // upstream bytes, or telemetry consume it. let Ok((scheme, host, port, mut path)) = parse_proxy_uri(target_uri) else { - ocsf_emit!(build_forward_parse_error_ocsf_event(&telemetry_path)); + ocsf_emit!(build_forward_parse_error_ocsf_event( + workload_addr, + &telemetry_path + )); respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); }; @@ -4144,17 +4207,13 @@ async fn handle_forward_proxy( } if scheme != "http" { - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Refuse) - .action(ActionId::Denied) - .disposition(DispositionId::Rejected) - .severity(SeverityId::Informational) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .message(format!( - "FORWARD rejected: unsupported scheme {scheme} for {host_lc}:{port}" - )) - .build(); + let event = build_forward_unsupported_scheme_ocsf_event( + method, + &scheme, + &host_lc, + port, + &telemetry_path, + ); ocsf_emit!(event); if scheme == "https" { respond( @@ -4182,7 +4241,6 @@ async fn handle_forward_proxy( canonicalize_forward_host_header(&buf[..used], &canonical_authority)?; // 2. Evaluate OPA policy (same identity binding as CONNECT) - let workload_addr = client.peer_addr().into_diagnostic()?; let proxy_addr = client.local_addr().into_diagnostic()?; let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); @@ -4610,14 +4668,9 @@ async fn handle_forward_proxy( { Ok(info) => info, Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .message(format!("FORWARD_GRAPHQL_L7 request rejected: {e}")) - .build(); - ocsf_emit!(event); + ocsf_emit!(build_forward_graphql_inspection_failure_ocsf_event( + &host_lc, port, + )); emit_activity_simple(activity_tx, true, "l7_parse_rejection"); respond( client, @@ -6361,6 +6414,52 @@ network_policies: ); } + #[test] + fn accept_errors_preserve_endpoint_and_diagnostics_in_shorthand() { + use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; + let addr = "127.0.0.1:3128".parse().unwrap(); + let error = std::io::Error::other("accept failed"); + for (action, expected) in [ + ( + AcceptAction::Terminal, + "exiting on terminal error: accept failed", + ), + ( + AcceptAction::Retry { + backoff: std::time::Duration::from_millis(250), + severity: SeverityId::Low, + }, + "retrying in 250ms): accept failed", + ), + ] { + let event = build_accept_error_event(addr, &error, &action); + let json = event.to_json().unwrap(); + validate_required_fields(&json, &load_class_schema("network_activity")); + assert_eq!(json["dst_endpoint"]["ip"], "127.0.0.1"); + assert_eq!(json["dst_endpoint"]["port"], 3128); + let shorthand = event.format_shorthand(); + assert!(shorthand.contains("127.0.0.1:3128"), "{shorthand}"); + assert!(shorthand.contains(expected), "{shorthand}"); + } + } + + #[test] + fn connection_and_parse_errors_include_known_peer_for_ocsf18() { + use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; + let peer: SocketAddr = "127.0.0.1:54321".parse().unwrap(); + let schema = load_class_schema("network_activity"); + for event in [ + build_connection_error_event(peer, "Proxy connection error".to_string()), + build_forward_parse_error_ocsf_event(peer, "/[INVALID_REQUEST_TARGET]"), + ] { + let json = event.to_json().unwrap(); + assert_eq!(json["class_uid"], 4001); + assert_eq!(json["src_endpoint"]["ip"], "127.0.0.1"); + assert_eq!(json["src_endpoint"]["port"], 54321); + validate_required_fields(&json, &schema); + } + } + #[test] fn middleware_failure_response_uses_platform_text_without_policy_guidance() { let response = build_middleware_failure_response("api-policy"); @@ -6701,9 +6800,12 @@ network_policies: assert!(!serialized.contains("real-secret"), "{serialized}"); assert!(!serialized.contains("?token="), "{serialized}"); - let malformed = build_forward_parse_error_ocsf_event(&forward_telemetry_path( - "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", - )) + let malformed = build_forward_parse_error_ocsf_event( + "127.0.0.1:12345".parse().unwrap(), + &forward_telemetry_path( + "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", + ), + ) .to_json() .unwrap(); assert_eq!( @@ -9257,6 +9359,42 @@ network_policies: assert!(!malformed.contains("real-secret")); } + #[test] + fn unsupported_forward_scheme_event_keeps_only_redacted_request_context() { + let target = "https://api.example.com/v1/openshell:resolve:env:API_TOKEN?token=real-secret"; + let (_, host, port, _) = parse_proxy_uri(target).unwrap(); + let event = build_forward_unsupported_scheme_ocsf_event( + "GET", + "https", + &host, + port, + &forward_telemetry_path(target), + ); + + let json = event.to_json().unwrap(); + assert_eq!(json["http_request"]["http_method"], "GET"); + assert_eq!(json["http_request"]["url"]["scheme"], "https"); + assert_eq!(json["http_request"]["url"]["hostname"], "api.example.com"); + assert_eq!(json["http_request"]["url"]["path"], "/v1/[CREDENTIAL]"); + assert_eq!(json["http_request"]["url"]["port"], 443); + + let rendered = format!("{json} {}", event.format_shorthand()); + assert!(!rendered.contains("API_TOKEN")); + assert!(!rendered.contains("real-secret")); + } + + #[test] + fn graphql_inspection_failure_event_has_no_parser_detail() { + let event = build_forward_graphql_inspection_failure_ocsf_event("api.example.com", 443); + let json = event.to_json().unwrap(); + + assert_eq!( + json["message"], + "FORWARD_GRAPHQL_L7 request rejected during inspection" + ); + assert_eq!(json["status_detail"], "graphql_request_inspection_failed"); + } + #[test] fn forward_credentials_capture_endpoint_resolver_and_revision_together() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; diff --git a/crates/openshell-supervisor-process/Cargo.toml b/crates/openshell-supervisor-process/Cargo.toml index 2e2120f1d0..7076c02f70 100644 --- a/crates/openshell-supervisor-process/Cargo.toml +++ b/crates/openshell-supervisor-process/Cargo.toml @@ -45,6 +45,7 @@ socket2 = { workspace = true } tempfile = "3" [dev-dependencies] +openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] } tempfile = "3" [lints] diff --git a/crates/openshell-supervisor-process/src/bypass_monitor/mod.rs b/crates/openshell-supervisor-process/src/bypass_monitor/mod.rs index 44847b0d13..3c92d2dcc8 100644 --- a/crates/openshell-supervisor-process/src/bypass_monitor/mod.rs +++ b/crates/openshell-supervisor-process/src/bypass_monitor/mod.rs @@ -159,6 +159,17 @@ fn hint_for_event(event: &BypassEvent) -> &'static str { } } +fn build_start_failure_event(message: impl Into) -> openshell_ocsf::OcsfEvent { + openshell_ocsf::AppLifecycleBuilder::new(openshell_ocsf::ctx::ctx()) + .app_name("OpenShell Bypass Monitor") + .activity(ActivityId::Reset) + .status(openshell_ocsf::StatusId::Failure) + .status_detail("bypass_monitor_start_failure") + .severity(SeverityId::Low) + .message(message) + .build() +} + /// Spawn the bypass monitor as a background tokio task. /// /// Uses `dmesg --follow` to tail the kernel ring buffer for nftables log @@ -189,14 +200,10 @@ pub fn spawn( .status(); if !dmesg_check.is_ok_and(|s| s.success()) { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Other) - .severity(SeverityId::Low) - .message( - "dmesg not available; bypass detection monitor will not run. \ + let event = build_start_failure_event( + "dmesg not available; bypass detection monitor will not run. \ Bypass REJECT rules still provide fast-fail behavior.", - ) - .build(); + ); ocsf_emit!(event); return None; } @@ -217,24 +224,18 @@ pub fn spawn( { Ok(c) => c, Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Other) - .severity(SeverityId::Low) - .message(format!( - "Failed to start dmesg --follow; bypass monitor will not run: {e}" - )) - .build(); + let event = build_start_failure_event(format!( + "Failed to start dmesg --follow; bypass monitor will not run: {e}" + )); ocsf_emit!(event); return; } }; let Some(stdout) = child.stdout.take() else { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Other) - .severity(SeverityId::Low) - .message("dmesg --follow produced no stdout; bypass monitor will not run") - .build(); + let event = build_start_failure_event( + "dmesg --follow produced no stdout; bypass monitor will not run", + ); ocsf_emit!(event); return; }; @@ -382,6 +383,28 @@ fn resolve_process_identity(entrypoint_pid: u32, src_port: u16) -> (String, Stri mod tests { use super::*; + #[test] + fn start_failures_preserve_component_and_cause_in_shorthand() { + use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; + for message in [ + "dmesg not available; bypass detection monitor will not run", + "Failed to start dmesg --follow; bypass monitor will not run: permission denied", + "dmesg --follow produced no stdout; bypass monitor will not run", + ] { + let event = build_start_failure_event(message); + let json = event.to_json().unwrap(); + validate_required_fields(&json, &load_class_schema("application_lifecycle")); + assert_eq!(json["app"]["name"], "OpenShell Bypass Monitor"); + assert_eq!(json["message"], message); + let shorthand = event.format_shorthand(); + assert!( + shorthand.contains("OpenShell Bypass Monitor failure"), + "{shorthand}" + ); + assert!(shorthand.contains(message), "{shorthand}"); + } + } + #[test] fn parse_kmsg_line_tcp_bypass() { let line = "6,1234,5678,-;openshell:bypass:sandbox-abcd1234:IN= OUT=veth-s-abcd1234 \ diff --git a/crates/openshell-supervisor-process/src/log_push.rs b/crates/openshell-supervisor-process/src/log_push.rs index b24382787c..45960656bd 100644 --- a/crates/openshell-supervisor-process/src/log_push.rs +++ b/crates/openshell-supervisor-process/src/log_push.rs @@ -378,6 +378,23 @@ mod tests { assert!(line.timestamp_ms > 0); } + #[test] + fn unclassified_network_failures_do_not_push_raw_messages() { + let sentinel = "raw-parser-secret"; + let event = NetworkActivityBuilder::new(&ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain("example.com", 443)) + .message(format!("parser failed: {sentinel}")) + .build(); + + let lines = capture(16, || ocsf_emit!(event)); + + assert_eq!(lines.len(), 1); + assert!(!lines[0].message.contains(sentinel), "{:?}", lines[0]); + } + #[test] fn non_ocsf_events_use_visitor_extraction() { let lines = capture(16, || { diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index a8f60a5681..cfa7dc6969 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -143,17 +143,23 @@ fn relay_open_event( open: &RelayOpen, ssh_socket_path: &std::path::Path, ) -> OcsfEvent { - let mut builder = NetworkActivityBuilder::new(ctx) + // Unix socket relay operations have no network endpoint. + let Some(endpoint) = relay_target_endpoint(open) else { + return openshell_ocsf::BaseEventBuilder::new(ctx) + .activity_name("Relay open") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(relay_target_message(open, "open", ssh_socket_path)) + .build(); + }; + NetworkActivityBuilder::new(ctx) .activity(ActivityId::Open) .severity(SeverityId::Informational) .status(StatusId::Success) - .message(relay_target_message(open, "open", ssh_socket_path)); - if let Some(endpoint) = relay_target_endpoint(open) { - builder = builder - .dst_endpoint(endpoint) - .connection_info(ConnectionInfo::new("tcp")); - } - builder.build() + .message(relay_target_message(open, "open", ssh_socket_path)) + .dst_endpoint(endpoint) + .connection_info(ConnectionInfo::new("tcp")) + .build() } fn relay_closed_event( @@ -161,17 +167,23 @@ fn relay_closed_event( open: &RelayOpen, ssh_socket_path: &std::path::Path, ) -> OcsfEvent { - let mut builder = NetworkActivityBuilder::new(ctx) + // Unix socket relay operations have no network endpoint. + let Some(endpoint) = relay_target_endpoint(open) else { + return openshell_ocsf::BaseEventBuilder::new(ctx) + .activity_name("Relay closed") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(relay_target_message(open, "closed", ssh_socket_path)) + .build(); + }; + NetworkActivityBuilder::new(ctx) .activity(ActivityId::Close) .severity(SeverityId::Informational) .status(StatusId::Success) - .message(relay_target_message(open, "closed", ssh_socket_path)); - if let Some(endpoint) = relay_target_endpoint(open) { - builder = builder - .dst_endpoint(endpoint) - .connection_info(ConnectionInfo::new("tcp")); - } - builder.build() + .message(relay_target_message(open, "closed", ssh_socket_path)) + .dst_endpoint(endpoint) + .connection_info(ConnectionInfo::new("tcp")) + .build() } fn relay_failed_event( @@ -180,25 +192,34 @@ fn relay_failed_event( ssh_socket_path: &std::path::Path, error: &str, ) -> OcsfEvent { - let mut builder = NetworkActivityBuilder::new(ctx) + // Unix socket relay operations have no network endpoint. + let Some(endpoint) = relay_target_endpoint(open) else { + return openshell_ocsf::BaseEventBuilder::new(ctx) + .activity_name("Relay failed") + .severity(SeverityId::Low) + .status(StatusId::Failure) + .message(format!( + "{}: {error}", + relay_target_message(open, "bridge failed", ssh_socket_path) + )) + .build(); + }; + NetworkActivityBuilder::new(ctx) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) .message(format!( "{}: {error}", relay_target_message(open, "bridge failed", ssh_socket_path) - )); - if let Some(endpoint) = relay_target_endpoint(open) { - builder = builder - .dst_endpoint(endpoint) - .connection_info(ConnectionInfo::new("tcp")); - } - builder.build() + )) + .dst_endpoint(endpoint) + .connection_info(ConnectionInfo::new("tcp")) + .build() } fn relay_close_from_gateway_event(ctx: &EventContext, channel_id: &str, reason: &str) -> OcsfEvent { - NetworkActivityBuilder::new(ctx) - .activity(ActivityId::Close) + openshell_ocsf::BaseEventBuilder::new(ctx) + .activity_name("Relay close from gateway") .severity(SeverityId::Informational) .message(format!( "relay close from gateway (channel_id={channel_id}, reason={reason})" @@ -994,10 +1015,16 @@ mod ocsf_event_tests { } #[test] - fn relay_open_emits_network_open_success() { + fn relay_open_emits_base_open_success() { let event = relay_open_event(&ctx(), &ssh_relay_open("ch-42"), ssh_socket_path()); - let na = network_activity(&event); - assert_eq!(na.base.activity_id, ActivityId::Open.as_u8()); + let OcsfEvent::Base(na) = &event else { + panic!("expected Base Event for relay control operation") + }; + openshell_ocsf::validation::validate_required_fields( + &event.to_json().unwrap(), + &openshell_ocsf::validation::load_class_schema("base_event"), + ); + assert_eq!(na.base.activity_name, "Relay open"); assert_eq!(na.base.severity, SeverityId::Informational); let msg = na.base.message.as_deref().unwrap_or_default(); assert!(msg.contains("ch-42"), "message: {msg}"); @@ -1030,23 +1057,35 @@ mod ocsf_event_tests { } #[test] - fn relay_closed_emits_network_close_success() { + fn relay_closed_emits_base_close_success() { let event = relay_closed_event(&ctx(), &ssh_relay_open("ch-42"), ssh_socket_path()); - let na = network_activity(&event); - assert_eq!(na.base.activity_id, ActivityId::Close.as_u8()); + let OcsfEvent::Base(na) = &event else { + panic!("expected Base Event for relay control operation") + }; + openshell_ocsf::validation::validate_required_fields( + &event.to_json().unwrap(), + &openshell_ocsf::validation::load_class_schema("base_event"), + ); + assert_eq!(na.base.activity_name, "Relay closed"); assert_eq!(na.base.status, Some(StatusId::Success)); } #[test] - fn relay_failed_emits_network_fail_low() { + fn relay_failed_emits_base_fail_low() { let event = relay_failed_event( &ctx(), &ssh_relay_open("ch-42"), ssh_socket_path(), "write to ssh failed", ); - let na = network_activity(&event); - assert_eq!(na.base.activity_id, ActivityId::Fail.as_u8()); + let OcsfEvent::Base(na) = &event else { + panic!("expected Base Event for relay control operation") + }; + openshell_ocsf::validation::validate_required_fields( + &event.to_json().unwrap(), + &openshell_ocsf::validation::load_class_schema("base_event"), + ); + assert_eq!(na.base.activity_name, "Relay failed"); assert_eq!(na.base.severity, SeverityId::Low); assert_eq!(na.base.status, Some(StatusId::Failure)); let msg = na.base.message.as_deref().unwrap_or_default(); @@ -1055,10 +1094,16 @@ mod ocsf_event_tests { } #[test] - fn relay_close_from_gateway_is_network_close_informational() { + fn relay_close_from_gateway_is_base_close_informational() { let event = relay_close_from_gateway_event(&ctx(), "ch-42", "sandbox deleted"); - let na = network_activity(&event); - assert_eq!(na.base.activity_id, ActivityId::Close.as_u8()); + let OcsfEvent::Base(na) = &event else { + panic!("expected Base Event for relay control operation") + }; + openshell_ocsf::validation::validate_required_fields( + &event.to_json().unwrap(), + &openshell_ocsf::validation::load_class_schema("base_event"), + ); + assert_eq!(na.base.activity_name, "Relay close from gateway"); assert_eq!(na.base.severity, SeverityId::Informational); let msg = na.base.message.as_deref().unwrap_or_default(); assert!(msg.contains("sandbox deleted"), "message: {msg}"); diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index 4bc4aad6de..7702944b0a 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -54,6 +54,7 @@ OpenShell maps sandbox events to these OCSF classes: | Shorthand prefix | OCSF class | Class UID | What it covers | |---|---|---|---| +| `EVENT` | Base Event | 0 | Unix socket relay and relay-control notifications without network endpoints | | `NET:` | Network Activity | 4001 | TCP proxy CONNECT tunnels, bypass detection, DNS failures | | `HTTP:` | HTTP Activity | 4002 | HTTP FORWARD requests, L7 enforcement decisions | | `SSH:` | SSH Activity | 4007 | SSH handshakes, authentication, channel operations | @@ -70,6 +71,10 @@ The shorthand format follows this pattern: CLASS:ACTIVITY [SEVERITY] ACTION DETAILS [CONTEXT] ``` +Base Events use `EVENT [SEVERITY] MESSAGE [CONTEXT]`. Unix socket relay and +relay-control notifications moved from `NET:*` to `EVENT` because they do not +have a network endpoint. + ### Components **Class and activity** (`NET:OPEN`, `HTTP:GET`, `PROC:LAUNCH`) identify the OCSF event class and what happened. The class name always starts at the same column position for vertical scanning. diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index 8a5b6430e7..8dd82d2de9 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -160,6 +160,7 @@ The `class_uid` field identifies the event type: | `class_uid` | Class | Shorthand prefix | |---|---|---| +| 0 | Base Event | `EVENT` | | 4001 | Network Activity | `NET:` | | 4002 | HTTP Activity | `HTTP:` | | 4007 | SSH Activity | `SSH:` | @@ -168,6 +169,18 @@ The `class_uid` field identifies the event type: | 5019 | Device Config State Change | `CONFIG:` | | 6002 | Application Lifecycle | `LIFECYCLE:` | +HTTP Activity records include request or response details. Rejections that only +identify a connection, such as an early credential-binding failure, use Network +Activity. Connection-error records include the known peer or listening endpoint. + +Policy configuration warnings use Device Config State Change, and bypass-monitor +startup failures use Application Lifecycle. Unix socket relay and relay-control +notifications without a network endpoint use Base Event (`class_uid: 0`). Include +these classes when filtering supervisor operational events. Relay-control records +that previously appeared as `NET:*` now appear as `EVENT`. +Human-readable logs include the error cause for proxy accept failures and +bypass-monitor startup failures alongside the endpoint or component name. + ## SIEM Schema Version Compatibility OpenShell emits OCSF v1.8.0 events internally, but many SIEMs only support older schema versions. The `ocsf_schema_version` setting tells the JSONL layer to downgrade events before writing, stripping fields and profiles that don't exist in the target version.