From ed94456aa037d6087866fa52ec729d305e7c26d9 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 17 Sep 2026 14:54:12 -0700 Subject: [PATCH 1/3] fix(ocsf): attribute MXC proxy events to sandboxes NVBug 6783086 Signed-off-by: Prekshi Vyas --- architecture/security-policy.md | 7 + .../openshell-supervisor-network/src/host.rs | 77 +++++- .../openshell-supervisor-network/src/proxy.rs | 236 ++++++++++++++---- .../src/proxy/tests/compatibility.rs | 6 + 4 files changed, 270 insertions(+), 56 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 655886d87c..7503532ed8 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -418,6 +418,13 @@ middleware, token grants, credential rewriting, policy-generation checks, and the HTTP relay have succeeded so a later denial cannot coexist with an allowed record for the same request. +Each Windows MXC sandbox owns a separate host proxy. That proxy carries an +immutable per-sandbox OCSF context so proxy lifecycle events and top-level +CONNECT/forward decisions use the correct `container.uid` and `container.name` +even when one gateway serves multiple sandboxes concurrently. The process-wide +sandbox context is only suitable for the one-supervisor-per-sandbox runtime +model. + Never log secrets, credentials, bearer tokens, or query parameters in OCSF messages. OCSF JSONL output may be shipped to external systems. The gateway-local OCSF JSONL file sink is restricted to the Windows/MXC path diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index 9d6f5cfffa..ea4082b9fa 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -22,7 +22,7 @@ use openshell_core::proposals::AgentProposals; use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_ocsf::{ - ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ctx::ctx as ocsf_ctx, ocsf_emit, + ConfigStateChangeBuilder, EventContext, SeverityId, StateId, StatusId, ocsf_emit, }; use tokio::sync::mpsc::UnboundedSender; @@ -68,7 +68,11 @@ pub struct HostProxyConfig { /// Per-sandbox client authentication. Host-side MXC proxies must set this /// so another sandbox cannot borrow this proxy's identity and policy. pub client_auth: HostProxyClientAuth, + /// Stable sandbox identifier used to attribute host-proxy OCSF events. + /// Required and non-empty for every host-side proxy. pub sandbox_id: Option, + /// Sandbox display name used to attribute host-proxy OCSF events. + /// Required and non-empty for every host-side proxy. pub sandbox_name: Option, pub openshell_endpoint: Option, pub provider_credentials: Option, @@ -99,6 +103,36 @@ impl HostProxyHandle { } } +fn host_proxy_event_context(config: &HostProxyConfig) -> Result { + let sandbox_id = config + .sandbox_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| miette::miette!("host proxy requires a non-empty sandbox_id"))?; + let sandbox_name = config + .sandbox_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| miette::miette!("host proxy requires a non-empty sandbox_name"))?; + + Ok(EventContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: String::new(), + hostname: std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .ok() + .map(|hostname| hostname.trim().to_string()) + .filter(|hostname| !hostname.is_empty()) + .unwrap_or_else(|| "openshell-gateway".to_string()), + product_version: env!("CARGO_PKG_VERSION").to_string(), + proxy_ip: config.bind_addr.ip(), + proxy_port: config.bind_addr.port(), + }) +} + /// Start a host-side proxy for one sandbox. /// /// Linux supervisor mode should continue to use `run::run_networking`; this API @@ -117,6 +151,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result Result Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -174,7 +209,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -189,7 +224,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -203,7 +238,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -218,7 +253,8 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result Result String { let mut request = String::from( "GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n", diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 22bb8191c3..64a6fb4cf7 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -31,8 +31,8 @@ use openshell_core::policy::ProxyPolicy; use openshell_core::provider_credentials::{ProviderCredentialSnapshot, ProviderCredentialState}; use openshell_core::secrets::{self, SecretResolver, rewrite_header_line_checked}; use openshell_ocsf::{ - ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, HttpResponse, - NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, + ActionId, ActivityId, DispositionId, Endpoint, EventContext, HttpActivityBuilder, HttpRequest, + HttpResponse, NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; #[cfg(target_os = "linux")] use std::mem::size_of; @@ -175,6 +175,9 @@ pub(crate) enum ProxyIdentityMode { binary_path: PathBuf, binary_sha256: String, required_proxy_authorization: Option>, + /// Per-sandbox context for host-side proxies. The process-wide OCSF + /// context cannot identify one sandbox when a gateway hosts many. + event_context: Option>, }, } @@ -206,9 +209,33 @@ impl ProxyIdentityMode { binary_path, binary_sha256, required_proxy_authorization, + event_context: None, }) } + #[cfg(target_os = "windows")] + pub(super) fn with_event_context(mut self, context: EventContext) -> Self { + match &mut self { + #[cfg(target_os = "linux")] + Self::Procfs { .. } => {} + Self::Static { event_context, .. } => { + *event_context = Some(Arc::new(context)); + } + } + self + } + + fn event_context(&self) -> &EventContext { + match self { + #[cfg(any(not(target_os = "linux"), test))] + Self::Static { + event_context: Some(context), + .. + } => context, + _ => openshell_ocsf::ctx::ctx(), + } + } + fn required_proxy_authorization(&self) -> Option<&str> { match self { #[cfg(target_os = "linux")] @@ -269,8 +296,9 @@ impl ProxyHandle { let listener = TcpListener::bind(http_addr).await.into_diagnostic()?; let local_addr = listener.local_addr().into_diagnostic()?; + let event_context = Arc::new(identity_mode.event_context().clone()); { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Listen) .severity(SeverityId::Informational) .status(StatusId::Success) @@ -307,22 +335,21 @@ impl ProxyHandle { // access. let upstream_proxy: Arc> = Arc::new( UpstreamProxyConfig::from_args(upstream_proxy_args).map_err(|err| { - let event = - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(openshell_ocsf::StateId::Disabled, "invalid") - .message(format!( - "Upstream corporate proxy configuration invalid; \ + let event = openshell_ocsf::ConfigStateChangeBuilder::new(&event_context) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(openshell_ocsf::StateId::Disabled, "invalid") + .message(format!( + "Upstream corporate proxy configuration invalid; \ refusing to start: {err}" - )) - .build(); + )) + .build(); ocsf_emit!(event); miette::miette!("invalid upstream corporate proxy configuration: {err}") })?, ); if let Some(cfg) = upstream_proxy.as_ref() { - let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::Informational) .status(StatusId::Success) .state(openshell_ocsf::StateId::Enabled, "enabled") @@ -392,6 +419,7 @@ impl ProxyHandle { let dtx = denial_tx.clone(); let atx = activity_tx.clone(); let endpoint_observations = endpoint_observation_tx.clone(); + let event_context = event_context.clone(); tokio::spawn(async move { #[allow(clippy::large_futures)] if let Err(err) = handle_tcp_connection( @@ -412,7 +440,7 @@ impl ProxyHandle { ) .await { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -429,7 +457,7 @@ impl ProxyHandle { &mut consecutive_unknown_errors, ) { AcceptAction::Terminal => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::High) .status(StatusId::Failure) @@ -441,7 +469,7 @@ impl ProxyHandle { break; } AcceptAction::Retry { backoff, severity } => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(severity) .status(StatusId::Failure) @@ -1366,6 +1394,7 @@ fn emit_denial_simple( #[allow(clippy::too_many_arguments)] fn build_connect_allow_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, host: &str, port: u16, @@ -1381,7 +1410,7 @@ fn build_connect_allow_ocsf_event( } else { "CONNECT" }; - NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + NetworkActivityBuilder::new(event_context) .activity(ActivityId::Open) .action(ActionId::Allowed) .disposition(DispositionId::Allowed) @@ -1397,6 +1426,7 @@ fn build_connect_allow_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_allow_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, method: &str, host: &str, @@ -1408,7 +1438,7 @@ fn build_forward_allow_ocsf_event( cmdline: &str, policy: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .action(ActionId::Allowed) .disposition(DispositionId::Allowed) @@ -1426,8 +1456,11 @@ 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( + event_context: &EventContext, + path: &str, +) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(event_context) .activity(ActivityId::Other) .http_response(HttpResponse { code: StatusCode::BAD_REQUEST.as_u16(), @@ -1443,12 +1476,13 @@ fn build_forward_parse_error_ocsf_event(path: &str) -> openshell_ocsf::OcsfEvent /// contain credentials; the method and generated response provide the HTTP /// context required by OCSF 1.8. fn build_forward_unsupported_scheme_ocsf_event( + event_context: &EventContext, method: &str, scheme: &str, host: &str, port: u16, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .http_request(HttpRequest { http_method: method.parse().expect("HTTP method parsing is infallible"), @@ -1470,6 +1504,7 @@ fn build_forward_unsupported_scheme_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_l7_parse_rejection_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, method: &str, host: &str, @@ -1482,7 +1517,7 @@ fn build_forward_l7_parse_rejection_ocsf_event( policy: &str, detail: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1505,6 +1540,7 @@ fn build_forward_l7_parse_rejection_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_policy_deny_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, method: &str, host: &str, @@ -1516,7 +1552,7 @@ fn build_forward_policy_deny_ocsf_event( cmdline: &str, reason: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::Other) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1556,6 +1592,7 @@ fn endpoint_result_for_destination_failure(kind: DestinationDenialKind) -> Endpo #[allow(clippy::too_many_arguments)] fn build_connect_destination_deny_ocsf_event( + event_context: &EventContext, denial: &DestinationDenial, peer_addr: SocketAddr, host: &str, @@ -1572,7 +1609,7 @@ fn build_connect_destination_deny_ocsf_event( format!("CONNECT blocked: {detail} for {host}:{port}") }; - NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + NetworkActivityBuilder::new(event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1589,6 +1626,7 @@ fn build_connect_destination_deny_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_destination_deny_ocsf_event( + event_context: &EventContext, denial: &DestinationDenial, peer_addr: SocketAddr, method: &str, @@ -1608,7 +1646,7 @@ fn build_forward_destination_deny_ocsf_event( detail }; - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1629,6 +1667,7 @@ fn build_forward_destination_deny_ocsf_event( #[allow(clippy::too_many_arguments)] async fn deny_connect_destination( + event_context: &EventContext, client: &mut TcpStream, denial: &DestinationDenial, peer_addr: SocketAddr, @@ -1644,7 +1683,15 @@ async fn deny_connect_destination( ) -> Result<()> { let detail = destination_denial_detail(denial.kind); ocsf_emit!(build_connect_destination_deny_ocsf_event( - denial, peer_addr, host, port, binary, pid, ancestors, cmdline, + event_context, + denial, + peer_addr, + host, + port, + binary, + pid, + ancestors, + cmdline, )); emit_denial( @@ -1675,6 +1722,7 @@ async fn deny_connect_destination( #[allow(clippy::too_many_arguments)] async fn deny_forward_destination( + event_context: &EventContext, client: &mut TcpStream, denial: &DestinationDenial, peer_addr: SocketAddr, @@ -1693,7 +1741,18 @@ async fn deny_forward_destination( ) -> Result<()> { let detail = destination_denial_detail(denial.kind); ocsf_emit!(build_forward_destination_deny_ocsf_event( - denial, peer_addr, method, host, port, path, binary, pid, ancestors, cmdline, policy, + event_context, + denial, + peer_addr, + method, + host, + port, + path, + binary, + pid, + ancestors, + cmdline, + policy, )); emit_denial_simple( @@ -1782,6 +1841,7 @@ async fn handle_tcp_connection( activity_tx: Option, endpoint_observation_tx: Option, ) -> Result<()> { + let event_context = identity_mode.event_context().clone(); // Capture authority before request parsing or policy selection can yield. // A later inventory installation cannot acquire this connection's result. let endpoint_observation_context = endpoint_observation_tx @@ -1935,7 +1995,7 @@ async fn handle_tcp_connection( // Allowed connections are logged after the L7 config check (below) // so we can distinguish CONNECT (L4-only) from CONNECT_L7 (L7 follows). if matches!(decision.action, NetworkAction::Deny { .. }) { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2021,6 +2081,7 @@ async fn handle_tcp_connection( observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_connect_destination( + &event_context, &mut client, &denial, workload_addr, @@ -2062,6 +2123,7 @@ async fn handle_tcp_connection( observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_connect_destination( + &event_context, &mut client, &denial, workload_addr, @@ -2093,7 +2155,7 @@ async fn handle_tcp_connection( if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::TlsFailed); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2131,7 +2193,7 @@ async fn handle_tcp_connection( if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::PolicyDenied); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2231,6 +2293,7 @@ async fn handle_tcp_connection( // Log the allowed CONNECT — use CONNECT_L7 when L7 inspection follows, // so log consumers can distinguish L4-only decisions from tunnel lifecycle events. ocsf_emit!(build_connect_allow_ocsf_event( + &event_context, workload_addr, &host_lc, port, @@ -2339,7 +2402,7 @@ async fn handle_tcp_connection( if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::TlsFailed); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -2368,7 +2431,7 @@ async fn handle_tcp_connection( "TLS connection closed" ); } else { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -2391,7 +2454,7 @@ async fn handle_tcp_connection( // placeholder verbatim). const DETAIL: &str = "TLS termination unavailable after tunnel establishment; \ closing connection - credential rewrite would be bypassed"; - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2443,7 +2506,7 @@ async fn handle_tcp_connection( } else { format!("HTTP relay error: {e}") }; - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -2462,7 +2525,7 @@ async fn handle_tcp_connection( if requirement == InspectionRequirement::RequiredMiddleware { crate::l7::middleware::emit_middleware_uninspectable(&ctx, protocol_detail, true); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -4491,6 +4554,7 @@ async fn handle_forward_proxy( activity_tx: Option<&ActivitySender>, endpoint_observation_tx: Option, ) -> Result<()> { + let event_context = identity_mode.event_context().clone(); // Capture authority before asynchronous authorization or credential selection. // Policy and provider snapshots must belong to this same installation. let endpoint_observation_context = endpoint_observation_tx @@ -4501,7 +4565,10 @@ async fn handle_forward_proxy( // 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( + &event_context, + &telemetry_path + )); respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); }; @@ -4543,7 +4610,13 @@ async fn handle_forward_proxy( } if scheme != "http" { - let event = build_forward_unsupported_scheme_ocsf_event(method, &scheme, &host_lc, port); + let event = build_forward_unsupported_scheme_ocsf_event( + &event_context, + method, + &scheme, + &host_lc, + port, + ); ocsf_emit!(event); if scheme == "https" { respond( @@ -4624,6 +4697,7 @@ async fn handle_forward_proxy( NetworkAction::Allow { matched_policy } => matched_policy.clone(), NetworkAction::Deny { reason } => { ocsf_emit!(build_forward_policy_deny_ocsf_event( + &event_context, workload_addr, method, &host_lc, @@ -4735,7 +4809,7 @@ async fn handle_forward_proxy( let prepared_target = match prepare_forward_target(&path, canonicalize_options) { Ok(prepared) => prepared, Err(error) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) @@ -4906,6 +4980,7 @@ async fn handle_forward_proxy( observer.observe(EndpointResult::PolicyDenied); } ocsf_emit!(build_forward_l7_parse_rejection_ocsf_event( + &event_context, workload_addr, method, &host_lc, @@ -4935,7 +5010,7 @@ async fn handle_forward_proxy( if let Some(observer) = endpoint_observer.as_ref() { observer.observe(EndpointResult::PolicyDenied); } - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = HttpActivityBuilder::new(&event_context) .activity(ActivityId::Other) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -5015,7 +5090,7 @@ async fn handle_forward_proxy( { Ok(info) => info, Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) @@ -5069,7 +5144,7 @@ async fn handle_forward_proxy( { Ok(body) => body, Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) @@ -5132,7 +5207,7 @@ async fn handle_forward_proxy( || { crate::l7::relay::evaluate_l7_request(&tunnel_engine, &l7_ctx, &request_info) .unwrap_or_else(|e| { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -5197,7 +5272,7 @@ async fn handle_forward_proxy( ) }, ); - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = HttpActivityBuilder::new(&event_context) .activity(ActivityId::Other) .action(action_id) .disposition(disposition_id) @@ -5271,6 +5346,7 @@ async fn handle_forward_proxy( observer.observe(EndpointResult::PolicyDenied); } deny_forward_destination( + &event_context, client, &denial, workload_addr, @@ -5311,6 +5387,7 @@ async fn handle_forward_proxy( observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_forward_destination( + &event_context, client, &denial, workload_addr, @@ -5702,7 +5779,7 @@ async fn handle_forward_proxy( if let Some(observer) = endpoint_observer.as_ref() { observer.observe(EndpointResult::TransportFailed); } - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = HttpActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -5860,6 +5937,7 @@ async fn handle_forward_proxy( // rewriting, generation checks, and the HTTP relay. Only now record the // final allowed outcome. ocsf_emit!(build_forward_allow_ocsf_event( + &event_context, workload_addr, method, &host_lc, @@ -6154,6 +6232,18 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; + fn proxy_event_context(sandbox_id: &str, sandbox_name: &str) -> EventContext { + EventContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: String::new(), + hostname: "windows-gateway".to_string(), + product_version: "test".to_string(), + proxy_ip: Ipv4Addr::LOCALHOST.into(), + proxy_port: 3128, + } + } + #[test] fn endpoint_result_distinguishes_resolution_from_policy() { assert_eq!( @@ -7169,6 +7259,7 @@ network_policies: fn forward_policy_denial_ocsf_includes_validation_rationale() { let reason = "policy validation failed; fail-closed quarantine is active; candidate version 7 rejected: conflicting tls metadata"; let event = build_forward_policy_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), "127.0.0.1:45123".parse().unwrap(), "GET", "api.example.com", @@ -7187,9 +7278,45 @@ network_policies: assert_eq!(json["disposition"], "Blocked"); } + #[test] + fn forward_policy_denial_ocsf_keeps_per_proxy_sandbox_attribution() { + use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; + + let peer = "127.0.0.1:45123".parse().unwrap(); + let build_event = |context: &EventContext| { + build_forward_policy_deny_ocsf_event( + context, + peer, + "GET", + "api.example.com", + 80, + "/v1/models", + r"C:\agent.exe", + "-", + "-", + r"C:\agent.exe", + "endpoint is not allowed by any policy", + ) + .to_json() + .unwrap() + }; + + let sandbox_a = build_event(&proxy_event_context("sandbox-a-id", "sandbox-a")); + let sandbox_b = build_event(&proxy_event_context("sandbox-b-id", "sandbox-b")); + + assert_eq!(sandbox_a["container"]["uid"], "sandbox-a-id"); + assert_eq!(sandbox_a["container"]["name"], "sandbox-a"); + assert_eq!(sandbox_b["container"]["uid"], "sandbox-b-id"); + assert_eq!(sandbox_b["container"]["name"], "sandbox-b"); + let schema = load_class_schema("http_activity"); + validate_required_fields(&sandbox_a, &schema); + validate_required_fields(&sandbox_b, &schema); + } + #[test] fn forward_l7_parse_rejection_ocsf_includes_denial_context() { let event = build_forward_l7_parse_rejection_ocsf_event( + openshell_ocsf::ctx::ctx(), "127.0.0.1:45123".parse().unwrap(), "GET", "api.example.com", @@ -7364,6 +7491,7 @@ network_policies: assert_eq!(path, "/v1/[CREDENTIAL]"); let allowed = build_forward_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", "api.example.com", @@ -7378,6 +7506,7 @@ network_policies: .to_json() .unwrap(); let denied = build_forward_policy_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", "api.example.com", @@ -7404,6 +7533,7 @@ network_policies: assert_eq!(host, "api.example.com"); assert_eq!(path, "/?token=real-secret"); let no_path_query = build_forward_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", &host, @@ -7423,9 +7553,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( + openshell_ocsf::ctx::ctx(), + &forward_telemetry_path( + "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", + ), + ) .to_json() .unwrap(); assert_eq!( @@ -10387,8 +10520,13 @@ network_policies: fn unsupported_forward_scheme_event_omits_request_url() { use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; - let event = - build_forward_unsupported_scheme_ocsf_event("GET", "https", "api.example.com", 443); + let event = build_forward_unsupported_scheme_ocsf_event( + openshell_ocsf::ctx::ctx(), + "GET", + "https", + "api.example.com", + 443, + ); let json = event.to_json().unwrap(); assert_eq!(json["http_request"]["http_method"], "GET"); diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 5948cd5224..759f888b20 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -92,6 +92,7 @@ async fn destination_denials_preserve_adapter_specific_wire_contracts() { let (mut app, mut proxy) = tcp_pair().await; deny_connect_destination( + openshell_ocsf::ctx::ctx(), &mut proxy, &denial, peer, @@ -119,6 +120,7 @@ async fn destination_denials_preserve_adapter_specific_wire_contracts() { let (mut app, mut proxy) = tcp_pair().await; deny_forward_destination( + openshell_ocsf::ctx::ctx(), &mut proxy, &denial, peer, @@ -165,6 +167,7 @@ fn representative_adapter_denials_preserve_ocsf_fields() { // global tracing pipeline. Its callsite-interest cache is process-global, // so parallel tests can otherwise make captured-event assertions flaky. let connect = serde_json::to_value(build_connect_destination_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), &denial, peer, "target.example", @@ -194,6 +197,7 @@ fn representative_adapter_denials_preserve_ocsf_fields() { assert_eq!(connect["status_detail"], denial_reason); let forward = serde_json::to_value(build_forward_destination_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), &denial, peer, "POST", @@ -229,6 +233,7 @@ fn representative_adapter_denials_preserve_ocsf_fields() { fn representative_adapter_allows_preserve_ocsf_fields() { let peer: SocketAddr = "127.0.0.1:41000".parse().unwrap(); let connect = serde_json::to_value(build_connect_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "target.example", 8443, @@ -254,6 +259,7 @@ fn representative_adapter_allows_preserve_ocsf_fields() { assert_eq!(connect["message"], "CONNECT_L7 allowed target.example:8443"); let forward = serde_json::to_value(build_forward_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", "target.example", From 3a902900ed7d0d995b3e58ace3f5e0fe9bd32e80 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sat, 19 Sep 2026 20:44:16 -0700 Subject: [PATCH 2/3] fix(network): scope Windows egress by socket owner Resolve each accepted Windows proxy connection to its unique owning PID and executable before evaluating binary-scoped network policy. Fail closed when ownership or process identity cannot be established, and cover allowed and undeclared child processes with a real MXC regression. --- Cargo.lock | 1 + Cargo.toml | 4 +- crates/openshell-driver-mxc/src/driver.rs | 9 - .../tests/wxc_exec_real.rs | 165 ++++++++++ .../openshell-supervisor-network/Cargo.toml | 3 + .../openshell-supervisor-network/src/host.rs | 48 +-- .../openshell-supervisor-network/src/lib.rs | 2 + .../openshell-supervisor-network/src/proxy.rs | 127 +++++++- .../openshell-supervisor-network/src/run.rs | 4 +- .../src/windows_process.rs | 286 ++++++++++++++++++ 10 files changed, 590 insertions(+), 59 deletions(-) create mode 100644 crates/openshell-supervisor-network/src/windows_process.rs diff --git a/Cargo.lock b/Cargo.lock index 431b79335a..9408792da7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4684,6 +4684,7 @@ dependencies = [ "tracing-subscriber", "uuid", "webpki-roots", + "windows", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e916b4c52a..b8e6f599d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,8 +56,8 @@ terminal-colorsaurus = "1.0" miette = { version = "7", features = ["fancy"] } thiserror = "2" -# Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only) -windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } +# Windows platform APIs (MXC audit and host-proxy process identity; Windows-only) +windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Threading", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 513a92d858..785d1c1ce8 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -508,14 +508,6 @@ fn allocate_sandbox_proxy_addr( const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; -fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { - config - .command - .first() - .filter(|command| !command.trim().is_empty()) - .map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from) -} - const TLS_ENV_KEYS: [&str; 6] = [ "NODE_EXTRA_CA_CERTS", "DENO_CERT", @@ -1461,7 +1453,6 @@ async fn run_lifecycle( openshell_supervisor_network::host::HostProxyConfig { bind_addr: addr, policy: proxy_policy, - binary_path: host_proxy_binary_path(&sandbox_config), client_auth: proxy_auth.host_client_auth(), sandbox_id: Some(sandbox_id.clone()), sandbox_name: Some(sandbox_name.clone()), diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index abd048c73c..e011d2627a 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -961,6 +961,171 @@ async fn pc_https_egress_reads_injected_ca_bundle() { ); } +/// Prove that host-proxy binary policy follows the process that owns each TCP +/// connection, rather than the sandbox entry command. This deliberately uses +/// L4 CONNECT policy so the assertion is independent of TLS/L7 enforcement. +#[tokio::test] +#[ignore = "requires real wxc-exec and outbound HTTPS"] +async fn pc_proxy_scopes_network_policy_to_socket_owner() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + // QueryFullProcessImageNameW returns this Win32 spelling on the Windows + // test image. Keep the spelling exact here; path and case normalization + // are covered separately. + let cmd = PathBuf::from(r"C:\Windows\System32\cmd.exe"); + let curl = PathBuf::from(r"C:\Windows\System32\curl.exe"); + if !cmd.exists() || !curl.exists() { + eprintln!( + "SKIP: expected Windows binaries are absent (cmd={}, curl={})", + cmd.display(), + curl.display() + ); + return; + } + + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + run_proxy_binary_scope_case(&wxc, "pc-owner-allow-child", &cmd, &curl, &curl, true).await; + run_proxy_binary_scope_case(&wxc, "pc-owner-deny-child", &cmd, &curl, &cmd, false).await; +} + +async fn run_proxy_binary_scope_case( + wxc: &Path, + sandbox_id: &str, + cmd: &Path, + curl: &Path, + allowed_binary: &Path, + expect_allowed: bool, +) { + let output_dir = tempfile::tempdir().expect("proxy scope output directory"); + let output_path = output_dir.path().join("example.html"); + let diagnostic_path = output_dir.path().join("curl-diagnostic.txt"); + let output_dir_string = output_dir.path().to_string_lossy().into_owned(); + let command = vec![ + cmd.to_string_lossy().into_owned(), + "/d".to_string(), + "/c".to_string(), + format!( + "echo proxy-scope 1>\"{}\" && \"{}\" --fail --silent --show-error --ssl-no-revoke --cacert \"%CURL_CA_BUNDLE%\" https://example.com/ --output \"{}\" 2>>\"{}\"", + diagnostic_path.display(), + curl.display(), + output_path.display(), + diagnostic_path.display() + ), + ]; + let serde_json::Value::Object(driver_config) = serde_json::json!({ + "command": command, + "cwd": output_dir_string, + }) else { + unreachable!(); + }; + let policy = SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: vec![output_dir_string], + }), + network_policies: std::collections::HashMap::from([( + "https_example".to_string(), + NetworkPolicyRule { + name: "https-example".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + ports: vec![443], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: allowed_binary.to_string_lossy().into_owned(), + }], + }, + )]), + ..Default::default() + }; + let sandbox = DriverSandbox { + id: sandbox_id.to_string(), + name: sandbox_id.to_string(), + spec: Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some( + openshell_core::proto_struct::json_object_to_struct(driver_config) + .expect("driver config"), + ), + ..Default::default() + }), + policy: Some(policy), + ..Default::default() + }), + ..Default::default() + }; + let backend = MxcComputeBackend::new(MxcComputeConfig { + wxc_exec_path: wxc.to_string_lossy().into_owned(), + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".to_string(), + ..Default::default() + }); + backend + .create_sandbox(&sandbox) + .await + .expect("real proxy-scope sandbox create accepted"); + + let mut terminal_condition = None; + for _ in 0..600 { + if let Some(observed) = backend.get_sandbox(sandbox_id).await + && let Some(condition) = observed + .status + .and_then(|status| status.conditions.into_iter().find(|c| c.r#type == "Ready")) + && matches!( + condition.reason.as_str(), + "AgentCompleted" | "ExecFailed" | "ProvisionFailed" + ) + { + terminal_condition = Some(condition); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let condition = terminal_condition.expect("proxy-scope sandbox should terminate"); + let diagnostic = std::fs::read_to_string(&diagnostic_path) + .unwrap_or_else(|error| format!("failed to read curl diagnostic: {error}")); + backend + .delete_sandbox(sandbox_id, sandbox_id) + .await + .expect("delete completed proxy-scope sandbox"); + + if expect_allowed { + assert_eq!( + condition.reason, "AgentCompleted", + "declared child binary must be allowed: {}; diagnostic: {diagnostic}", + condition.message + ); + assert!( + std::fs::metadata(&output_path).is_ok_and(|metadata| metadata.len() > 0), + "allowed curl response should be non-empty; diagnostic: {diagnostic}" + ); + } else { + assert_eq!( + condition.reason, "ExecFailed", + "entry-command grant must not be inherited by curl: {}; diagnostic: {diagnostic}", + condition.message + ); + assert!( + diagnostic.contains("403"), + "undeclared curl child should receive proxy 403; diagnostic: {diagnostic}" + ); + assert!( + !output_path.exists(), + "denied curl child must not write an HTTPS response" + ); + } +} + /// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. /// This is the genuine OS default-deny proof — the `AppContainer` blocks the write /// without requiring any host ACL lockdown. The mock can only fake this. diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index e4a2f12917..c45a20c563 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -69,6 +69,9 @@ tokio-stream = { workspace = true, features = ["net"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "windows")'.dependencies] +windows = { workspace = true } + [target.'cfg(unix)'.dev-dependencies] [lints] diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index eb09634772..56820779aa 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -61,10 +61,6 @@ pub struct HostProxyConfig { pub bind_addr: SocketAddr, /// Network-only policy produced by the compute driver's policy split. pub policy: ProtoSandboxPolicy, - /// Static process identity used when the platform cannot recover the - /// socket-owning sandbox process. Policy binaries must match this path for - /// L4/L7 allow rules to pass. - pub binary_path: PathBuf, /// Per-sandbox client authentication. Host-side MXC proxies must set this /// so another sandbox cannot borrow this proxy's identity and policy. pub client_auth: HostProxyClientAuth, @@ -215,10 +211,9 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result HostProxyConfig { + fn test_config(bind_addr: SocketAddr) -> HostProxyConfig { HostProxyConfig { bind_addr, policy: ProtoSandboxPolicy { version: 1, ..Default::default() }, - binary_path, client_auth: HostProxyClientAuth::basic("openshell", "test-secret"), sandbox_id: Some("sandbox-123".to_string()), sandbox_name: Some("agent-box".to_string()), @@ -307,7 +301,9 @@ mod tests { let mut client = TcpStream::connect(addr).await.unwrap(); client.write_all(request.as_bytes()).await.unwrap(); let mut response = Vec::new(); - tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + // The first authenticated CONNECT performs a full executable hash for + // TOFU identity binding; debug test binaries can be hundreds of MB. + tokio::time::timeout(Duration::from_secs(10), client.read_to_end(&mut response)) .await .unwrap() .unwrap(); @@ -316,11 +312,7 @@ mod tests { #[tokio::test] async fn rejects_non_loopback_bind_addr() { - let result = start_host_proxy(test_config( - ([192, 0, 2, 1], 0).into(), - PathBuf::from("missing-agent.exe"), - )) - .await; + let result = start_host_proxy(test_config(([192, 0, 2, 1], 0).into())).await; let Err(err) = result else { panic!("host proxy should reject non-loopback bind addresses"); @@ -333,10 +325,7 @@ mod tests { #[tokio::test] async fn rejects_middleware_policy_without_registry() { - let mut config = test_config( - ([127, 0, 0, 1], 0).into(), - PathBuf::from("missing-agent.exe"), - ); + let mut config = test_config(([127, 0, 0, 1], 0).into()); config.policy.network_middlewares.insert( "redactor".into(), NetworkMiddlewareConfig { @@ -365,15 +354,9 @@ mod tests { #[tokio::test] async fn starts_loopback_proxy_and_serves_policy_local() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let binary = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(binary.path(), b"agent").unwrap(); - - let handle = start_host_proxy(test_config( - ([127, 0, 0, 1], 0).into(), - binary.path().to_path_buf(), - )) - .await - .unwrap(); + let handle = start_host_proxy(test_config(([127, 0, 0, 1], 0).into())) + .await + .unwrap(); let addr = handle.http_addr().expect("proxy should report bound addr"); assert!(addr.ip().is_loopback()); @@ -413,9 +396,6 @@ mod tests { #[tokio::test] async fn per_sandbox_credentials_reject_missing_wrong_cross_and_duplicate_auth() { - let binary = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(binary.path(), b"agent").unwrap(); - let auth_a = HostProxyClientAuth::basic("openshell", "sandbox-a-secret"); let auth_b = HostProxyClientAuth::basic("openshell", "sandbox-b-secret"); // Node's EnvHttpProxyAgent currently emits the field name in lower @@ -429,11 +409,11 @@ mod tests { auth_b.expected_proxy_authorization ); - let mut config_a = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + let mut config_a = test_config(([127, 0, 0, 1], 0).into()); config_a.client_auth = auth_a; let proxy_a = start_host_proxy(config_a).await.unwrap(); - let mut config_b = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + let mut config_b = test_config(([127, 0, 0, 1], 0).into()); config_b.client_auth = auth_b; let proxy_b = start_host_proxy(config_b).await.unwrap(); diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index b815e826c4..2a3ae8824c 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -21,6 +21,8 @@ pub mod run; pub mod sigv4; mod token_grant; pub mod upstream_proxy; +#[cfg(target_os = "windows")] +pub(crate) mod windows_process; #[cfg(test)] pub(crate) mod test_alloc { diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index c6dc767d95..1a364bab50 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7,7 +7,7 @@ pub(crate) mod destination; mod egress; mod relay; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows"))] use crate::identity::BinaryIdentityCache; use crate::l7::EndpointObserver; use crate::l7::tls::ProxyTlsState; @@ -166,11 +166,16 @@ pub(crate) enum ProxyIdentityMode { identity_cache: Arc, entrypoint_pid: Arc, }, - /// Host-side mode for platforms where procfs socket ownership is - /// unavailable. MXC uses this on Windows: every connection redirected to - /// the per-sandbox listener is evaluated as the configured sandbox agent - /// identity. - #[cfg(any(not(target_os = "linux"), test))] + /// Windows host-side mode: bind each request to the process that owns the + /// workload side of the accepted TCP connection. + #[cfg(target_os = "windows")] + Windows { + identity_cache: Arc, + required_proxy_authorization: Option>, + }, + /// Static fallback for platforms without socket-owner resolution and for + /// tests that need to inject a deterministic identity. + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Static { binary_path: PathBuf, binary_sha256: String, @@ -190,12 +195,20 @@ impl ProxyIdentityMode { } } - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + pub(crate) fn windows_with_client_auth(required_proxy_authorization: Option>) -> Self { + Self::Windows { + identity_cache: Arc::new(BinaryIdentityCache::new()), + required_proxy_authorization, + } + } + + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] pub(crate) fn static_binary(path: impl Into) -> Result { Self::static_binary_with_client_auth(path, None) } - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] pub(crate) fn static_binary_with_client_auth( path: impl Into, required_proxy_authorization: Option>, @@ -213,7 +226,12 @@ impl ProxyIdentityMode { match self { #[cfg(target_os = "linux")] Self::Procfs { .. } => None, - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + Self::Windows { + required_proxy_authorization, + .. + } => required_proxy_authorization.as_deref(), + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Self::Static { required_proxy_authorization, .. @@ -227,7 +245,9 @@ impl ProxyIdentityMode { Self::Procfs { entrypoint_pid, .. } => { entrypoint_pid.load(std::sync::atomic::Ordering::Acquire) } - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + Self::Windows { .. } => 0, + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Self::Static { .. } => 0, } } @@ -2870,6 +2890,83 @@ fn sidecar_topology_enabled() -> bool { .is_ok_and(|value| value == SIDECAR_SUPERVISOR_TOPOLOGY) } +#[cfg(target_os = "windows")] +fn authorize_egress_intent_windows( + connection: crate::procfs::WorkloadProxyTcpConnection, + engine: &OpaEngine, + identity_cache: &BinaryIdentityCache, + intent: EgressIntent, +) -> EgressDecision { + let deny = |reason: String, binary: Option, binary_pid: Option| EgressDecision { + intent: intent.clone(), + action: NetworkAction::Deny { reason }, + policy_generation: engine.current_generation(), + identity: ProcessIdentityEvidence::Unavailable(IdentityUnavailableReason::LookupFailed), + endpoint: EndpointDecision::default(), + binary, + binary_pid, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + + let (binary_path, binary_pid) = + match crate::windows_process::resolve_tcp_peer_identity(connection) { + Ok(identity) => identity, + Err(error) => { + return deny( + format!("failed to resolve Windows proxy peer identity: {error}"), + None, + None, + ); + } + }; + let binary_sha256 = match identity_cache.verify_or_cache(&binary_path) { + Ok(hash) => hash, + Err(error) => { + return deny( + format!("binary integrity check failed: {error}"), + Some(binary_path), + Some(binary_pid), + ); + } + }; + let input = crate::opa::NetworkInput { + host: intent.destination.host.clone(), + port: intent.destination.port, + binary_path: binary_path.clone(), + binary_sha256, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + + match engine.authorize_egress(&input) { + Ok(authorization) => EgressDecision { + intent, + action: authorization.action.clone(), + policy_generation: authorization.generation, + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::from_authorization(&authorization), + binary: Some(binary_path), + binary_pid: Some(binary_pid), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }, + Err(error) => EgressDecision { + intent, + action: NetworkAction::Deny { + reason: format!("policy evaluation error: {error}"), + }, + policy_generation: engine.current_generation(), + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::default(), + binary: Some(binary_path), + binary_pid: Some(binary_pid), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }, + } +} + fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> EgressDecision { let input = crate::opa::NetworkInput { host: intent.destination.host.clone(), @@ -2918,7 +3015,7 @@ fn authorize_egress_intent( identity_mode: &ProxyIdentityMode, intent: EgressIntent, ) -> EgressDecision { - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "windows")))] let _ = &connection; if !crate::opa::network_binary_identity_required() { @@ -2937,7 +3034,11 @@ fn authorize_egress_intent( entrypoint_pid, intent, ), - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + ProxyIdentityMode::Windows { identity_cache, .. } => { + authorize_egress_intent_windows(connection, engine, identity_cache, intent) + } + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] ProxyIdentityMode::Static { binary_path, binary_sha256, @@ -7552,6 +7653,8 @@ network_policies: } #[cfg(target_os = "linux")] ProxyIdentityMode::Procfs { .. } => panic!("expected static identity mode"), + #[cfg(target_os = "windows")] + ProxyIdentityMode::Windows { .. } => panic!("expected static identity mode"), } } diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index d1372d8432..622a5b31d3 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -163,7 +163,7 @@ pub struct Networking { _transparent_tcp: Option, } -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] fn current_exe_static_identity_path() -> Result { std::env::current_exe().map_err(|e| { miette::miette!("failed to resolve supervisor executable for static proxy identity: {e}") @@ -441,7 +441,7 @@ pub async fn run_networking( ProxyIdentityMode::procfs(cache, entrypoint_pid.clone()) }; #[cfg(target_os = "windows")] - let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; + let identity_mode = ProxyIdentityMode::windows_with_client_auth(None); #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; diff --git a/crates/openshell-supervisor-network/src/windows_process.rs b/crates/openshell-supervisor-network/src/windows_process.rs new file mode 100644 index 0000000000..d27f73aa4e --- /dev/null +++ b/crates/openshell-supervisor-network/src/windows_process.rs @@ -0,0 +1,286 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Windows TCP socket-owner and process-image resolution. + +use std::mem::{size_of, size_of_val}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::path::PathBuf; + +use miette::Result; +use windows::Win32::Foundation::{CloseHandle, ERROR_INSUFFICIENT_BUFFER, HANDLE}; +use windows::Win32::NetworkManagement::IpHelper::{ + GetExtendedTcpTable, MIB_TCP_STATE_ESTAB, MIB_TCP6ROW_OWNER_PID, MIB_TCPROW_OWNER_PID, + TCP_TABLE_OWNER_PID_ALL, +}; +use windows::Win32::Networking::WinSock::{AF_INET, AF_INET6}; +use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, +}; +use windows::core::PWSTR; + +use crate::procfs::WorkloadProxyTcpConnection; + +struct ProcessHandle(HANDLE); + +impl Drop for ProcessHandle { + fn drop(&mut self) { + // SAFETY: `self.0` is a valid handle returned by `OpenProcess`, and this + // guard is its sole owner. + #[allow(unsafe_code)] + let _ = unsafe { CloseHandle(self.0) }; + } +} + +/// Resolve the process that owns the workload side of an accepted proxy TCP +/// connection and return its PID and executable image path. +pub fn resolve_tcp_peer_identity(connection: WorkloadProxyTcpConnection) -> Result<(PathBuf, u32)> { + let mut owners = match (connection.workload, connection.proxy) { + (SocketAddr::V4(workload), SocketAddr::V4(proxy)) => ipv4_owner_pids(workload, proxy)?, + (SocketAddr::V6(workload), SocketAddr::V6(proxy)) => ipv6_owner_pids(&workload, &proxy)?, + _ => { + return Err(miette::miette!( + "TCP connection address families do not match: {connection}" + )); + } + }; + owners.sort_unstable(); + owners.dedup(); + + let pid = match owners.as_slice() { + [pid] => *pid, + [] => { + return Err(miette::miette!( + "No Windows process owns proxy connection {connection}" + )); + } + pids => { + return Err(miette::miette!( + "Ambiguous Windows proxy connection ownership for {connection}: PIDs [{}]", + pids.iter() + .map(u32::to_string) + .collect::>() + .join(", ") + )); + } + }; + + Ok((process_image_path(pid)?, pid)) +} + +fn ipv4_owner_pids( + workload: std::net::SocketAddrV4, + proxy: std::net::SocketAddrV4, +) -> Result> { + let (buffer, byte_len) = tcp_table(u32::from(AF_INET.0))?; + let rows = table_rows::(&buffer, byte_len)?; + let established = u32::try_from(MIB_TCP_STATE_ESTAB.0).expect("TCP state constant fits u32"); + Ok(rows + .into_iter() + .filter(|row| { + row.dwState == established + && IpAddr::V4(Ipv4Addr::from(row.dwLocalAddr.to_ne_bytes())) + == IpAddr::V4(*workload.ip()) + && tcp_port(row.dwLocalPort) == workload.port() + && IpAddr::V4(Ipv4Addr::from(row.dwRemoteAddr.to_ne_bytes())) + == IpAddr::V4(*proxy.ip()) + && tcp_port(row.dwRemotePort) == proxy.port() + }) + .map(|row| row.dwOwningPid) + .collect()) +} + +fn ipv6_owner_pids( + workload: &std::net::SocketAddrV6, + proxy: &std::net::SocketAddrV6, +) -> Result> { + let (buffer, byte_len) = tcp_table(u32::from(AF_INET6.0))?; + let rows = table_rows::(&buffer, byte_len)?; + let established = u32::try_from(MIB_TCP_STATE_ESTAB.0).expect("TCP state constant fits u32"); + Ok(rows + .into_iter() + .filter(|row| { + row.dwState == established + && Ipv6Addr::from(row.ucLocalAddr) == *workload.ip() + && row.dwLocalScopeId == workload.scope_id() + && tcp_port(row.dwLocalPort) == workload.port() + && Ipv6Addr::from(row.ucRemoteAddr) == *proxy.ip() + && row.dwRemoteScopeId == proxy.scope_id() + && tcp_port(row.dwRemotePort) == proxy.port() + }) + .map(|row| row.dwOwningPid) + .collect()) +} + +fn tcp_port(raw: u32) -> u16 { + let low_word = u16::try_from(raw & u32::from(u16::MAX)).expect("masked TCP port fits u16"); + u16::from_be(low_word) +} + +fn tcp_table(address_family: u32) -> Result<(Vec, usize)> { + let mut byte_len = 0u32; + // SAFETY: A null table pointer is the documented size-query form. The + // mutable size pointer is valid for the duration of the call. + #[allow(unsafe_code)] + let initial = unsafe { + GetExtendedTcpTable( + None, + &raw mut byte_len, + false, + address_family, + TCP_TABLE_OWNER_PID_ALL, + 0, + ) + }; + if initial != ERROR_INSUFFICIENT_BUFFER.0 && initial != 0 { + return Err(miette::miette!( + "GetExtendedTcpTable size query failed with Win32 error {initial}" + )); + } + + for _ in 0..3 { + let mut buffer = vec![0u32; (byte_len as usize).div_ceil(size_of::()).max(1)]; + let mut actual_len = u32::try_from(buffer.len() * size_of::()) + .map_err(|_| miette::miette!("TCP table buffer is too large"))?; + // SAFETY: The u32-backed buffer has sufficient alignment and capacity + // for the requested byte count. The API writes at most `actual_len` + // bytes and updates it when the table grows concurrently. + #[allow(unsafe_code)] + let status = unsafe { + GetExtendedTcpTable( + Some(buffer.as_mut_ptr().cast()), + &raw mut actual_len, + false, + address_family, + TCP_TABLE_OWNER_PID_ALL, + 0, + ) + }; + if status == 0 { + return Ok((buffer, actual_len as usize)); + } + if status != ERROR_INSUFFICIENT_BUFFER.0 { + return Err(miette::miette!( + "GetExtendedTcpTable failed with Win32 error {status}" + )); + } + byte_len = actual_len; + } + + Err(miette::miette!( + "GetExtendedTcpTable changed size during three consecutive reads" + )) +} + +fn table_rows(buffer: &[u32], byte_len: usize) -> Result> { + if byte_len < size_of::() { + return Err(miette::miette!("Windows TCP table is missing its header")); + } + let count = buffer[0] as usize; + let required = size_of::() + .checked_add( + count + .checked_mul(size_of::()) + .ok_or_else(|| miette::miette!("Windows TCP row count overflow"))?, + ) + .ok_or_else(|| miette::miette!("Windows TCP table size overflow"))?; + if required > byte_len || required > size_of_val(buffer) { + return Err(miette::miette!( + "Windows TCP table is truncated: {count} rows require {required} bytes, got {byte_len}" + )); + } + + let mut rows = Vec::with_capacity(count); + // SAFETY: Bounds were checked above. `read_unaligned` avoids assuming the + // row begins at more than the four-byte alignment guaranteed by the API. + #[allow(unsafe_code)] + unsafe { + let first = buffer.as_ptr().cast::().add(size_of::()); + for index in 0..count { + rows.push(std::ptr::read_unaligned( + first.add(index * size_of::()).cast::(), + )); + } + } + Ok(rows) +} + +fn process_image_path(pid: u32) -> Result { + // SAFETY: The access mask and PID are plain values; the returned handle is + // immediately placed under an RAII guard. + #[allow(unsafe_code)] + let handle = ProcessHandle( + unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } + .map_err(|error| miette::miette!("Failed to open socket-owning PID {pid}: {error}"))?, + ); + let mut path = vec![0u16; 32_768]; + let mut path_len = u32::try_from(path.len()).expect("Windows path buffer length fits u32"); + // SAFETY: The handle is valid, and the UTF-16 output buffer and in/out + // length pointer remain valid for the call. + #[allow(unsafe_code)] + unsafe { + QueryFullProcessImageNameW( + handle.0, + PROCESS_NAME_WIN32, + PWSTR(path.as_mut_ptr()), + &raw mut path_len, + ) + } + .map_err(|error| { + miette::miette!("Failed to query executable path for socket-owning PID {pid}: {error}") + })?; + path.truncate(path_len as usize); + Ok(PathBuf::from(String::from_utf16(&path).map_err( + |error| miette::miette!("Socket-owning PID {pid} returned an invalid UTF-16 path: {error}"), + )?)) +} + +#[cfg(test)] +mod tests { + use std::io::Read; + use std::net::{TcpListener, TcpStream}; + use std::process::{Command, Stdio}; + + use super::*; + + const CHILD_PORT_ENV: &str = "OPENSHELL_TEST_WINDOWS_SOCKET_OWNER_PORT"; + + #[test] + fn socket_owner_child() { + let Ok(port) = std::env::var(CHILD_PORT_ENV) else { + return; + }; + let mut stream = TcpStream::connect(("127.0.0.1", port.parse::().unwrap())).unwrap(); + let mut byte = [0u8; 1]; + let _ = stream.read(&mut byte); + } + + #[test] + fn resolves_child_that_owns_ipv4_connection() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let proxy = listener.local_addr().unwrap(); + let current_exe = std::env::current_exe().unwrap(); + let mut child = Command::new(¤t_exe) + .args([ + "--exact", + "windows_process::tests::socket_owner_child", + "--nocapture", + ]) + .env(CHILD_PORT_ENV, proxy.port().to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + + let (accepted, workload) = listener.accept().unwrap(); + let result = resolve_tcp_peer_identity(WorkloadProxyTcpConnection::new(workload, proxy)); + drop(accepted); + let status = child.wait().unwrap(); + assert!(status.success()); + + let (path, pid) = result.unwrap(); + assert_eq!(pid, child.id()); + assert_eq!(path, current_exe); + } +} From 61afbad9bc2496cb04b9606ff4cdb649cfc491a2 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 21 Sep 2026 10:40:35 -0700 Subject: [PATCH 3/3] fix(network): preserve sandbox context with socket owners Signed-off-by: Prekshi Vyas (cherry picked from commit 32ea316deb6c83c157fdf2de4a2cdc3a7d2da1e3) --- .../openshell-supervisor-network/src/host.rs | 7 ++--- .../openshell-supervisor-network/src/proxy.rs | 27 ++++++++++++++++--- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index 98bfebc283..e2a020ffb5 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -308,7 +308,7 @@ mod tests { #[test] fn host_proxy_event_context_uses_configured_sandbox_identity() { let bind_addr = "127.0.0.1:18080".parse().unwrap(); - let config = test_config(bind_addr, PathBuf::from("agent.exe")); + let config = test_config(bind_addr); let context = host_proxy_event_context(&config).unwrap(); @@ -320,10 +320,7 @@ mod tests { #[test] fn host_proxy_event_context_rejects_missing_sandbox_identity() { - let mut config = test_config( - "127.0.0.1:18080".parse().unwrap(), - PathBuf::from("agent.exe"), - ); + let mut config = test_config("127.0.0.1:18080".parse().unwrap()); config.sandbox_id = Some(" ".to_string()); let error = host_proxy_event_context(&config).unwrap_err(); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 0fa724b2eb..f34e3a7417 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -172,6 +172,9 @@ pub(crate) enum ProxyIdentityMode { Windows { identity_cache: Arc, required_proxy_authorization: Option>, + /// Per-sandbox context for host-side proxies. The process-wide OCSF + /// context cannot identify one sandbox when a gateway hosts many. + event_context: Option>, }, /// Static fallback for platforms without socket-owner resolution and for /// tests that need to inject a deterministic identity. @@ -203,6 +206,7 @@ impl ProxyIdentityMode { Self::Windows { identity_cache: Arc::new(BinaryIdentityCache::new()), required_proxy_authorization, + event_context: None, } } @@ -229,8 +233,10 @@ impl ProxyIdentityMode { #[cfg(target_os = "windows")] pub(super) fn with_event_context(mut self, context: EventContext) -> Self { match &mut self { - #[cfg(target_os = "linux")] - Self::Procfs { .. } => {} + Self::Windows { event_context, .. } => { + *event_context = Some(Arc::new(context)); + } + #[cfg(test)] Self::Static { event_context, .. } => { *event_context = Some(Arc::new(context)); } @@ -240,7 +246,12 @@ impl ProxyIdentityMode { fn event_context(&self) -> &EventContext { match self { - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + Self::Windows { + event_context: Some(context), + .. + } => context, + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Self::Static { event_context: Some(context), .. @@ -7417,6 +7428,16 @@ network_policies: validate_required_fields(&sandbox_b, &schema); } + #[cfg(target_os = "windows")] + #[test] + fn windows_socket_owner_identity_keeps_per_proxy_sandbox_attribution() { + let identity = ProxyIdentityMode::windows_with_client_auth(None) + .with_event_context(proxy_event_context("sandbox-a-id", "sandbox-a")); + + assert_eq!(identity.event_context().sandbox_id, "sandbox-a-id"); + assert_eq!(identity.event_context().sandbox_name, "sandbox-a"); + } + #[test] fn forward_l7_parse_rejection_ocsf_includes_denial_context() { let event = build_forward_l7_parse_rejection_ocsf_event(