diff --git a/Cargo.toml b/Cargo.toml index 8f8a2efcc6..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_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "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 7f28408535..d5094b3141 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -668,14 +668,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", @@ -1621,7 +1613,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 e5e7dbd399..d54a9b8cb6 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -1125,6 +1125,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/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index ea4082b9fa..e2a020ffb5 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, @@ -250,10 +246,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()), @@ -314,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(); @@ -326,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(); @@ -369,7 +360,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(); @@ -378,11 +371,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"); @@ -395,10 +384,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 { @@ -427,15 +413,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()); @@ -475,9 +455,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 @@ -491,11 +468,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/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index b824c58d26..cefa955cea 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -11,6 +11,7 @@ use crate::procfs; use miette::Result; use std::collections::HashMap; +#[cfg(not(target_os = "windows"))] use std::fs::Metadata; #[cfg(unix)] use std::os::unix::fs::MetadataExt; @@ -18,6 +19,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use tracing::debug; +#[cfg(not(target_os = "windows"))] #[derive(Clone)] struct FileFingerprint { len: u64, @@ -29,6 +31,7 @@ struct FileFingerprint { ino: u64, } +#[cfg(not(target_os = "windows"))] impl FileFingerprint { fn from_metadata(metadata: &Metadata) -> Self { #[cfg(unix)] @@ -53,7 +56,7 @@ impl FileFingerprint { } } -#[cfg(not(unix))] +#[cfg(all(not(unix), not(target_os = "windows")))] fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { let duration = time.duration_since(std::time::UNIX_EPOCH).ok()?; Some(( @@ -62,6 +65,7 @@ fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { )) } +#[cfg(not(target_os = "windows"))] impl PartialEq for FileFingerprint { fn eq(&self, other: &Self) -> bool { self.len == other.len @@ -87,6 +91,7 @@ impl PartialEq for FileFingerprint { #[derive(Clone)] struct CachedBinary { hash: String, + #[cfg(not(target_os = "windows"))] fingerprint: FileFingerprint, } @@ -138,8 +143,10 @@ impl BinaryIdentityCache { let start = std::time::Instant::now(); let metadata = std::fs::metadata(access_path) .map_err(|error| miette::miette!("Failed to stat {}: {error}", cache_path.display()))?; + #[cfg(not(target_os = "windows"))] let fingerprint = FileFingerprint::from_metadata(&metadata); + #[cfg(not(target_os = "windows"))] let cached = self .hashes .lock() @@ -147,6 +154,10 @@ impl BinaryIdentityCache { .get(cache_path) .cloned(); + // Windows creation/modification timestamps and length can be restored + // after an in-place same-length rewrite. Rehash every Windows request + // so writable executables cannot reuse a spoofed cache fingerprint. + #[cfg(not(target_os = "windows"))] if let Some(cached_binary) = &cached && cached_binary.fingerprint == fingerprint { @@ -186,6 +197,7 @@ impl BinaryIdentityCache { cache_path.to_path_buf(), CachedBinary { hash: current_hash.clone(), + #[cfg(not(target_os = "windows"))] fingerprint, }, ); @@ -253,7 +265,7 @@ mod tests { .unwrap(); assert_eq!(hash1, hash2); - assert_eq!(hash_calls, 1); + assert_eq!(hash_calls, if cfg!(target_os = "windows") { 2 } else { 1 }); } #[test] @@ -329,6 +341,34 @@ mod tests { assert_eq!(hash_calls, 2); } + #[cfg(target_os = "windows")] + #[test] + fn same_length_rewrite_with_restored_mtime_is_rehashed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("binary.exe"); + let original = b"trusted-content!"; + let tampered = b"tampered-content"; + assert_eq!(original.len(), tampered.len()); + std::fs::write(&path, original).unwrap(); + + let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap(); + let cache = BinaryIdentityCache::new(); + cache.verify_or_cache(&path).unwrap(); + + std::fs::write(&path, tampered).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap() + .set_modified(original_mtime) + .unwrap(); + + let error = cache + .verify_or_cache(&path) + .expect_err("Windows must rehash despite a restored metadata fingerprint"); + assert!(error.to_string().contains("integrity violation")); + } + #[test] fn display_path_can_differ_from_access_path() { let mut tmp = tempfile::NamedTempFile::new().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 fb722ad4da..4f3d9af566 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; @@ -175,11 +175,19 @@ 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>, + /// 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. + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Static { binary_path: PathBuf, binary_sha256: String, @@ -202,12 +210,21 @@ 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, + event_context: None, + } + } + + #[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>, @@ -225,8 +242,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)); } @@ -236,7 +255,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), .. @@ -249,7 +273,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, .. @@ -263,7 +292,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, } } @@ -2989,6 +3020,86 @@ 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(), + binary_match_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(), + binary_match_paths: authorization.binary_match_paths.clone(), + }, + 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(), + binary_match_paths: Vec::new(), + }, + } +} + fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> EgressDecision { let input = crate::opa::NetworkInput { host: intent.destination.host.clone(), @@ -3039,7 +3150,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() { @@ -3058,7 +3169,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, @@ -7399,6 +7514,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( @@ -7768,6 +7893,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/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 9c5cfb1af8..423d63c7cf 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -471,8 +471,14 @@ network_policies: }, ); request.request_default_port = Some(80); - let context = prepare_http_relay(Some(&route), &engine, &decision, &request) - .expect("current policy generation should prepare the relay"); + let context = prepare_http_relay( + Some(&route), + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .expect("current policy generation should prepare the relay"); relay_http_stream(&mut relay_client, &mut relay_upstream, context).await }); 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..15a1d828e5 --- /dev/null +++ b/crates/openshell-supervisor-network/src/windows_process.rs @@ -0,0 +1,381 @@ +// 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::{offset_of, 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_TCP6TABLE_OWNER_PID, + MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_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, + offset_of!(MIB_TCPTABLE_OWNER_PID, table), + size_of::(), + )?; + 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, + offset_of!(MIB_TCP6TABLE_OWNER_PID, table), + size_of::(), + )?; + let established = u32::try_from(MIB_TCP_STATE_ESTAB.0).expect("TCP state constant fits u32"); + Ok(rows + .into_iter() + .filter(|row| ipv6_row_matches(row, workload, proxy, established)) + .map(|row| row.dwOwningPid) + .collect()) +} + +fn ipv6_row_matches( + row: &MIB_TCP6ROW_OWNER_PID, + workload: &std::net::SocketAddrV6, + proxy: &std::net::SocketAddrV6, + established: u32, +) -> bool { + row.dwState == established + && Ipv6Addr::from(row.ucLocalAddr) == *workload.ip() + && u32::from_be(row.dwLocalScopeId) == workload.scope_id() + && tcp_port(row.dwLocalPort) == workload.port() + && Ipv6Addr::from(row.ucRemoteAddr) == *proxy.ip() + && u32::from_be(row.dwRemoteScopeId) == proxy.scope_id() + && tcp_port(row.dwRemotePort) == proxy.port() +} + +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, + first_row_offset: usize, + row_stride: usize, +) -> Result> { + if byte_len < size_of::() { + return Err(miette::miette!("Windows TCP table is missing its header")); + } + if row_stride < size_of::() { + return Err(miette::miette!( + "Windows TCP table row stride {row_stride} is smaller than row size {}", + size_of::() + )); + } + let count = buffer[0] as usize; + let required = if count == 0 { + size_of::() + } else { + first_row_offset + .checked_add( + (count - 1) + .checked_mul(row_stride) + .ok_or_else(|| miette::miette!("Windows TCP row count overflow"))?, + ) + .and_then(|last_row| last_row.checked_add(size_of::())) + .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` supports the padding + // permitted before the first row and between generated table rows. + #[allow(unsafe_code)] + unsafe { + let first = buffer.as_ptr().cast::().add(first_row_offset); + for index in 0..count { + rows.push(std::ptr::read_unaligned( + first.add(index * row_stride).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); + } + + #[test] + fn parses_table_with_header_and_inter_row_padding() { + #[repr(C)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct SyntheticRow { + value: u32, + pid: u32, + } + + const FIRST_ROW_OFFSET: usize = 8; + const ROW_STRIDE: usize = 12; + let buffer = vec![ + 2, // dwNumEntries + 0xAAAA_AAAA, // header padding + 11, + 101, + 0xBBBB_BBBB, // inter-row padding + 22, + 202, + ]; + let rows = table_rows::( + &buffer, + size_of_val(buffer.as_slice()), + FIRST_ROW_OFFSET, + ROW_STRIDE, + ) + .expect("padded table should parse using its declared layout"); + + assert_eq!( + rows, + vec![ + SyntheticRow { + value: 11, + pid: 101, + }, + SyntheticRow { + value: 22, + pid: 202, + }, + ] + ); + } + + #[test] + fn matches_ipv6_scope_ids_from_network_byte_order() { + let workload = std::net::SocketAddrV6::new("fe80::1".parse().unwrap(), 51_234, 0, 17); + let proxy = std::net::SocketAddrV6::new("fe80::2".parse().unwrap(), 31_234, 0, 23); + let established = u32::try_from(MIB_TCP_STATE_ESTAB.0).unwrap(); + let row = MIB_TCP6ROW_OWNER_PID { + ucLocalAddr: workload.ip().octets(), + dwLocalScopeId: workload.scope_id().to_be(), + dwLocalPort: u32::from(workload.port().to_be()), + ucRemoteAddr: proxy.ip().octets(), + dwRemoteScopeId: proxy.scope_id().to_be(), + dwRemotePort: u32::from(proxy.port().to_be()), + dwState: established, + dwOwningPid: 42, + }; + + assert!(ipv6_row_matches(&row, &workload, &proxy, established)); + } +}