diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 5164ec4233..4ed25ea989 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -395,7 +395,10 @@ or decide whether an in-boundary change is eligible for automatic approval. The containment model covers filesystem paths, supported process identities, Landlock compatibility requirements, L4 destinations including IP ranges, and -enforced REST method and path authority. Identity comparisons assume consistent +enforced REST method, path, and supported query-parameter authority. Query checks +support exact ASCII values and `*` with the runtime's dot-delimited glob semantics, +including missing and repeated parameters. Other query matchers remain unsupported. +Identity comparisons assume consistent user and group resolution. Compatibility checks compare requested enforcement requirements, not the actual kernel state of a running sandbox. It returns explicit unsupported or inconclusive diff --git a/crates/openshell-prover-cli/src/main.rs b/crates/openshell-prover-cli/src/main.rs index 69166c4e37..6f621803f9 100644 --- a/crates/openshell-prover-cli/src/main.rs +++ b/crates/openshell-prover-cli/src/main.rs @@ -109,6 +109,8 @@ enum CounterexampleJson<'a> { protocol: &'a str, method: Option<&'a str>, path: Option<&'a str>, + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + query_params: &'a std::collections::BTreeMap>, }, } @@ -413,6 +415,7 @@ fn counterexample_json(counterexample: &Counterexample) -> Result CounterexampleJson::Network { binary: binary.as_deref(), @@ -425,6 +428,7 @@ fn counterexample_json(counterexample: &Counterexample) -> Result return Err("unsupported counterexample kind returned by containment API".to_owned()), }; @@ -471,9 +475,10 @@ fn render_text(mut writer: impl Write, envelope: &Envelope<'_>) -> Result<(), St protocol, method, path, + query_params, } => writeln!( writer, - "counterexample: network binary={} ancestor_binary={} binary_identity_required={} host={}:{} destination_ip={} trusted_gateway={} protocol={} method={} path={}", + "counterexample: network binary={} ancestor_binary={} binary_identity_required={} host={}:{} destination_ip={} trusted_gateway={} protocol={} method={} path={} query_params={query_params:?}", binary.map_or("-".to_owned(), escape_terminal), ancestor_binary.map_or("-".to_owned(), escape_terminal), binary_identity_required, diff --git a/crates/openshell-prover-cli/tests/cli.rs b/crates/openshell-prover-cli/tests/cli.rs index 414164c85d..9c0fc65e6e 100644 --- a/crates/openshell-prover-cli/tests/cli.rs +++ b/crates/openshell-prover-cli/tests/cli.rs @@ -38,6 +38,41 @@ fn check_json(candidate: &str, boundary: &str) -> Output { ]) } +#[test] +fn query_containment_and_counterexample_are_exposed_in_cli() { + let within = check_json("query-upload.yaml", "query-any.yaml"); + assert_eq!( + within.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&within.stdout) + ); + let denied = check_json("query-receive.yaml", "query-upload.yaml"); + assert_eq!( + denied.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&denied.stdout) + ); + let value: Value = serde_json::from_slice(&denied.stdout).unwrap(); + assert_eq!( + value["counterexample"]["query_params"]["service"], + serde_json::json!(["git-receive-pack"]) + ); + let text = run(&[ + "check", + fixture("query-receive.yaml").to_str().unwrap(), + "--boundary", + fixture("query-upload.yaml").to_str().unwrap(), + ]); + assert_eq!(text.status.code(), Some(1)); + let text = String::from_utf8(text.stdout).unwrap(); + assert!( + text.contains("query_params=") && text.contains("git-receive-pack"), + "{text}" + ); +} + #[test] fn help_and_version_succeed() { for args in [ diff --git a/crates/openshell-prover-cli/tests/fixtures/query-any.yaml b/crates/openshell-prover-cli/tests/fixtures/query-any.yaml new file mode 100644 index 0000000000..dc83ba358f --- /dev/null +++ b/crates/openshell-prover-cli/tests/fixtures/query-any.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 +network_policies: + git: + binaries: + - path: /usr/bin/git + endpoints: + - host: github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /org/repo.git/info/refs + query: + service: "*" diff --git a/crates/openshell-prover-cli/tests/fixtures/query-receive.yaml b/crates/openshell-prover-cli/tests/fixtures/query-receive.yaml new file mode 100644 index 0000000000..f95d285476 --- /dev/null +++ b/crates/openshell-prover-cli/tests/fixtures/query-receive.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 +network_policies: + git: + binaries: + - path: /usr/bin/git + endpoints: + - host: github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /org/repo.git/info/refs + query: + service: "git-receive-pack" diff --git a/crates/openshell-prover-cli/tests/fixtures/query-upload.yaml b/crates/openshell-prover-cli/tests/fixtures/query-upload.yaml new file mode 100644 index 0000000000..38302c57a7 --- /dev/null +++ b/crates/openshell-prover-cli/tests/fixtures/query-upload.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 +network_policies: + git: + binaries: + - path: /usr/bin/git + endpoints: + - host: github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /org/repo.git/info/refs + query: + service: "git-upload-pack" diff --git a/crates/openshell-prover/README.md b/crates/openshell-prover/README.md index 0026a04172..bda29e9902 100644 --- a/crates/openshell-prover/README.md +++ b/crates/openshell-prover/README.md @@ -24,7 +24,18 @@ endpoint host and path selectors, and REST allow and deny method and path selectors. It returns `unsupported_policy_shape` when either policy uses a non-ASCII literal in one of those fields. This boundary does not apply to filesystem paths or unrelated policy text. Embedded NUL bytes in network -selector fields are also unsupported. ASCII wildcards are modeled over the +selector fields are also unsupported. REST query keys and exact values must be +ASCII without NUL. Supported query matchers are exact strings and `*`; partial +globs and `any` matchers remain unsupported. The runtime treats `.` as a glob +delimiter, so `*` matches an empty value or `a/b`, but not `a.b`. Configured keys +must be present. All repeated values must match an allow constraint; any matching +value satisfies each configured deny constraint. Unconfigured keys are unrestricted. +The model separates wildcard-matching and nonmatching values, including in decoded +`query_params` counterexamples. It does not infer application-specific permissions. +At most 256 query matchers are admitted across both policies; their keys and values +count toward the existing pattern-byte limits. + +ASCII wildcards are modeled over the runtime match language and can therefore match non-ASCII runtime values. A solver string that cannot be decoded and validated exactly produces `invalid_witness` rather than counterexample evidence. diff --git a/crates/openshell-prover/src/containment.rs b/crates/openshell-prover/src/containment.rs index 86397f754c..7c2c7f0675 100644 --- a/crates/openshell-prover/src/containment.rs +++ b/crates/openshell-prover/src/containment.rs @@ -22,13 +22,16 @@ use std::time::{Duration, Instant}; use openshell_policy_schema::{ AccessPreset, FilesystemPolicy, L7Allow as Allow, L7DenyRule as DenyRule, LandlockPolicy, NetworkBinary as Binary, NetworkEndpoint as Endpoint, NetworkMiddleware, - NetworkPolicyRule as NetworkRule, PolicyDocument, ProcessPolicy, + NetworkPolicyRule as NetworkRule, PolicyDocument, ProcessPolicy, QueryMatcher, }; use z3::ast::{Ast, Bool, Int, Regexp, String as Z3String}; use z3::{Context, Params, SatResult, Solver}; mod execution; mod ip; +#[cfg(test)] +mod probe_tests; +mod query; const LAYER_L4: &str = "l4"; const LAYER_REST: &str = "rest"; @@ -39,6 +42,8 @@ const MAX_BINARIES: usize = 4_096; const MAX_PORT_ENTRIES: usize = 65_536; const MAX_L7_RULES: usize = 16_384; const MAX_IP_RANGES: usize = 4_096; +const MAX_QUERY_MATCHERS: usize = 256; +const MAX_CONCRETE_PROBES: usize = 64; const MAX_PATTERN_BYTES: usize = 4 * 1024; const MAX_TOTAL_PATTERN_BYTES: usize = 1024 * 1024; @@ -285,6 +290,8 @@ pub enum Counterexample { protocol: Protocol, method: Option, path: Option, + /// Decoded query values; repeated parameters retain distinct values. + query_params: BTreeMap>, }, } @@ -364,6 +371,7 @@ struct SymbolicAction { path: Z3String, ip: ip::SymbolicIp, trusted_gateway: Bool, + query: query::SymbolicQuery, } enum NetworkSolve { @@ -517,15 +525,29 @@ fn solve_network_mode( if network_is_structurally_contained(boundary, candidate, binary_identity_required) { return NetworkSolve::Within; } - if let Some(witness) = concrete_network_witness(boundary, candidate, binary_identity_required) { + if let Some(witness) = concrete_network_witness( + boundary, + candidate, + binary_identity_required, + started, + timeout, + cancelled, + ) { return NetworkSolve::Exceeds(witness); } + if cancelled.is_some_and(|flag| flag.load(Ordering::Relaxed)) { + return NetworkSolve::Incomplete(cancelled_result()); + } + if started.elapsed() >= timeout { + return NetworkSolve::Incomplete(solver_timeout_result()); + } let solver = Solver::new(); - let action = symbolic_action(if binary_identity_required { + let mut action = symbolic_action(if binary_identity_required { "strict_boundary_policy_action" } else { "relaxed_boundary_policy_action" }); + action.query = query::SymbolicQuery::new("boundary", boundary, candidate); assert_action_domain(&solver, &action, binary_identity_required); solver.assert( Bool::and(&[ @@ -592,7 +614,15 @@ fn concrete_network_witness( boundary: &ContainmentPolicy, candidate: &ContainmentPolicy, binary_identity_required: bool, + started: Instant, + timeout: Duration, + cancelled: Option<&AtomicBool>, ) -> Option { + let stopped = || { + cancelled.is_some_and(|flag| flag.load(Ordering::Relaxed)) || started.elapsed() >= timeout + }; + let mut rules_considered = 0; + let mut replays = 0; for (rule, endpoint) in candidate .network_policies .values() @@ -610,41 +640,59 @@ fn concrete_network_witness( |binary| binary.path.replace("**", "a").replace('*', "a"), ) }); - let method = endpoint - .rules - .first() - .map_or("GET", |rule| rule.allow.method.as_str()) - .to_ascii_uppercase(); - let path = endpoint + // Preset/L4 endpoints still get one unconstrained-query request. + for allow in endpoint .rules - .first() - .map_or(endpoint.path.as_str(), |rule| rule.allow.path.as_str()); - let path = if path.is_empty() { - "/".to_owned() - } else { - path.replace("**", "a").replace('*', "a") - }; - if !is_canonical_dns_host(&host) - || !is_canonical_rest_path(&path) - || !is_http_method(&method) + .iter() + .map(|rule| Some(&rule.allow)) + .chain(endpoint.rules.is_empty().then_some(None)) { - continue; - } - for destination_ip in ip::sample_addresses(endpoint) { - let witness = Counterexample::Network { - binary: binary.clone(), - ancestor_binary: binary.clone(), - binary_identity_required, - host: host.clone(), - destination_ip, - trusted_gateway: false, - port: endpoint.effective_ports()[0], - protocol: endpoint.protocol_kind(), - method: (endpoint.protocol_kind() == Protocol::Rest).then(|| method.clone()), - path: (endpoint.protocol_kind() == Protocol::Rest).then(|| path.clone()), + if rules_considered == MAX_CONCRETE_PROBES || stopped() { + return None; + } + rules_considered += 1; + let method = allow + .map_or("GET", |allow| allow.method.as_str()) + .to_ascii_uppercase(); + let path = allow.map_or(endpoint.path.as_str(), |allow| allow.path.as_str()); + let query_params = + allow.map_or_else(BTreeMap::new, |allow| query::sample(&allow.query)); + let path = if path.is_empty() { + "/".to_owned() + } else { + path.replace("**", "a").replace('*', "a") }; - if counterexample_satisfies_predicate(boundary, candidate, &witness) { - return Some(witness); + if !is_canonical_dns_host(&host) + || !is_canonical_rest_path(&path) + || !is_http_method(&method) + { + continue; + } + for destination_ip in ip::sample_addresses(endpoint) { + if replays == MAX_CONCRETE_PROBES || stopped() { + return None; + } + replays += 1; + let witness = Counterexample::Network { + binary: binary.clone(), + ancestor_binary: binary.clone(), + binary_identity_required, + host: host.clone(), + destination_ip, + trusted_gateway: false, + port: endpoint.effective_ports()[0], + protocol: endpoint.protocol_kind(), + method: (endpoint.protocol_kind() == Protocol::Rest).then(|| method.clone()), + path: (endpoint.protocol_kind() == Protocol::Rest).then(|| path.clone()), + query_params: query_params.clone(), + }; + let exceeds = counterexample_satisfies_predicate(boundary, candidate, &witness); + if stopped() { + return None; + } + if exceeds { + return Some(witness); + } } } } @@ -760,6 +808,7 @@ fn rest_endpoint_structurally_contains(boundary: &Endpoint, candidate: &Endpoint boundary.rules.iter().any(|boundary_rule| { method_pattern_contains(&boundary_rule.allow.method, &candidate_rule.allow.method) && path_pattern_contains(&boundary_rule.allow.path, &candidate_rule.allow.path) + && query::contains(&boundary_rule.allow.query, &candidate_rule.allow.query) }) }) } @@ -800,6 +849,7 @@ fn symbolic_action(name: &str) -> SymbolicAction { path: Z3String::new_const(format!("{name}_path")), ip: ip::SymbolicIp::new(name), trusted_gateway: Bool::new_const(format!("{name}_trusted_gateway")), + query: query::SymbolicQuery::default(), } } @@ -929,12 +979,12 @@ fn endpoint_denies(endpoint: &Endpoint, action: &SymbolicAction) -> Bool { endpoint_matches_connection(endpoint, action), action.layer.eq(LAYER_REST), endpoint_path_matches(endpoint, action), - bool_or( - endpoint - .deny_rules - .iter() - .map(|deny| method_and_path_match(&deny.method, &deny.path, action)), - ), + bool_or(endpoint.deny_rules.iter().map(|deny| { + Bool::and(&[ + method_and_path_match(&deny.method, &deny.path, action), + action.query.matches(&deny.query, true), + ]) + })), ]) } @@ -942,12 +992,12 @@ fn rest_endpoint_allows(endpoint: &Endpoint, action: &SymbolicAction) -> Bool { match AccessPreset::parse(&endpoint.access) { Some(AccessPreset::Full) => any_method_matches(action, "**"), Some(preset) => methods_match(action, preset.methods("rest"), "**"), - None => bool_or( - endpoint - .rules - .iter() - .map(|rule| method_and_path_match(&rule.allow.method, &rule.allow.path, action)), - ), + None => bool_or(endpoint.rules.iter().map(|rule| { + Bool::and(&[ + method_and_path_match(&rule.allow.method, &rule.allow.path, action), + action.query.matches(&rule.allow.query, false), + ]) + })), } } @@ -1054,6 +1104,11 @@ fn counterexample_from_model( protocol, method, path, + query_params: if protocol == Protocol::Rest { + action.query.decode(model)? + } else { + BTreeMap::new() + }, }) } @@ -1090,6 +1145,7 @@ fn counterexample_satisfies_predicate( protocol, method, path, + query_params, } = counterexample else { return false; @@ -1104,6 +1160,7 @@ fn counterexample_satisfies_predicate( path: Z3String::from_str(path.as_deref().unwrap_or("/")).unwrap(), ip: ip::SymbolicIp::concrete(*destination_ip), trusted_gateway: Bool::from_bool(*trusted_gateway), + query: query::SymbolicQuery::concrete(boundary, candidate, query_params), }; Bool::and(&[ policy_allows(candidate, &concrete, *binary_identity_required), @@ -1942,6 +1999,7 @@ fn resource_limit_reason( let mut port_entry_count = 0_usize; let mut l7_count = 0_usize; let mut ip_range_count = 0_usize; + let mut query_matcher_count = 0_usize; let mut total_pattern_bytes = 0_usize; for policy in policies { for path in policy @@ -2016,6 +2074,37 @@ fn resource_limit_reason( } } } + for rules in endpoint + .rules + .iter() + .map(|rule| &rule.allow.query) + .chain(endpoint.deny_rules.iter().map(|rule| &rule.query)) + { + query_matcher_count = query_matcher_count.saturating_add(rules.len()); + if query_matcher_count > MAX_QUERY_MATCHERS { + return Some(resource_limit_detail( + "query_matchers", + query_matcher_count, + MAX_QUERY_MATCHERS, + )); + } + for (key, matcher) in rules { + if let Some(reason) = account_pattern_bytes(key, &mut total_pattern_bytes) { + return Some(reason); + } + let patterns = match matcher { + QueryMatcher::Glob(value) => std::slice::from_ref(value), + QueryMatcher::Any(values) => values.any.as_slice(), + }; + for pattern in patterns { + if let Some(reason) = + account_pattern_bytes(pattern, &mut total_pattern_bytes) + { + return Some(reason); + } + } + } + } } } } @@ -2042,7 +2131,7 @@ fn resource_limit_detail(metric: &str, observed: usize, limit: usize) -> String fn unsupported_allow(rule: &Allow) -> bool { rule.method.is_empty() || !rule.command.is_empty() - || !rule.query.is_empty() + || !query::supported(&rule.query) || !rule.operation_type.is_empty() || !rule.operation_name.is_empty() || !rule.fields.is_empty() @@ -2056,7 +2145,7 @@ fn unsupported_allow(rule: &Allow) -> bool { fn unsupported_deny(rule: &DenyRule) -> bool { rule.method.is_empty() || !rule.command.is_empty() - || !rule.query.is_empty() + || !query::supported(&rule.query) || !rule.operation_type.is_empty() || !rule.operation_name.is_empty() || !rule.fields.is_empty() diff --git a/crates/openshell-prover/src/containment/probe_tests.rs b/crates/openshell-prover/src/containment/probe_tests.rs new file mode 100644 index 0000000000..68758378a3 --- /dev/null +++ b/crates/openshell-prover/src/containment/probe_tests.rs @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use serde_json::{Value, json}; +use std::collections::BTreeSet; + +fn fixtures() -> (Value, Value) { + ( + serde_json::from_str(include_str!( + "../../tests/fixtures/github-parent-discovery.json" + )) + .unwrap(), + serde_yml::from_str(include_str!("../../tests/fixtures/github-child-clone.yaml")).unwrap(), + ) +} + +fn parse(value: &Value) -> ContainmentPolicy { + parse_policy_str(&value.to_string()).unwrap() +} + +fn probe(parent: &Value, child: &Value) -> Option { + concrete_network_witness( + &parse(parent), + &parse(child), + false, + Instant::now(), + Duration::from_secs(5), + None, + ) +} + +fn check(parent: &Value, child: &Value) -> CheckResult { + check_within_boundary( + &parse(parent), + &parse(child), + CheckOptions::new(Duration::from_secs(5)), + ) +} + +#[test] +fn github_regression_all_rule_orders_find_a_replayed_violation() { + let (parent, original) = fixtures(); + let rules = original["network_policies"]["github_repository"]["endpoints"][0]["rules"] + .as_array() + .unwrap(); + let mut count = 0; + for a in 0..4 { + for b in 0..4 { + for c in 0..4 { + for d in 0..4 { + if BTreeSet::from([a, b, c, d]).len() != 4 { + continue; + } + let mut child = original.clone(); + child["network_policies"]["github_repository"]["endpoints"][0]["rules"] = + json!([rules[a], rules[b], rules[c], rules[d]]); + let witness = probe(&parent, &child) + .expect("bounded probes must find the GitHub expansion without SMT"); + assert!(counterexample_satisfies_predicate( + &parse(&parent), + &parse(&child), + &witness + )); + assert!(matches!(check(&parent, &child), CheckResult::Exceeds(_))); + count += 1; + } + } + } + } + assert_eq!(count, 24); +} + +#[test] +fn query_probe_contains_required_values_and_checks_later_rules() { + let (parent, mut child) = fixtures(); + let witness = probe(&parent, &child).unwrap(); + let Counterexample::Network { query_params, .. } = witness else { + panic!("network witness") + }; + assert_eq!(query_params["service"], ["git-upload-pack"]); + let rules = &mut child["network_policies"]["github_repository"]["endpoints"][0]["rules"]; + // Covered query-constrained discovery first, missing POST second. + *rules = json!([rules[2], rules[1]]); + let Counterexample::Network { method, .. } = probe(&parent, &child).unwrap() else { + panic!("network witness") + }; + assert_eq!(method.as_deref(), Some("POST")); +} + +#[test] +fn probe_replays_other_boundary_grants_and_candidate_denies() { + let (mut parent, mut child) = fixtures(); + parent["network_policies"]["other_grant"] = + child["network_policies"]["github_repository"].clone(); + assert!(probe(&parent, &child).is_none()); + assert!(matches!(check(&parent, &child), CheckResult::Within(_))); + parent["network_policies"] = json!({}); + let endpoint = &mut child["network_policies"]["github_repository"]["endpoints"][0]; + endpoint["deny_rules"] = json!( + endpoint["rules"] + .as_array() + .unwrap() + .iter() + .map(|rule| rule["allow"].clone()) + .collect::>() + ); + assert!( + probe(&parent, &child).is_none(), + "candidate denies must block sample requests" + ); + assert!(matches!(check(&parent, &child), CheckResult::Within(_))); +} + +#[test] +fn wildcard_seed_blocked_by_deny_falls_back_to_solver() { + let (mut parent, mut child) = fixtures(); + parent["network_policies"] = json!({}); + let endpoint = &mut child["network_policies"]["github_repository"]["endpoints"][0]; + endpoint["rules"] = + json!([{"allow":{"method":"GET", "path":"/info/refs", "query":{"service":"*"}}}]); + endpoint["deny_rules"] = json!([{"method":"GET", "path":"/info/refs", "query":{"service":""}}]); + assert!(probe(&parent, &child).is_none()); + assert!( + matches!(check(&parent, &child), CheckResult::Exceeds(_)), + "no sample is not proof of containment" + ); +} + +#[test] +fn probe_budget_exhaustion_falls_back_to_solver() { + let (mut parent, mut child) = fixtures(); + parent["network_policies"] + .as_object_mut() + .unwrap() + .remove("openshell_tool_service"); + parent["network_policies"]["github_pi_subagents_clone_discovery"]["endpoints"][0]["rules"][0] + ["allow"]["query"] = json!({"service":"a"}); + let endpoint = &mut child["network_policies"]["github_repository"]["endpoints"][0]; + let mut covered = endpoint["rules"][2].clone(); + covered["allow"]["query"] = json!({"service":"a"}); + let mut rules = vec![covered.clone(); MAX_CONCRETE_PROBES]; + covered["allow"]["query"] = json!({"service":"b"}); + rules.push(covered); + endpoint["rules"] = json!(rules); + assert!(probe(&parent, &child).is_none()); + assert!( + matches!(check(&parent, &child), CheckResult::Exceeds(_)), + "exhausting probes must not authorize" + ); +} + +#[test] +fn probes_stop_on_cancellation_and_expired_deadline() { + let (parent, child) = fixtures(); + let (parent, child) = (parse(&parent), parse(&child)); + let cancelled = AtomicBool::new(true); + assert!( + concrete_network_witness( + &parent, + &child, + false, + Instant::now(), + Duration::from_secs(5), + Some(&cancelled) + ) + .is_none() + ); + assert!( + concrete_network_witness(&parent, &child, false, Instant::now(), Duration::ZERO, None) + .is_none() + ); + assert!( + matches!(solve_network_mode(&parent, &child, false, Instant::now(), Duration::ZERO, Some(&cancelled)), NetworkSolve::Incomplete(CheckResult::Inconclusive(ref evidence)) if evidence.reason_code() == ReasonCode::Cancelled) + ); +} + +#[test] +fn probes_respect_endpoint_paths_and_destination_ips() { + let (mut parent, mut child) = fixtures(); + parent["network_policies"] = json!({}); + child["network_policies"]["github_repository"]["endpoints"][0]["path"] = + json!("/different-path"); + assert!(probe(&parent, &child).is_none()); + child["network_policies"]["github_repository"]["endpoints"][0] + .as_object_mut() + .unwrap() + .remove("path"); + child["network_policies"]["github_repository"]["endpoints"][0]["allowed_ips"] = + json!(["10.10.10.10/32"]); + let Counterexample::Network { destination_ip, .. } = probe(&parent, &child).unwrap() else { + panic!("network witness") + }; + assert_eq!(destination_ip.to_string(), "10.10.10.10"); +} diff --git a/crates/openshell-prover/src/containment/query.rs b/crates/openshell-prover/src/containment/query.rs new file mode 100644 index 0000000000..f9abb3fe4d --- /dev/null +++ b/crates/openshell-prover/src/containment/query.rs @@ -0,0 +1,368 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Exact finite abstraction of decoded REST query parameters for exact / `*` +//! matchers. Each key has one Boolean per literal mentioned by either policy, +//! plus separate wildcard-matching and nonmatching classes for other values. +//! The runtime's `glob.match(pattern, [], value)` uses `.` as a delimiter, +//! so `*` does not match dotted values. Multiple classes may be present: +//! runtime allow rules require ALL repeated values to match, while deny rules +//! require ANY value to match. Multiplicity and order do not affect either rule. +//! All-false represents an absent key. The REST parser never produces a present +//! key with an empty value list (a bare key instead has one empty-string value). + +use std::collections::{BTreeMap, BTreeSet}; + +use openshell_policy_schema::QueryMatcher; +use z3::ast::Bool; + +use super::{ContainmentPolicy, bool_or, unsupported_glob, unsupported_network_literal}; + +pub(super) type QueryRules = BTreeMap; +pub(super) type QueryValues = BTreeMap>; + +/// One satisfying decoded request for supported query constraints. This is a +/// heuristic seed only: full-policy replay must account for all other controls. +pub(super) fn sample(rules: &QueryRules) -> QueryValues { + rules + .iter() + .map(|(key, matcher)| { + let QueryMatcher::Glob(value) = matcher else { + unreachable!("query matchers must be validated before sampling") + }; + ( + key.clone(), + vec![if value == "*" { + String::new() + } else { + value.clone() + }], + ) + }) + .collect() +} + +#[derive(Default)] +pub(super) struct SymbolicQuery(BTreeMap); + +struct QueryKey { + literals: BTreeMap, + // Index 0 matches `*`; index 1 does not. + other: [Bool; 2], + other_values: [String; 2], +} + +fn wildcard_matches(value: &str) -> bool { + !value.contains('.') +} + +pub(super) fn supported(rules: &QueryRules) -> bool { + rules.iter().all(|(key, matcher)| { + unsupported_network_literal(key).is_none() + && matches!(matcher, QueryMatcher::Glob(value) + if unsupported_network_literal(value).is_none() + && (value == "*" || (!value.contains('*') && !unsupported_glob(value)))) + }) +} + +/// Sufficient implication check for the structural REST fast path. Both maps +/// have already passed shape validation. Extra candidate constraints narrow it. +pub(super) fn contains(boundary: &QueryRules, candidate: &QueryRules) -> bool { + boundary.iter().all(|(key, required)| { + candidate.get(key).is_some_and(|proposed| { + required == proposed + || (matches!(required, QueryMatcher::Glob(value) if value == "*") + && matches!(proposed, QueryMatcher::Glob(value) if wildcard_matches(value))) + }) + }) +} + +fn literals( + boundary: &ContainmentPolicy, + candidate: &ContainmentPolicy, +) -> BTreeMap> { + let mut keys: BTreeMap> = BTreeMap::new(); + for policy in [boundary, candidate] { + for endpoint in policy + .network_policies + .values() + .flat_map(|rule| &rule.endpoints) + { + for rules in endpoint + .rules + .iter() + .map(|rule| &rule.allow.query) + .chain(endpoint.deny_rules.iter().map(|rule| &rule.query)) + { + for (key, matcher) in rules { + let values = keys.entry(key.clone()).or_default(); + if let QueryMatcher::Glob(value) = matcher + && value != "*" + { + values.insert(value.clone()); + } + } + } + } + } + keys +} + +impl SymbolicQuery { + pub(super) fn new( + name: &str, + boundary: &ContainmentPolicy, + candidate: &ContainmentPolicy, + ) -> Self { + Self( + literals(boundary, candidate) + .into_iter() + .enumerate() + .map(|(key_index, (key, values))| { + let mut other_values = [String::new(), ".".to_owned()]; + for value in &mut other_values { + while values.contains(value) { + value.push('a'); + } + } + ( + key, + QueryKey { + literals: values + .into_iter() + .enumerate() + .map(|(value_index, value)| { + ( + value, + Bool::new_const(format!( + "{name}_query_{key_index}_{value_index}" + )), + ) + }) + .collect(), + other: std::array::from_fn(|class| { + Bool::new_const(format!("{name}_query_{key_index}_other_{class}")) + }), + other_values, + }, + ) + }) + .collect(), + ) + } + + pub(super) fn concrete( + boundary: &ContainmentPolicy, + candidate: &ContainmentPolicy, + values: &QueryValues, + ) -> Self { + let mut query = Self::new("concrete", boundary, candidate); + for (key, classes) in &mut query.0 { + let present = values.get(key).map_or(&[][..], Vec::as_slice); + classes.other = std::array::from_fn(|class| { + Bool::from_bool(present.iter().any(|value| { + !classes.literals.contains_key(value) + && usize::from(!wildcard_matches(value)) == class + })) + }); + for (value, flag) in &mut classes.literals { + *flag = Bool::from_bool(present.contains(value)); + } + } + query + } + + pub(super) fn matches(&self, rules: &QueryRules, deny: bool) -> Bool { + Bool::and( + &rules + .iter() + .map(|(key, matcher)| { + let classes = &self.0[key]; + let QueryMatcher::Glob(value) = matcher else { + unreachable!("query matchers must be validated before modeling") + }; + if value == "*" { + let matching = bool_or( + classes + .literals + .iter() + .filter(|(literal, _)| wildcard_matches(literal)) + .map(|(_, flag)| flag.clone()) + .chain([classes.other[0].clone()]), + ); + if deny { + matching + } else { + Bool::and(&[ + matching, + !bool_or( + classes + .literals + .iter() + .filter(|(literal, _)| !wildcard_matches(literal)) + .map(|(_, flag)| flag.clone()) + .chain([classes.other[1].clone()]), + ), + ]) + } + } else if deny { + classes.literals[value].clone() + } else { + Bool::and(&[ + classes.literals[value].clone(), + !bool_or(classes.other.iter().cloned()), + !bool_or( + classes + .literals + .iter() + .filter(|(literal, _)| *literal != value) + .map(|(_, flag)| flag.clone()), + ), + ]) + } + }) + .collect::>(), + ) + } + + pub(super) fn decode(&self, model: &z3::Model) -> Option { + let mut query = BTreeMap::new(); + for (key, classes) in &self.0 { + let mut values = Vec::new(); + for (literal, flag) in &classes.literals { + if model.eval(flag, true)?.as_bool()? { + values.push(literal.clone()); + } + } + for (flag, value) in classes.other.iter().zip(&classes.other_values) { + if model.eval(flag, true)?.as_bool()? { + values.push(value.clone()); + } + } + if !values.is_empty() { + query.insert(key.clone(), values); + } + } + Some(query) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use regorus::{Engine, Value}; + use serde_json::json; + use z3::ast::Ast; + + #[test] + fn symbolic_wildcard_preserves_mixed_other_values_in_witness() { + let policy = super::super::parse_policy_str( + r#"{"version":1,"network_policies":{"n":{"endpoints":[{ + "host":"example.com","port":443,"protocol":"rest","enforcement":"enforce", + "rules":[{"allow":{"method":"GET","path":"/**","query":{"q":"*"}}}] + }]}}}"#, + ) + .unwrap(); + let query = SymbolicQuery::new("mixed", &policy, &policy); + let rules = BTreeMap::from([("q".to_owned(), QueryMatcher::Glob("*".to_owned()))]); + let solver = z3::Solver::new(); + solver.assert(&query.0["q"].other[0]); + solver.assert(&query.0["q"].other[1]); + solver.assert(!query.matches(&rules, false)); + solver.assert(query.matches(&rules, true)); + assert_eq!(solver.check(), z3::SatResult::Sat); + let decoded = query.decode(&solver.get_model().unwrap()).unwrap(); + assert_eq!(decoded["q"], ["", "."]); + let concrete = SymbolicQuery::concrete(&policy, &policy, &decoded); + assert_eq!( + concrete.matches(&rules, false).simplify().as_bool(), + Some(false) + ); + assert_eq!( + concrete.matches(&rules, true).simplify().as_bool(), + Some(true) + ); + } + + #[test] + fn decoded_query_model_matches_runtime_for_missing_and_repeated_values() { + let mut engine = Engine::new(); + engine + .add_policy( + "runtime.rego".into(), + include_str!("../../../openshell-supervisor-network/data/sandbox-policy.rego") + .into(), + ) + .unwrap(); + engine + .add_policy( + "query-test.rego".into(), + r" +package query_test +import rego.v1 +allow := data.openshell.sandbox.query_params_match(input.request, input.rule) +deny := data.openshell.sandbox.deny_query_params_match(input.request, input.rule.allow) +" + .into(), + ) + .unwrap(); + for rules in [ + json!({}), + json!({"service":"*"}), + json!({"service":"a"}), + json!({"service":"a.b"}), + json!({"service":""}), + json!({"service":"a", "v":"2"}), + json!({"":"*"}), + json!({"service":"a b+c%&="}), + ] { + let policy = super::super::parse_policy_str(&json!({ + "version":1, + "network_policies":{"n":{"endpoints":[{ + "host":"example.com", "port":443, "protocol":"rest", "enforcement":"enforce", + "rules":[{"allow":{"method":"GET", "path":"/", "query":rules}}] + }]}} + }).to_string()).unwrap(); + let rules: QueryRules = serde_json::from_value(rules).unwrap(); + for values in [ + json!({}), + json!({"service":["a"]}), + json!({"service":["b"]}), + json!({"service":["a.b"]}), + json!({"service":["a", "a.b"]}), + json!({"service":["a.b", "a"]}), + json!({"service":["a.b", "a.b"]}), + json!({"service":["."]}), + json!({"service":["/", "a/b", "é", "\n"]}), + json!({"service":["a","a"]}), + json!({"service":["a","b"]}), + json!({"service":[""]}), + json!({"service":["","a"]}), + json!({"service":["a"],"v":["2"]}), + json!({"service":["a"],"v":["2","3"]}), + json!({"service":["a"],"other":["x"]}), + json!({"service":["é"]}), + json!({"service":["a b+c%&="]}), + json!({"":[""]}), + ] { + let query = SymbolicQuery::concrete( + &policy, + &policy, + &serde_json::from_value(values.clone()).unwrap(), + ); + engine.set_input_json(&json!({"request":{"query_params":values}, "rule":{"allow":{"query":rules}}}).to_string()).unwrap(); + for (deny, rule) in [ + (false, "data.query_test.allow"), + (true, "data.query_test.deny"), + ] { + let runtime = engine.eval_rule(rule.into()).unwrap() == Value::from(true); + assert_eq!( + query.matches(&rules, deny).simplify().as_bool(), + Some(runtime), + "rules={rules:?} values={values} deny={deny}" + ); + } + } + } + } +} diff --git a/crates/openshell-prover/tests/fixtures/github-child-clone.yaml b/crates/openshell-prover/tests/fixtures/github-child-clone.yaml new file mode 100644 index 0000000000..edf2659149 --- /dev/null +++ b/crates/openshell-prover/tests/fixtures/github-child-clone.yaml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /lib64 + - /proc + - /etc + - /app + - /opt + - /var/log + - /dev/urandom + read_write: + - /sandbox + - /tmp + - /dev/null + - /home/sandbox +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +network_policies: + github_repository: + name: github-repository-read-only + endpoints: + - host: github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /nicobailon/pi-subagents.git/info/refs + query: + service: git-upload-pack + - allow: + method: POST + path: /nicobailon/pi-subagents.git/git-upload-pack + - allow: + method: GET + path: /nicobailon/pi-subagents/info/refs + query: + service: git-upload-pack + - allow: + method: POST + path: /nicobailon/pi-subagents/git-upload-pack + binaries: + - path: /usr/bin/git + - path: /usr/local/bin/git diff --git a/crates/openshell-prover/tests/fixtures/github-parent-discovery.json b/crates/openshell-prover/tests/fixtures/github-parent-discovery.json new file mode 100644 index 0000000000..8ff696ea47 --- /dev/null +++ b/crates/openshell-prover/tests/fixtures/github-parent-discovery.json @@ -0,0 +1,109 @@ +{ + "filesystem_policy": { + "include_workdir": true, + "read_only": [ + "/usr", + "/lib", + "/lib64", + "/proc", + "/etc", + "/app", + "/opt", + "/var/log", + "/dev/urandom" + ], + "read_write": [ + "/sandbox", + "/tmp", + "/dev/null", + "/home/sandbox" + ] + }, + "landlock": { + "compatibility": "best_effort" + }, + "network_policies": { + "github_pi_subagents_clone_discovery": { + "binaries": [ + { + "path": "/usr/bin/git" + }, + { + "path": "/usr/local/bin/git" + } + ], + "endpoints": [ + { + "enforcement": "enforce", + "host": "github.com", + "port": 443, + "protocol": "rest", + "rules": [ + { + "allow": { + "method": "GET", + "path": "/nicobailon/pi-subagents/info/refs" + } + } + ] + } + ], + "name": "github_pi_subagents_clone_discovery" + }, + "openshell_tool_service": { + "binaries": [ + { + "path": "/usr/bin/pi" + }, + { + "path": "/usr/local/bin/pi" + }, + { + "path": "/usr/bin/node" + }, + { + "path": "/usr/local/bin/node" + }, + { + "path": "/usr/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" + }, + { + "path": "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" + } + ], + "endpoints": [ + { + "allowed_ips": [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + ], + "enforcement": "enforce", + "host": "host.openshell.internal", + "port": 8765, + "protocol": "rest", + "rules": [ + { + "allow": { + "method": "POST", + "path": "/v1/jobs" + } + }, + { + "allow": { + "method": "GET", + "path": "/v1/jobs/**" + } + } + ] + } + ], + "name": "openshell-tool-service" + } + }, + "process": { + "run_as_group": "sandbox", + "run_as_user": "sandbox" + }, + "version": 1 +} diff --git a/crates/openshell-prover/tests/query_containment.rs b/crates/openshell-prover/tests/query_containment.rs new file mode 100644 index 0000000000..9b2cbe6485 --- /dev/null +++ b/crates/openshell-prover/tests/query_containment.rs @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use openshell_prover::containment::{ + CheckOptions, CheckResult, Counterexample, ReasonCode, check_within_boundary, parse_policy_str, +}; +use serde_json::{Value, json}; +use std::time::Duration; + +fn policy(queries: &[Value], denies: &[Value]) -> String { + json!({ + "version": 1, + "network_policies": {"git": { + "binaries": [{"path": "/usr/bin/git"}], + "endpoints": [{ + "host": "github.com", "port": 443, + "protocol": "rest", "enforcement": "enforce", + "rules": queries.iter().map(|q| json!({"allow": { + "method": "GET", "path": "/org/repo.git/info/refs", "query": q + }})).collect::>(), + "deny_rules": denies.iter().map(|q| json!({ + "method": "GET", "path": "/org/repo.git/info/refs", "query": q + })).collect::>() + }] + }} + }) + .to_string() +} + +fn check(boundary: &str, candidate: &str) -> CheckResult { + check_within_boundary( + &parse_policy_str(boundary).unwrap(), + &parse_policy_str(candidate).unwrap(), + CheckOptions::new(Duration::from_secs(5)), + ) +} + +#[test] +fn exact_wildcard_and_required_keys() { + for (parent, child, within) in [ + (json!({"service":"*"}), json!({"service":"a.b"}), false), + (json!({"service":"*"}), json!({"service":"a/b"}), true), + ( + json!({"service":"git-upload-pack"}), + json!({"service":"git-upload-pack"}), + true, + ), + ( + json!({"service":"git-upload-pack"}), + json!({"service":"git-receive-pack"}), + false, + ), + ( + json!({"service":"git-receive-pack"}), + json!({"service":"git-upload-pack"}), + false, + ), + ( + json!({"service":"*"}), + json!({"service":"git-upload-pack"}), + true, + ), + ( + json!({"service":"git-upload-pack"}), + json!({"service":"*"}), + false, + ), + (json!({}), json!({"service":"git-upload-pack"}), true), + (json!({"service":"*"}), json!({}), false), + (json!({"service":"*"}), json!({"service":""}), true), + ( + json!({"service":""}), + json!({"service":"git-upload-pack"}), + false, + ), + ( + json!({"service":"git-upload-pack"}), + json!({"service":"git-upload-pack", "version":"2"}), + true, + ), + ( + json!({"service":"git-upload-pack", "version":"2"}), + json!({"service":"git-upload-pack"}), + false, + ), + ( + json!({"service":"git-upload-pack"}), + json!({"Service":"git-upload-pack"}), + false, + ), + ( + json!({"service":"git-upload-pack"}), + json!({"service":"GIT-UPLOAD-PACK"}), + false, + ), + (json!({"":"*"}), json!({"":""}), true), + (json!({"x":"*"}), json!({"x":"a b+c%&="}), true), + ] { + let result = check( + &policy(std::slice::from_ref(&parent), &[]), + &policy(std::slice::from_ref(&child), &[]), + ); + assert!( + if within { + matches!(result, CheckResult::Within(_)) + } else { + matches!(result, CheckResult::Exceeds(_)) + }, + "parent={parent}, child={child}: {result:?}" + ); + } +} + +#[test] +fn query_denies_and_allow_unions() { + for (parent, child, within) in [ + ( + policy(&[json!({})], &[json!({"service":"a.b"})]), + policy(&[json!({})], &[json!({"service":"*"})]), + false, + ), + ( + policy(&[json!({})], &[json!({"service":"git-receive-pack"})]), + policy(&[json!({"service":"git-upload-pack"})], &[]), + true, + ), + ( + policy(&[json!({})], &[json!({"service":"git-receive-pack"})]), + policy(&[json!({})], &[]), + false, + ), + ( + policy(&[json!({})], &[]), + policy(&[json!({})], &[json!({"service":"git-receive-pack"})]), + true, + ), + ( + policy(&[json!({})], &[json!({"service":"*"})]), + policy(&[json!({})], &[json!({"service":"git-receive-pack"})]), + false, + ), + ( + policy(&[json!({})], &[json!({"service":"git-receive-pack"})]), + policy(&[json!({})], &[json!({"service":"*"})]), + true, + ), + ( + policy( + &[ + json!({"service":"git-upload-pack"}), + json!({"service":"git-receive-pack"}), + ], + &[], + ), + policy(&[json!({"service":"git-upload-pack"})], &[]), + true, + ), + ( + policy(&[json!({})], &[json!({"service":"git-receive-pack"})]), + policy( + &[json!({})], + &[json!({"service":"git-receive-pack", "version":"2"})], + ), + false, + ), + ] { + let result = check(&parent, &child); + assert!( + if within { + matches!(result, CheckResult::Within(_)) + } else { + matches!(result, CheckResult::Exceeds(_)) + }, + "{result:?}" + ); + } +} + +#[test] +fn witness_preserves_query_values() { + let parent = policy(&[json!({"service":"git-upload-pack"})], &[]); + let child = policy(&[json!({"service":"git-receive-pack"})], &[]); + let CheckResult::Exceeds(evidence) = check(&parent, &child) else { + panic!("expected expansion") + }; + let Counterexample::Network { query_params, .. } = evidence.counterexample() else { + panic!("expected network witness") + }; + assert_eq!(query_params["service"], ["git-receive-pack"]); +} + +#[test] +fn unsupported_query_matchers_fail_closed_in_either_policy() { + let supported = policy(&[json!({})], &[]); + for matcher in [ + json!("git-*"), + json!("?"), + json!("[ab]"), + json!("{a,b}"), + json!("a\\b"), + json!("é"), + json!("\u{0000}"), + json!({"any":["a","b"]}), + ] { + for unsupported in [ + policy(&[json!({"service":matcher})], &[]), + policy(&[json!({})], &[json!({"service":matcher})]), + ] { + for (parent, child) in [ + (&unsupported, &supported), + (&supported, &unsupported), + (&unsupported, &unsupported), + ] { + let result = check(parent, child); + assert!(matches!(result, CheckResult::Unsupported(_)), "{result:?}"); + } + } + } + for key in ["é", "\u{0000}"] { + let unsupported = policy(&[json!({key: "*"})], &[]); + assert!(matches!( + check(&unsupported, &unsupported), + CheckResult::Unsupported(_) + )); + } +} + +#[test] +fn query_resources_are_bounded_before_equality_or_shape_shortcuts() { + let at_limit: serde_json::Map = + (0..128).map(|i| (format!("key{i}"), json!("*"))).collect(); + let at_limit = policy(&[Value::Object(at_limit)], &[]); + assert!(matches!( + check(&at_limit, &at_limit), + CheckResult::Within(_) + )); + let queries: serde_json::Map = + (0..129).map(|i| (format!("key{i}"), json!("*"))).collect(); + let oversized = policy(&[Value::Object(queries)], &[]); + let result = check(&oversized, &oversized); + assert!( + matches!(result, CheckResult::Inconclusive(ref e) if e.reason_code() == ReasonCode::ResourceLimit), + "{result:?}" + ); + for query in [ + json!({"key": "a".repeat(4097)}), + json!({"a".repeat(4097): "*"}), + json!({"key":{"any":["a".repeat(4097)]}}), + ] { + let oversized = policy(&[query], &[]); + let result = check(&oversized, &oversized); + assert!( + matches!(result, CheckResult::Inconclusive(ref e) if e.reason_code() == ReasonCode::ResourceLimit), + "{result:?}" + ); + } +} diff --git a/crates/openshell-prover/tests/runtime_parity.rs b/crates/openshell-prover/tests/runtime_parity.rs index 198198bdae..8a22e0e087 100644 --- a/crates/openshell-prover/tests/runtime_parity.rs +++ b/crates/openshell-prover/tests/runtime_parity.rs @@ -85,6 +85,80 @@ fn eval_array_len(engine: &mut Engine, input: &Value, rule: &str) -> usize { } } +#[test] +fn query_counterexamples_replay_against_runtime() { + let policy = |allow: serde_json::Value, deny: Option| { + json!({"version":1, "network_policies":{"n":{ + "binaries":[{"path":"/usr/bin/curl"}], + "endpoints":[{"host":"api.example.com", "ports":[443], + "protocol":"rest", "enforcement":"enforce", + "rules":[{"allow":{"method":"GET", "path":"/info/refs", "query":allow}}], + "deny_rules":deny.into_iter().map(|query| json!({"method":"GET", "path":"/info/refs", "query":query})).collect::>() + }] + }}}).to_string() + }; + for (boundary, candidate) in [ + ( + policy(json!({"service":"*"}), None), + policy(json!({"service":"a.b"}), None), + ), + ( + policy(json!({}), Some(json!({"service":"a.b"}))), + policy(json!({}), Some(json!({"service":"*"}))), + ), + ( + policy(json!({"service":"git-upload-pack"}), None), + policy(json!({"service":"git-receive-pack"}), None), + ), + ( + policy(json!({"service":"*"}), None), + policy(json!({}), None), + ), + ( + policy(json!({}), Some(json!({"service":"git-receive-pack"}))), + policy(json!({}), None), + ), + ( + policy(json!({"service":""}), None), + policy(json!({"service":"*"}), None), + ), + ] { + let result = check(&boundary, &candidate); + let CheckResult::Exceeds(evidence) = result else { + panic!("{result:?}") + }; + let Counterexample::Network { + binary, + ancestor_binary, + binary_identity_required, + host, + port, + method, + path, + query_params, + .. + } = evidence.counterexample() + else { + panic!("expected network witness") + }; + let input: Value = serde_json::from_value(json!({ + "exec":{"path":binary.as_deref().unwrap_or(""), "ancestors":ancestor_binary.iter().collect::>(), "cmdline_paths":[]}, + "network":{"host":host, "port":port}, + "request":{"method":method, "path":path, "query_params":query_params} + })).unwrap(); + assert!(eval_bool( + &mut runtime_engine_with_identity(&candidate, *binary_identity_required), + &input, + "data.openshell.sandbox.allow_request" + )); + assert!(!eval_bool( + &mut runtime_engine_with_identity(&boundary, *binary_identity_required), + &input, + "data.openshell.sandbox.allow_request" + )); + } +} + #[test] fn underscore_host_counterexample_replays_at_runtime() { let boundary = "version: 1\n"; diff --git a/docs/reference/policy-prover.mdx b/docs/reference/policy-prover.mdx index f34106c83a..a59e8bc7c1 100644 --- a/docs/reference/policy-prover.mdx +++ b/docs/reference/policy-prover.mdx @@ -121,7 +121,7 @@ The `counterexample.domain` field selects one of these objects: | `filesystem` | `access` (`read` or `write`) and `path`. | | `process` | `field` (`run_as_user` or `run_as_group`), `boundary`, and `candidate`. | | `landlock` | `boundary` and `candidate` compatibility modes. | -| `network` | `binary`, `ancestor_binary`, `binary_identity_required`, `host`, `destination_ip`, `trusted_gateway`, `port`, `protocol`, `method`, and `path`. | +| `network` | `binary`, `ancestor_binary`, `binary_identity_required`, `host`, `destination_ip`, `trusted_gateway`, `port`, `protocol`, `method`, `path`, and optional `query_params`. | Network `binary` and `ancestor_binary` values are `null` when binary identity enforcement is disabled. `method` and `path` are `null` for L4 witnesses. @@ -129,6 +129,10 @@ enforcement is disabled. `method` and `path` are `null` for L4 witnesses. with a runtime-provided trusted gateway binding; `false` uses ordinary destination validation. +`query_params` contains decoded query values as arrays, preserving distinct repeated +values. It is omitted when empty. Percent-encode keys and values when constructing +a request to replay the counterexample. + The stable reason codes are `invalid_input`, `unsupported_policy_shape`, `unresolved_workdir`, `unresolved_binary_path`, `unresolved_filesystem_path`, `solver_timeout`, `solver_unknown`, @@ -161,7 +165,7 @@ inconclusive results as failures in CI. The containment check covers filesystem paths, process identity settings, Landlock compatibility requirements, L4 destination authority, and enforced -REST method and path authority, including explicit REST denies. The result +REST method, path, and supported query constraints, including explicit REST denies. The result object reports the policy domains modeled by each check. Policies that use recognized authority outside that coverage return `unsupported` rather than silently ignoring it. @@ -175,13 +179,28 @@ containment model return `unsupported` and exit `3`. The prover applies aggregate limits across the candidate and boundary before semantic shape validation: 1,024 network rules, 4,096 endpoints, 4,096 binary selectors, 65,536 authored port entries, 4,096 `allowed_ips` entries, 16,384 -REST rules, 4 KiB per modeled pattern, and 1 MiB of modeled pattern text. +REST rules, 256 query matchers, 4 KiB per modeled pattern, and 1 MiB of modeled pattern text. +Query keys and values count toward the pattern-byte limits. These limits combine +both inputs: comparing identical policies with 129 query matchers exceeds the limit. Exceeding any limit returns `inconclusive` with `reason_code: resource_limit`. A cancellation already requested at preflight takes precedence over that result; otherwise a resource limit takes precedence over unsupported policy-shape diagnostics. This ordering keeps validation work bounded for checked-in CI inputs. +### REST query constraints + +Query keys and exact values must be ASCII without NUL. Exact strings and the whole +`*` wildcard are supported; partial globs and `any` matchers return `unsupported`. +The runtime uses `.` as a glob delimiter: `*` matches `a` and an empty value, but +not `a.b`. A boundary `q: "*"` therefore does not contain a candidate `q: "a.b"`. + +Every configured key must be present. All repeated values must match an allow +constraint; any matching value satisfies each configured deny constraint. Thus +`q=a&q=a.b` fails an allow `q: "*"` but matches a deny `q: "*"`. +Unconfigured keys are unrestricted. Comparisons use decoded values and do not +infer that one application operation includes another. + ### Process and Landlock settings Matching supported `run_as_user` and `run_as_group` values do not expand the