From eab3eec2a8b50d9306738daac7379dba0f25b1ae Mon Sep 17 00:00:00 2001 From: PurpleCHOIms <31537001+PurpleCHOIms@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:14:37 +0900 Subject: [PATCH 1/3] fix(policy): stop treating named and hostless endpoints as competing selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hostless allowed_ips endpoint and a named endpoint authorize different destination classes: the hostless endpoint matches raw-IP connections in its ranges, the named endpoint matches requests to its hostname. The two never compete for the same request's connection or request-pipeline config, so find_endpoint_ambiguities now skips pairs where exactly one side is hostless. This mirrors the existing plain_l4_endpoint_does_not_compete_with_l7_endpoint_metadata guarantee — an IP-scoped selector could never qualify as plain L4 because its destination selector is the allowed_ips list itself. Hostless-vs-hostless pairs still compete (both match the same raw-IP connection class), so differing allowed_ips between two hostless endpoints remains ambiguous. This unblocks the common red-team scope shape of named targets plus CIDR ranges in one policy, which the current check rejects even though the runtime match sets are disjoint. Signed-off-by: PurpleCHOIms <31537001+PurpleCHOIms@users.noreply.github.com> --- crates/openshell-policy/src/ambiguity.rs | 59 ++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/openshell-policy/src/ambiguity.rs b/crates/openshell-policy/src/ambiguity.rs index efb846a9a1..4dfcc1fe0b 100644 --- a/crates/openshell-policy/src/ambiguity.rs +++ b/crates/openshell-policy/src/ambiguity.rs @@ -89,6 +89,13 @@ pub fn find_endpoint_ambiguities(policy: &SandboxPolicy) -> Vec NetworkEndpoint { + NetworkEndpoint { + host: String::new(), + port: 443, + allowed_ips: vec!["10.10.0.0/24".to_string()], + protocol: protocol.to_string(), + enforcement: NetworkEnforcementMode::Enforce as i32, + ..Default::default() + } + } + + #[test] + fn named_and_hostless_endpoints_are_different_destination_classes() { + // A hostless allowed_ips endpoint authorizes raw-IP connections in + // its range; a named endpoint authorizes requests to its hostname. + // They never compete for the same request's config, so the pair + // mirrors plain_l4_endpoint_does_not_compete_with_l7_endpoint_metadata. + assert!(find_endpoint_ambiguities(&policy_with( + hostless_cidr("rest"), + endpoint("example.com", 443) + )) + .is_empty()); + } + + #[test] + fn named_endpoint_metadata_conflicts_do_not_spill_into_hostless_pairs() { + let mut tls_skip = hostless_cidr("rest"); + tls_skip.tls = NetworkTlsMode::Skip as i32; + let mut named_tcp = endpoint("example.com", 443); + named_tcp.protocol = "tcp".to_string(); + named_tcp.tls = NetworkTlsMode::Skip as i32; + + assert!(find_endpoint_ambiguities(&policy_with(tls_skip, named_tcp)).is_empty()); + } + + #[test] + fn hostless_endpoints_still_compete_on_raw_ip_connections() { + // Two hostless endpoints authorize the SAME raw-IP connection class, + // so differing allowed_ips remain ambiguous. + let mut other_range = hostless_cidr("rest"); + other_range.allowed_ips = vec!["192.168.0.0/16".to_string()]; + + let ambiguities = find_endpoint_ambiguities(&policy_with(hostless_cidr("rest"), other_range)); + assert_eq!(ambiguities.len(), 1); + assert!( + ambiguities[0] + .conflicts + .iter() + .any(|field| field.contains("allowed_ips")) + ); + } + #[test] fn explicit_tcp_and_omitted_protocol_are_ambiguous_for_native_tcp_eligibility() { let mut explicit_tcp = endpoint("api.example.com", 443); From 7ecde29fa947a82e0108bf3567d7e1489d64ef3a Mon Sep 17 00:00:00 2001 From: PurpleCHOIms <31537001+PurpleCHOIms@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:14:37 +0900 Subject: [PATCH 2/3] fix(network): scope hostless allowed_ips endpoints to raw-IP destinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hostless allowed_ips endpoint is a destination selector: it authorizes connections addressed by IP literal in its ranges. Under the previous matcher it also admitted every DNS-mediated hostname on its ports and constrained their pinned resolutions to its ranges, which made a policy mixing named endpoints with CIDR scopes deny every named host that resolves outside the range — the mixed-scope shape the ambiguity validator change (previous commit) was meant to unlock. The matcher now requires the destination to be an IP literal (dotted quad or anything carrying a colon); DNS-mediated names are governed by named endpoints, consistent with the named-vs-hostless class separation. The in-range check still happens in Rust after the pin. Tests updated: raw-IP admission, named rejection, multi-port on raw-IP. Signed-off-by: PurpleCHOIms <31537001+PurpleCHOIms@users.noreply.github.com> --- .../data/sandbox-policy.rego | 35 +++++++++++++++++-- .../openshell-supervisor-network/src/opa.rs | 33 ++++++++++++----- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 3c98c7c2e9..ca0188b051 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -124,15 +124,44 @@ endpoint_allowed(policy, network) if { endpoint.ports[_] == network.port } -# Endpoint matching: hostless with allowed_ips — match any host on port. -# When an endpoint has allowed_ips but no host, it matches any hostname on the -# given port. The actual IP validation happens in Rust post-DNS-resolution. +# Endpoint matching: hostless with allowed_ips — raw-IP destinations only. +# The allowed_ips list is the destination selector for these endpoints, so +# they authorize connections addressed by IP literal on the given port. +# DNS-mediated hostname connections are governed by their named endpoints; +# the range check itself still happens in Rust after the pin. Raw-IPv6 +# destinations carry colons; a hostname always carries letters, so the +# literal-shaped check cannot match a mediated name. endpoint_allowed(policy, network) if { some endpoint endpoint := policy.endpoints[_] object.get(endpoint, "host", "") == "" count(object.get(endpoint, "allowed_ips", [])) > 0 endpoint.ports[_] == network.port + raw_ip_destination(network.host) +} + +raw_ip_destination(host) if { + parts := split(host, ".") + count(parts) == 4 + every part in parts { + ipv4_label(part) + } +} + +raw_ip_destination(host) if { + contains(host, ":") +} + +ipv4_label(s) if { + glob.match("[0-9]", [], s) +} + +ipv4_label(s) if { + glob.match("[0-9][0-9]", [], s) +} + +ipv4_label(s) if { + glob.match("[0-9][0-9][0-9]", [], s) } # Binary matching can be relaxed by trusted runtime configuration. In that diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 2d1a73a949..152ffb7f60 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -9845,21 +9845,36 @@ network_policies: } #[test] - fn allowed_ips_mode3_hostless_allows_any_domain() { + fn allowed_ips_mode3_matches_raw_ip_and_not_dns_names() { let engine = allowed_ips_engine(); - // Any hostname on port 9443 should match the private_network policy - let input = NetworkInput { - host: "anything.example.com".into(), + // Hostless endpoints authorize IP-literal destinations on the port; + // DNS-mediated names are governed by named endpoints instead. + let raw = NetworkInput { + host: "172.16.9.9".into(), port: 9443, binary_path: PathBuf::from("/usr/bin/curl"), binary_sha256: "unused".into(), ancestors: vec![], cmdline_paths: vec![], }; - let decision = engine.evaluate_network(&input).unwrap(); + let decision = engine.evaluate_network(&raw).unwrap(); assert!( decision.allowed, - "Mode 3 (IPs only) should allow any domain on matching port: {}", + "Mode 3 (IPs only) should allow raw-IP destinations on matching port: {}", + decision.reason + ); + let named = NetworkInput { + host: "anything.example.com".into(), + port: 9443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&named).unwrap(); + assert!( + !decision.allowed, + "Mode 3 (IPs only) must not admit DNS-mediated names: {}", decision.reason ); } @@ -9868,7 +9883,7 @@ network_policies: fn allowed_ips_mode3_returns_allowed_ips() { let engine = allowed_ips_engine(); let input = NetworkInput { - host: "anything.example.com".into(), + host: "172.16.9.9".into(), port: 9443, binary_path: PathBuf::from("/usr/bin/curl"), binary_sha256: "unused".into(), @@ -10217,7 +10232,7 @@ network_policies: let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); // Port 80 let input80 = NetworkInput { - host: "anything.internal".into(), + host: "10.1.2.3".into(), port: 80, binary_path: PathBuf::from("/usr/bin/curl"), binary_sha256: "unused".into(), @@ -10232,7 +10247,7 @@ network_policies: ); // Port 443 let input443 = NetworkInput { - host: "anything.internal".into(), + host: "10.1.2.3".into(), port: 443, binary_path: PathBuf::from("/usr/bin/curl"), binary_sha256: "unused".into(), From 82b6260a1cdf9d0aa9b06640f40b5dc1d6198625 Mon Sep 17 00:00:00 2001 From: PurpleCHOIms <31537001+PurpleCHOIms@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:14:37 +0900 Subject: [PATCH 3/3] fix(network): apply the destination-class separation to endpoint metadata matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit endpoint_matches_request carried its own hostless matcher (port-only), so the metadata/allowed_ips extraction path still attached a CIDR endpoint's ranges to every DNS-mediated hostname sharing its ports. The policy-DNS pin validation then intersected the named endpoint's resolutions with the CIDR and produced an empty allowlist — mixed named+CIDR policies denied every named host resolving outside the range (live: example.com transparent_tcp_destination_denied). The hostless branch now requires a raw-IP destination, mirroring the admission matcher and the validator class separation. Signed-off-by: PurpleCHOIms <31537001+PurpleCHOIms@users.noreply.github.com> --- .../openshell-supervisor-network/data/sandbox-policy.rego | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index ca0188b051..969dd6fa0b 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -1096,11 +1096,15 @@ endpoint_matches_request(ep, network) if { ep.ports[_] == network.port } -# Hostless endpoint with allowed_ips: match on port only. +# Hostless endpoint with allowed_ips: raw-IP destinations on port only. +# Same destination-class separation as the admission matcher: DNS-mediated +# names are governed by named endpoints, so a hostless CIDR endpoint never +# constrains a hostname's pinned resolutions. endpoint_matches_request(ep, network) if { object.get(ep, "host", "") == "" count(object.get(ep, "allowed_ips", [])) > 0 ep.ports[_] == network.port + raw_ip_destination(network.host) } endpoint_matches_l7_request(ep, network, request) if {