diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 74bbfb4aea..0cbb349fef 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -596,6 +596,11 @@ HTTP Activity records contain a request or response; early rejections with only connection context use Network Activity. Producer regression tests validate required fields and `at_least_one` constraints against the vendored OCSF 1.8 schemas. +Network Activity records identify at least one observed endpoint; connection +failures retain their known peer or listening endpoint. +Configuration diagnostics use Config State Change. +Unix socket relay and relay-control notifications, plus proxy and mediation +failures without an observed network endpoint, use Base Event. ## Policy Proposals diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index bb8be28e65..bf041e5540 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -134,20 +134,6 @@ macro_rules! impl_src_endpoint_addr_setter { }; } -/// Generate the `firewall_rule` setter shared by network and HTTP builders. -macro_rules! impl_firewall_rule_setter { - ($builder:ident) => { - impl<'a> $builder<'a> { - /// Set the firewall rule that matched this event. - #[must_use] - pub fn firewall_rule(mut self, name: &str, rule_type: &str) -> Self { - self.firewall_rule = Some($crate::objects::FirewallRule::new(name, rule_type)); - self - } - } - }; -} - mod api_activity; mod base; mod config; diff --git a/crates/openshell-ocsf/src/builders/network.rs b/crates/openshell-ocsf/src/builders/network.rs index 8f405aba45..001100abc4 100644 --- a/crates/openshell-ocsf/src/builders/network.rs +++ b/crates/openshell-ocsf/src/builders/network.rs @@ -10,7 +10,7 @@ use crate::events::{NetworkActivityEvent, OcsfEvent}; use crate::objects::{Actor, ConnectionInfo, Endpoint, FirewallRule}; /// Builder for Network Activity [4001] events. -pub struct NetworkActivityBuilder<'a> { +pub struct NetworkActivityBuilder<'a, EndpointState = MissingNetworkEndpoint> { ctx: &'a EventContext, activity: ActivityId, activity_name: Option, @@ -28,10 +28,35 @@ pub struct NetworkActivityBuilder<'a> { status_detail: Option, unmapped: Option>, log_source: Option, + endpoint_state: std::marker::PhantomData, } -impl<'a> NetworkActivityBuilder<'a> { +/// Marker for a Network Activity builder without an endpoint. +pub struct MissingNetworkEndpoint; + +/// Marker for a Network Activity builder with a source or destination endpoint. +pub struct HasNetworkEndpoint; + +impl<'a> NetworkActivityBuilder<'a, MissingNetworkEndpoint> { /// Start building a Network Activity event. + /// + /// A Network Activity must include a source or destination endpoint before + /// it can be built. + /// + /// ```compile_fail + /// use openshell_ocsf::{EventContext, NetworkActivityBuilder}; + /// + /// let ctx = EventContext { + /// sandbox_id: String::new(), + /// sandbox_name: String::new(), + /// container_image: String::new(), + /// hostname: String::new(), + /// product_version: String::new(), + /// proxy_ip: "127.0.0.1".parse().unwrap(), + /// proxy_port: 3128, + /// }; + /// NetworkActivityBuilder::new(&ctx).build(); + /// ``` #[must_use] pub fn new(ctx: &'a EventContext) -> Self { Self { @@ -52,6 +77,92 @@ impl<'a> NetworkActivityBuilder<'a> { status_detail: None, unmapped: None, log_source: None, + endpoint_state: std::marker::PhantomData, + } + } + + #[must_use] + pub fn src_endpoint_addr( + self, + ip: std::net::IpAddr, + port: u16, + ) -> NetworkActivityBuilder<'a, HasNetworkEndpoint> { + self.with_src_endpoint(Endpoint::from_ip(ip, port)) + } + + #[must_use] + pub fn dst_endpoint( + self, + endpoint: Endpoint, + ) -> NetworkActivityBuilder<'a, HasNetworkEndpoint> { + self.with_dst_endpoint(endpoint) + } +} + +impl NetworkActivityBuilder<'_, HasNetworkEndpoint> { + #[must_use] + pub fn src_endpoint_addr(mut self, ip: std::net::IpAddr, port: u16) -> Self { + self.src_endpoint = Some(Endpoint::from_ip(ip, port)); + self + } + + #[must_use] + pub fn dst_endpoint(mut self, endpoint: Endpoint) -> Self { + self.dst_endpoint = Some(endpoint); + self + } +} + +impl<'a, EndpointState> NetworkActivityBuilder<'a, EndpointState> { + fn with_src_endpoint( + self, + endpoint: Endpoint, + ) -> NetworkActivityBuilder<'a, HasNetworkEndpoint> { + NetworkActivityBuilder { + ctx: self.ctx, + activity: self.activity, + activity_name: self.activity_name, + action: self.action, + disposition: self.disposition, + severity: self.severity, + status: self.status, + src_endpoint: Some(endpoint), + dst_endpoint: self.dst_endpoint, + actor: self.actor, + firewall_rule: self.firewall_rule, + connection_info: self.connection_info, + observation_point_id: self.observation_point_id, + message: self.message, + status_detail: self.status_detail, + unmapped: self.unmapped, + log_source: self.log_source, + endpoint_state: std::marker::PhantomData, + } + } + + fn with_dst_endpoint( + self, + endpoint: Endpoint, + ) -> NetworkActivityBuilder<'a, HasNetworkEndpoint> { + NetworkActivityBuilder { + ctx: self.ctx, + activity: self.activity, + activity_name: self.activity_name, + action: self.action, + disposition: self.disposition, + severity: self.severity, + status: self.status, + src_endpoint: self.src_endpoint, + dst_endpoint: Some(endpoint), + actor: self.actor, + firewall_rule: self.firewall_rule, + connection_info: self.connection_info, + observation_point_id: self.observation_point_id, + message: self.message, + status_detail: self.status_detail, + unmapped: self.unmapped, + log_source: self.log_source, + endpoint_state: std::marker::PhantomData, } } @@ -90,6 +201,56 @@ impl<'a> NetworkActivityBuilder<'a> { self } + #[must_use] + pub fn activity(mut self, id: ActivityId) -> Self { + self.activity = id; + self + } + + #[must_use] + pub fn action(mut self, id: ActionId) -> Self { + self.action = Some(id); + self + } + + #[must_use] + pub fn disposition(mut self, id: DispositionId) -> Self { + self.disposition = Some(id); + self + } + + #[must_use] + pub fn actor_process(mut self, process: crate::objects::Process) -> Self { + self.actor = Some(Actor { process }); + self + } + + #[must_use] + pub fn firewall_rule(mut self, name: &str, rule_type: &str) -> Self { + self.firewall_rule = Some(FirewallRule::new(name, rule_type)); + self + } + + #[must_use] + pub fn severity(mut self, id: SeverityId) -> Self { + self.severity = id; + self + } + + #[must_use] + pub fn status(mut self, id: StatusId) -> Self { + self.status = Some(id); + self + } + + #[must_use] + pub fn message(mut self, msg: impl Into) -> Self { + self.message = Some(msg.into()); + self + } +} + +impl NetworkActivityBuilder<'_, HasNetworkEndpoint> { /// Finalize and return the `OcsfEvent`. #[must_use] pub fn build(self) -> OcsfEvent { @@ -139,14 +300,6 @@ impl<'a> NetworkActivityBuilder<'a> { } } -impl_activity_setter!(NetworkActivityBuilder); -impl_action_disposition_setters!(NetworkActivityBuilder); -impl_actor_process_setter!(NetworkActivityBuilder); -impl_dst_endpoint_setter!(NetworkActivityBuilder); -impl_src_endpoint_addr_setter!(NetworkActivityBuilder); -impl_firewall_rule_setter!(NetworkActivityBuilder); -impl_builder_setters!(NetworkActivityBuilder); - #[cfg(test)] mod tests { use super::*; diff --git a/crates/openshell-ocsf/tests/event_identity.rs b/crates/openshell-ocsf/tests/event_identity.rs index 34a3868ca2..6b5ad32e05 100644 --- a/crates/openshell-ocsf/tests/event_identity.rs +++ b/crates/openshell-ocsf/tests/event_identity.rs @@ -5,7 +5,9 @@ use std::net::{IpAddr, Ipv4Addr}; -use openshell_ocsf::{ActivityId, EventContext, NetworkActivityBuilder, OcsfEvent, SeverityId}; +use openshell_ocsf::{ + ActivityId, Endpoint, EventContext, NetworkActivityBuilder, OcsfEvent, SeverityId, +}; fn sandbox_ctx(container_image: &str) -> EventContext { EventContext { @@ -23,6 +25,7 @@ fn event(ctx: &EventContext) -> OcsfEvent { NetworkActivityBuilder::new(ctx) .activity(ActivityId::Open) .severity(SeverityId::Medium) + .dst_endpoint(Endpoint::from_domain("api.example.com", 443)) .message("CONNECT api.example.com:443") .build() } diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 039310d371..39d4f11cfc 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -308,9 +308,10 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { let tls = match tls_value.as_str() { "skip" => TlsMode::Skip, "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) + .status(openshell_ocsf::StatusId::Success) + .state(openshell_ocsf::StateId::Other, "deprecated") .message( "'tls: terminate' is deprecated; TLS termination is now automatic. \ Use 'tls: skip' to explicitly disable. This field will be removed in a future version.", @@ -320,9 +321,10 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { TlsMode::Auto } "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) + .status(openshell_ocsf::StatusId::Success) + .state(openshell_ocsf::StateId::Other, "deprecated") .message( "'tls: passthrough' is deprecated; TLS termination is now automatic. \ Use 'tls: skip' to explicitly disable. This field will be removed in a future version.", @@ -374,9 +376,10 @@ 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) + .status(openshell_ocsf::StatusId::Failure) + .state(openshell_ocsf::StateId::Disabled, "invalid") .message(format!( "rejecting endpoint: unrecognized credential_signing value {other:?}" )) @@ -391,9 +394,10 @@ 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) + .status(openshell_ocsf::StatusId::Failure) + .state(openshell_ocsf::StateId::Disabled, "invalid") .message("rejecting endpoint: credential_signing requires signing_service".to_string()) .build(); openshell_ocsf::ocsf_emit!(event); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 02206063a2..1eea2a17c1 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -35,8 +35,9 @@ use openshell_isolation_interface::contract::{ NetworkMediationSource, PendingTcpOpen, ResolveError, TcpOpenDecision, TcpOpenDenial, }; use openshell_ocsf::{ - ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, HttpResponse, - NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, + ActionId, ActivityId, BaseEventBuilder, DispositionId, Endpoint, HttpActivityBuilder, + HttpRequest, HttpResponse, NetworkActivityBuilder, Process, SeverityId, StatusId, + Url as OcsfUrl, ocsf_emit, }; #[cfg(target_os = "linux")] use std::mem::size_of; @@ -114,6 +115,54 @@ const TUNNEL_PROTOCOL_PEEK_POLL: std::time::Duration = std::time::Duration::from const FORWARD_ENCODED_SLASH_REJECTION_DETAIL: &str = "request-target contains an encoded '/' (%2F) which is not allowed on this endpoint"; +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 build_proxy_connection_error_event( + peer_addr: Option, + transparent_destination: Option, + message: String, +) -> openshell_ocsf::OcsfEvent { + if let Some(peer_addr) = peer_addr { + return build_connection_error_event(peer_addr, message); + } + if let Some(destination) = transparent_destination { + return NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_ip(destination.ip(), destination.port())) + .message(message) + .build(); + } + + BaseEventBuilder::new(openshell_ocsf::ctx::ctx()) + .activity_name("Proxy connection failure") + .severity(SeverityId::Low) + .status(StatusId::Failure) + .message(message) + .build() +} + +fn build_mediation_lane_failure_event(message: String) -> openshell_ocsf::OcsfEvent { + BaseEventBuilder::new(openshell_ocsf::ctx::ctx()) + .activity_name("Network mediation source failure") + .severity(SeverityId::High) + .status(StatusId::Failure) + .message(message) + .build() +} + fn build_credential_endpoint_mismatch_event( method: &str, host: &str, @@ -426,6 +475,10 @@ impl ProxyHandle { }; match accepted { Ok((stream, supplied_identity, socket_addrs, transparent_destination)) => { + let peer_addr = socket_addrs.map(|(workload_addr, _)| workload_addr); + let transparent_destination_addr = transparent_destination + .as_ref() + .map(|transparent| transparent.destination); consecutive_resource_errors = 0; consecutive_unknown_errors = 0; let opa = opa_engine.clone(); @@ -476,57 +529,30 @@ 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(); - ocsf_emit!(event); + ocsf_emit!(build_proxy_connection_error_event( + peer_addr, + transparent_destination_addr, + format!("Proxy connection error: {err}"), + )); } }); } Err(ProxyAcceptError::Source(err)) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::High) - .status(StatusId::Failure) - .message(format!( - "Network-mediation source failed; proxy accept loop exiting: {err}" - )) - .build(); - ocsf_emit!(event); + ocsf_emit!(build_mediation_lane_failure_event(format!( + "Network-mediation source failed; proxy accept loop exiting: {err}" + ))); break; } Err(ProxyAcceptError::Listener(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; } } @@ -800,7 +826,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); @@ -826,14 +852,10 @@ 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() - ); + ocsf_emit!(build_connection_error_event( + peer_addr, + format!("Transparent TCP connection error: {error}") + )); } }); } @@ -1274,6 +1296,33 @@ 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) + .message(message) + .build() +} + fn classify_accept_error( err: &std::io::Error, consecutive_resource_errors: &mut u32, @@ -1693,16 +1742,29 @@ 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()) - .activity(ActivityId::Other) +fn build_forward_parse_error_ocsf_event( + peer_addr: Option, + method: &str, + path: &str, +) -> openshell_ocsf::OcsfEvent { + let builder = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::for_http_method(method)) + .http_request(HttpRequest { + http_method: method.parse().expect("HTTP method parsing is infallible"), + url: None, + }) .http_response(HttpResponse { code: StatusCode::BAD_REQUEST.as_u16(), }) .severity(SeverityId::Low) .status(StatusId::Failure) - .message(format!("FORWARD parse error for {path}")) - .build() + .message(format!("FORWARD parse error for {path}")); + match peer_addr { + Some(peer_addr) => builder + .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) + .build(), + None => builder.build(), + } } /// Build the rejection event for an absolute-form request whose scheme is not @@ -4181,9 +4243,12 @@ 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) + .status(StatusId::Success) + .state(openshell_ocsf::StateId::Other, "warning") .message(format!( "allowed_ips entry has a very broad CIDR {n} (/{}) < /{MIN_SAFE_PREFIX_LEN}; \ this may expose control-plane services on the same network", @@ -4848,12 +4913,21 @@ async fn handle_forward_proxy( .as_ref() .and_then(EndpointObservationSender::capture); let mut endpoint_observer = None; + let workload_peer_addr = socket_addrs.map(|(workload, _)| workload); + // The connection handlers below require a workload address for policy and + // accounting. OCSF events must instead use `workload_peer_addr`, because + // the unspecified fallback is not an observed network endpoint. + let workload_addr = workload_peer_addr.unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0))); 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_peer_addr, + method, + &telemetry_path + )); respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); }; @@ -4923,10 +4997,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 = socket_addrs.map_or_else( - || SocketAddr::from(([0, 0, 0, 0], 0)), - |(workload, _)| workload, - ); let intent = EgressIntent::forward_http(host_lc.clone(), port); let mut decision = if let Some(identity) = supplied_identity { authorize_supplied_identity(&opa_engine, intent, identity) @@ -7543,6 +7613,87 @@ network_policies: ); } + #[test] + fn accept_errors_include_the_listening_endpoint() { + 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 in [ + AcceptAction::Terminal, + AcceptAction::Retry { + backoff: std::time::Duration::from_millis(250), + severity: SeverityId::Low, + }, + ] { + 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); + assert!(json["message"].as_str().unwrap().contains("accept failed")); + } + } + + #[test] + fn connection_errors_include_the_known_peer() { + 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"); + let json = build_connection_error_event(peer, "Proxy connection error".to_string()) + .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 forward_parse_errors_include_http_context_and_peer() { + use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; + + let peer: SocketAddr = "127.0.0.1:54321".parse().unwrap(); + let json = + build_forward_parse_error_ocsf_event(Some(peer), "GET", "/[INVALID_REQUEST_TARGET]") + .to_json() + .unwrap(); + + assert_eq!(json["class_uid"], 4002); + assert_eq!(json["activity_name"], "Get"); + assert_eq!(json["http_request"]["http_method"], "GET"); + assert!(json["http_request"].get("url").is_none()); + assert_eq!(json["http_response"]["code"], 400); + assert_eq!(json["src_endpoint"]["ip"], "127.0.0.1"); + assert_eq!(json["src_endpoint"]["port"], 54321); + validate_required_fields(&json, &load_class_schema("http_activity")); + } + + #[test] + fn forward_parse_errors_without_a_peer_do_not_fabricate_an_endpoint() { + let json = build_forward_parse_error_ocsf_event(None, "GET", "/[INVALID_REQUEST_TARGET]") + .to_json() + .unwrap(); + + assert_eq!(json["class_uid"], 4002); + assert!(json.get("src_endpoint").is_none()); + assert!(json.get("dst_endpoint").is_none()); + } + + #[test] + fn endpointless_proxy_failures_are_base_events() { + for event in [ + build_proxy_connection_error_event(None, None, "connection failed".to_string()), + build_mediation_lane_failure_event("source failed".to_string()), + ] { + let json = event.to_json().unwrap(); + assert_eq!(json["class_uid"], 0); + assert_ne!(json["activity_name"], "Stop"); + assert_eq!(json["status"], "Failure"); + } + } + #[test] fn middleware_failure_response_uses_platform_text_without_policy_guidance() { let response = build_middleware_failure_response("api-policy"); @@ -7883,9 +8034,13 @@ 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( + Some("127.0.0.1:12345".parse().unwrap()), + "GET", + &forward_telemetry_path( + "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", + ), + ) .to_json() .unwrap(); assert_eq!( diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 533ee2b2eb..1eba8a0742 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -172,9 +172,7 @@ pub struct Networking { /// the workload child (entered via `setns()` in `pre_exec`). /// /// `denial_tx` and `denial_rx` are owned by the caller. The proxy uses the -/// sender; the aggregator owns the receiver. The caller is also responsible -/// for cloning `denial_tx` for the bypass monitor (which lives in -/// `openshell-supervisor-process`). +/// sender; the aggregator owns the receiver. /// /// # Errors /// diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index 1ddfd734e3..8be936e2ac 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -23,8 +23,8 @@ use openshell_core::proto::{ }; use openshell_isolation_interface::contract::{BoundaryLoopbackConnector, LoopbackTarget}; use openshell_ocsf::{ - ActivityId, ConnectionInfo, Endpoint, EventContext, NetworkActivityBuilder, OcsfEvent, - SeverityId, StatusId, ocsf_emit, + ActivityId, BaseEventBuilder, ConnectionInfo, Endpoint, EventContext, NetworkActivityBuilder, + OcsfEvent, SeverityId, StatusId, ocsf_emit, }; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::sync::{mpsc, watch}; @@ -149,17 +149,23 @@ fn relay_open_event( open: &RelayOpen, ssh_socket_path: &std::path::Path, ) -> OcsfEvent { - let mut builder = NetworkActivityBuilder::new(ctx) + let message = relay_target_message(open, "open", ssh_socket_path); + let Some(endpoint) = relay_target_endpoint(open) else { + return BaseEventBuilder::new(ctx) + .activity_name("Relay open") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(message) + .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(message) + .dst_endpoint(endpoint) + .connection_info(ConnectionInfo::new("tcp")) + .build() } fn relay_closed_event( @@ -167,17 +173,23 @@ fn relay_closed_event( open: &RelayOpen, ssh_socket_path: &std::path::Path, ) -> OcsfEvent { - let mut builder = NetworkActivityBuilder::new(ctx) + let message = relay_target_message(open, "closed", ssh_socket_path); + let Some(endpoint) = relay_target_endpoint(open) else { + return BaseEventBuilder::new(ctx) + .activity_name("Relay closed") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(message) + .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(message) + .dst_endpoint(endpoint) + .connection_info(ConnectionInfo::new("tcp")) + .build() } fn relay_failed_event( @@ -186,25 +198,31 @@ fn relay_failed_event( ssh_socket_path: &std::path::Path, error: &str, ) -> OcsfEvent { - let mut builder = NetworkActivityBuilder::new(ctx) + let message = format!( + "{}: {error}", + relay_target_message(open, "bridge failed", ssh_socket_path) + ); + let Some(endpoint) = relay_target_endpoint(open) else { + return BaseEventBuilder::new(ctx) + .activity_name("Relay failed") + .severity(SeverityId::Low) + .status(StatusId::Failure) + .message(message) + .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() + .message(message) + .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) + BaseEventBuilder::new(ctx) + .activity_name("Relay close from gateway") .severity(SeverityId::Informational) .message(format!( "relay close from gateway (channel_id={channel_id}, reason={reason})" @@ -950,6 +968,13 @@ mod ocsf_event_tests { } } + fn base_event(event: &OcsfEvent) -> &openshell_ocsf::BaseEvent { + match event { + OcsfEvent::Base(event) => event, + other => panic!("expected Base Event, got {other:?}"), + } + } + fn ssh_relay_open(channel_id: &str) -> RelayOpen { RelayOpen { channel_id: channel_id.to_string(), @@ -1013,12 +1038,13 @@ mod ocsf_event_tests { } #[test] - fn relay_open_emits_network_open_success() { + fn relay_open_emits_base_event() { 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()); - assert_eq!(na.base.severity, SeverityId::Informational); - let msg = na.base.message.as_deref().unwrap_or_default(); + let event = base_event(&event); + assert_eq!(event.base.activity_name, "Relay open"); + assert_eq!(event.base.severity, SeverityId::Informational); + assert_eq!(event.base.status, Some(StatusId::Success)); + let msg = event.base.message.as_deref().unwrap_or_default(); assert!(msg.contains("ch-42"), "message: {msg}"); assert!( msg.contains("target=unix:/run/openshell/ssh.sock"), @@ -1049,37 +1075,37 @@ mod ocsf_event_tests { } #[test] - fn relay_closed_emits_network_close_success() { + fn relay_closed_emits_base_event() { 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()); - assert_eq!(na.base.status, Some(StatusId::Success)); + let event = base_event(&event); + assert_eq!(event.base.activity_name, "Relay closed"); + assert_eq!(event.base.status, Some(StatusId::Success)); } #[test] - fn relay_failed_emits_network_fail_low() { + fn relay_failed_emits_base_event() { 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()); - 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(); + let event = base_event(&event); + assert_eq!(event.base.activity_name, "Relay failed"); + assert_eq!(event.base.severity, SeverityId::Low); + assert_eq!(event.base.status, Some(StatusId::Failure)); + let msg = event.base.message.as_deref().unwrap_or_default(); assert!(msg.contains("ch-42"), "message: {msg}"); assert!(msg.contains("write to ssh failed"), "message: {msg}"); } #[test] - fn relay_close_from_gateway_is_network_close_informational() { + fn relay_close_from_gateway_is_base_event() { 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()); - assert_eq!(na.base.severity, SeverityId::Informational); - let msg = na.base.message.as_deref().unwrap_or_default(); + let event = base_event(&event); + assert_eq!(event.base.activity_name, "Relay close from gateway"); + assert_eq!(event.base.severity, SeverityId::Informational); + let msg = event.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..13ddfeda6d 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 | Endpointless relay, proxy, and mediation failures | | `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, plus proxy and mediation failures with no observed +endpoint, use `EVENT` rather than a network or lifecycle event. + ### 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 29771f5678..81136e2e31 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:` | @@ -172,6 +173,15 @@ HTTP Activity records include request or response details. Rejections that have parsed a request, including early credential-binding failures, include safe method-only request context and the generated response. +Connection-error records include the known peer or listening endpoint. + +Policy configuration warnings use Device Config State Change. + +Unix socket relay and relay-control notifications, and proxy or mediation +failures without an observed 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`. + ## 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.