Skip to content

Commit 91abe12

Browse files
committed
fix(prover): match runtime query wildcard semantics
Signed-off-by: Kirit93 <kthadaka@nvidia.com>
1 parent 48e12bd commit 91abe12

6 files changed

Lines changed: 144 additions & 27 deletions

File tree

architecture/security-policy.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,10 @@ or decide whether an in-boundary change is eligible for automatic approval.
395395

396396
The containment model covers filesystem paths, supported process identities,
397397
Landlock compatibility requirements, L4 destinations including IP ranges, and
398-
enforced REST method and path authority. Identity comparisons assume consistent
398+
enforced REST method, path, and supported query-parameter authority. Query checks
399+
support exact ASCII values and `*` with the runtime's dot-delimited glob semantics,
400+
including missing and repeated parameters. Other query matchers remain unsupported.
401+
Identity comparisons assume consistent
399402
user and group resolution. Compatibility checks compare requested enforcement
400403
requirements, not the actual kernel state of a running sandbox.
401404
It returns explicit unsupported or inconclusive

crates/openshell-prover/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,18 @@ endpoint host and path selectors, and REST allow and deny method and path
2424
selectors. It returns `unsupported_policy_shape` when either policy uses a
2525
non-ASCII literal in one of those fields. This boundary does not apply to
2626
filesystem paths or unrelated policy text. Embedded NUL bytes in network
27-
selector fields are also unsupported. ASCII wildcards are modeled over the
27+
selector fields are also unsupported. REST query keys and exact values must be
28+
ASCII without NUL. Supported query matchers are exact strings and `*`; partial
29+
globs and `any` matchers remain unsupported. The runtime treats `.` as a glob
30+
delimiter, so `*` matches an empty value or `a/b`, but not `a.b`. Configured keys
31+
must be present. All repeated values must match an allow constraint; any matching
32+
value satisfies each configured deny constraint. Unconfigured keys are unrestricted.
33+
The model separates wildcard-matching and nonmatching values, including in decoded
34+
`query_params` counterexamples. It does not infer application-specific permissions.
35+
At most 256 query matchers are admitted across both policies; their keys and values
36+
count toward the existing pattern-byte limits.
37+
38+
ASCII wildcards are modeled over the
2839
runtime match language and can therefore match non-ASCII runtime values. A
2940
solver string that cannot be decoded and validated exactly produces
3041
`invalid_witness` rather than counterexample evidence.

crates/openshell-prover/src/containment/query.rs

Lines changed: 91 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
//! Exact finite abstraction of decoded REST query parameters for exact / `*`
55
//! matchers. Each key has one Boolean per literal mentioned by either policy,
6-
//! plus one shared class for all other values. Multiple classes may be present:
6+
//! plus separate wildcard-matching and nonmatching classes for other values.
7+
//! The runtime's `glob.match(pattern, [], value)` uses `.` as a delimiter,
8+
//! so `*` does not match dotted values. Multiple classes may be present:
79
//! runtime allow rules require ALL repeated values to match, while deny rules
810
//! require ANY value to match. Multiplicity and order do not affect either rule.
911
//! All-false represents an absent key. The REST parser never produces a present
@@ -45,8 +47,13 @@ pub(super) struct SymbolicQuery(BTreeMap<String, QueryKey>);
4547

4648
struct QueryKey {
4749
literals: BTreeMap<String, Bool>,
48-
other: Bool,
49-
other_value: String,
50+
// Index 0 matches `*`; index 1 does not.
51+
other: [Bool; 2],
52+
other_values: [String; 2],
53+
}
54+
55+
fn wildcard_matches(value: &str) -> bool {
56+
!value.contains('.')
5057
}
5158

5259
pub(super) fn supported(rules: &QueryRules) -> bool {
@@ -63,7 +70,9 @@ pub(super) fn supported(rules: &QueryRules) -> bool {
6370
pub(super) fn contains(boundary: &QueryRules, candidate: &QueryRules) -> bool {
6471
boundary.iter().all(|(key, required)| {
6572
candidate.get(key).is_some_and(|proposed| {
66-
required == proposed || matches!(required, QueryMatcher::Glob(value) if value == "*")
73+
required == proposed
74+
|| (matches!(required, QueryMatcher::Glob(value) if value == "*")
75+
&& matches!(proposed, QueryMatcher::Glob(value) if wildcard_matches(value)))
6776
})
6877
})
6978
}
@@ -110,9 +119,11 @@ impl SymbolicQuery {
110119
.into_iter()
111120
.enumerate()
112121
.map(|(key_index, (key, values))| {
113-
let mut other_value = String::new();
114-
while values.contains(&other_value) {
115-
other_value.push('a');
122+
let mut other_values = [String::new(), ".".to_owned()];
123+
for value in &mut other_values {
124+
while values.contains(value) {
125+
value.push('a');
126+
}
116127
}
117128
(
118129
key,
@@ -129,8 +140,10 @@ impl SymbolicQuery {
129140
)
130141
})
131142
.collect(),
132-
other: Bool::new_const(format!("{name}_query_{key_index}_other")),
133-
other_value,
143+
other: std::array::from_fn(|class| {
144+
Bool::new_const(format!("{name}_query_{key_index}_other_{class}"))
145+
}),
146+
other_values,
134147
},
135148
)
136149
})
@@ -146,11 +159,12 @@ impl SymbolicQuery {
146159
let mut query = Self::new("concrete", boundary, candidate);
147160
for (key, classes) in &mut query.0 {
148161
let present = values.get(key).map_or(&[][..], Vec::as_slice);
149-
classes.other = Bool::from_bool(
150-
present
151-
.iter()
152-
.any(|value| !classes.literals.contains_key(value)),
153-
);
162+
classes.other = std::array::from_fn(|class| {
163+
Bool::from_bool(present.iter().any(|value| {
164+
!classes.literals.contains_key(value)
165+
&& usize::from(!wildcard_matches(value)) == class
166+
}))
167+
});
154168
for (value, flag) in &mut classes.literals {
155169
*flag = Bool::from_bool(present.contains(value));
156170
}
@@ -168,19 +182,35 @@ impl SymbolicQuery {
168182
unreachable!("query matchers must be validated before modeling")
169183
};
170184
if value == "*" {
171-
bool_or(
185+
let matching = bool_or(
172186
classes
173187
.literals
174-
.values()
175-
.cloned()
176-
.chain([classes.other.clone()]),
177-
)
188+
.iter()
189+
.filter(|(literal, _)| wildcard_matches(literal))
190+
.map(|(_, flag)| flag.clone())
191+
.chain([classes.other[0].clone()]),
192+
);
193+
if deny {
194+
matching
195+
} else {
196+
Bool::and(&[
197+
matching,
198+
!bool_or(
199+
classes
200+
.literals
201+
.iter()
202+
.filter(|(literal, _)| !wildcard_matches(literal))
203+
.map(|(_, flag)| flag.clone())
204+
.chain([classes.other[1].clone()]),
205+
),
206+
])
207+
}
178208
} else if deny {
179209
classes.literals[value].clone()
180210
} else {
181211
Bool::and(&[
182212
classes.literals[value].clone(),
183-
!classes.other.clone(),
213+
!bool_or(classes.other.iter().cloned()),
184214
!bool_or(
185215
classes
186216
.literals
@@ -204,8 +234,10 @@ impl SymbolicQuery {
204234
values.push(literal.clone());
205235
}
206236
}
207-
if model.eval(&classes.other, true)?.as_bool()? {
208-
values.push(classes.other_value.clone());
237+
for (flag, value) in classes.other.iter().zip(&classes.other_values) {
238+
if model.eval(flag, true)?.as_bool()? {
239+
values.push(value.clone());
240+
}
209241
}
210242
if !values.is_empty() {
211243
query.insert(key.clone(), values);
@@ -222,6 +254,36 @@ mod tests {
222254
use serde_json::json;
223255
use z3::ast::Ast;
224256

257+
#[test]
258+
fn symbolic_wildcard_preserves_mixed_other_values_in_witness() {
259+
let policy = super::super::parse_policy_str(
260+
r#"{"version":1,"network_policies":{"n":{"endpoints":[{
261+
"host":"example.com","port":443,"protocol":"rest","enforcement":"enforce",
262+
"rules":[{"allow":{"method":"GET","path":"/**","query":{"q":"*"}}}]
263+
}]}}}"#,
264+
)
265+
.unwrap();
266+
let query = SymbolicQuery::new("mixed", &policy, &policy);
267+
let rules = BTreeMap::from([("q".to_owned(), QueryMatcher::Glob("*".to_owned()))]);
268+
let solver = z3::Solver::new();
269+
solver.assert(&query.0["q"].other[0]);
270+
solver.assert(&query.0["q"].other[1]);
271+
solver.assert(!query.matches(&rules, false));
272+
solver.assert(query.matches(&rules, true));
273+
assert_eq!(solver.check(), z3::SatResult::Sat);
274+
let decoded = query.decode(&solver.get_model().unwrap()).unwrap();
275+
assert_eq!(decoded["q"], ["", "."]);
276+
let concrete = SymbolicQuery::concrete(&policy, &policy, &decoded);
277+
assert_eq!(
278+
concrete.matches(&rules, false).simplify().as_bool(),
279+
Some(false)
280+
);
281+
assert_eq!(
282+
concrete.matches(&rules, true).simplify().as_bool(),
283+
Some(true)
284+
);
285+
}
286+
225287
#[test]
226288
fn decoded_query_model_matches_runtime_for_missing_and_repeated_values() {
227289
let mut engine = Engine::new();
@@ -248,6 +310,7 @@ deny := data.openshell.sandbox.deny_query_params_match(input.request, input.rule
248310
json!({}),
249311
json!({"service":"*"}),
250312
json!({"service":"a"}),
313+
json!({"service":"a.b"}),
251314
json!({"service":""}),
252315
json!({"service":"a", "v":"2"}),
253316
json!({"":"*"}),
@@ -265,6 +328,12 @@ deny := data.openshell.sandbox.deny_query_params_match(input.request, input.rule
265328
json!({}),
266329
json!({"service":["a"]}),
267330
json!({"service":["b"]}),
331+
json!({"service":["a.b"]}),
332+
json!({"service":["a", "a.b"]}),
333+
json!({"service":["a.b", "a"]}),
334+
json!({"service":["a.b", "a.b"]}),
335+
json!({"service":["."]}),
336+
json!({"service":["/", "a/b", "é", "\n"]}),
268337
json!({"service":["a","a"]}),
269338
json!({"service":["a","b"]}),
270339
json!({"service":[""]}),

crates/openshell-prover/tests/query_containment.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ fn check(boundary: &str, candidate: &str) -> CheckResult {
3838
#[test]
3939
fn exact_wildcard_and_required_keys() {
4040
for (parent, child, within) in [
41+
(json!({"service":"*"}), json!({"service":"a.b"}), false),
42+
(json!({"service":"*"}), json!({"service":"a/b"}), true),
4143
(
4244
json!({"service":"git-upload-pack"}),
4345
json!({"service":"git-upload-pack"}),
@@ -112,6 +114,11 @@ fn exact_wildcard_and_required_keys() {
112114
#[test]
113115
fn query_denies_and_allow_unions() {
114116
for (parent, child, within) in [
117+
(
118+
policy(&[json!({})], &[json!({"service":"a.b"})]),
119+
policy(&[json!({})], &[json!({"service":"*"})]),
120+
false,
121+
),
115122
(
116123
policy(&[json!({})], &[json!({"service":"git-receive-pack"})]),
117124
policy(&[json!({"service":"git-upload-pack"})], &[]),

crates/openshell-prover/tests/runtime_parity.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ fn query_counterexamples_replay_against_runtime() {
9898
}}}).to_string()
9999
};
100100
for (boundary, candidate) in [
101+
(
102+
policy(json!({"service":"*"}), None),
103+
policy(json!({"service":"a.b"}), None),
104+
),
105+
(
106+
policy(json!({}), Some(json!({"service":"a.b"}))),
107+
policy(json!({}), Some(json!({"service":"*"}))),
108+
),
101109
(
102110
policy(json!({"service":"git-upload-pack"}), None),
103111
policy(json!({"service":"git-receive-pack"}), None),

docs/reference/policy-prover.mdx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,18 @@ The `counterexample.domain` field selects one of these objects:
121121
| `filesystem` | `access` (`read` or `write`) and `path`. |
122122
| `process` | `field` (`run_as_user` or `run_as_group`), `boundary`, and `candidate`. |
123123
| `landlock` | `boundary` and `candidate` compatibility modes. |
124-
| `network` | `binary`, `ancestor_binary`, `binary_identity_required`, `host`, `destination_ip`, `trusted_gateway`, `port`, `protocol`, `method`, and `path`. |
124+
| `network` | `binary`, `ancestor_binary`, `binary_identity_required`, `host`, `destination_ip`, `trusted_gateway`, `port`, `protocol`, `method`, `path`, and optional `query_params`. |
125125

126126
Network `binary` and `ancestor_binary` values are `null` when binary identity
127127
enforcement is disabled. `method` and `path` are `null` for L4 witnesses.
128128
`trusted_gateway: true` means the witness uses a recognized host-gateway alias
129129
with a runtime-provided trusted gateway binding; `false` uses ordinary
130130
destination validation.
131131

132+
`query_params` contains decoded query values as arrays, preserving distinct repeated
133+
values. It is omitted when empty. Percent-encode keys and values when constructing
134+
a request to replay the counterexample.
135+
132136
The stable reason codes are `invalid_input`, `unsupported_policy_shape`,
133137
`unresolved_workdir`, `unresolved_binary_path`,
134138
`unresolved_filesystem_path`, `solver_timeout`, `solver_unknown`,
@@ -161,7 +165,7 @@ inconclusive results as failures in CI.
161165

162166
The containment check covers filesystem paths, process identity settings,
163167
Landlock compatibility requirements, L4 destination authority, and enforced
164-
REST method and path authority, including explicit REST denies. The result
168+
REST method, path, and supported query constraints, including explicit REST denies. The result
165169
object reports the policy domains modeled by each check. Policies that use
166170
recognized authority outside that coverage return `unsupported` rather than
167171
silently ignoring it.
@@ -175,13 +179,28 @@ containment model return `unsupported` and exit `3`.
175179
The prover applies aggregate limits across the candidate and boundary before
176180
semantic shape validation: 1,024 network rules, 4,096 endpoints, 4,096 binary
177181
selectors, 65,536 authored port entries, 4,096 `allowed_ips` entries, 16,384
178-
REST rules, 4 KiB per modeled pattern, and 1 MiB of modeled pattern text.
182+
REST rules, 256 query matchers, 4 KiB per modeled pattern, and 1 MiB of modeled pattern text.
183+
Query keys and values count toward the pattern-byte limits. These limits combine
184+
both inputs: comparing identical policies with 129 query matchers exceeds the limit.
179185
Exceeding any limit returns `inconclusive` with `reason_code: resource_limit`.
180186
A cancellation already requested at preflight takes precedence over that
181187
result; otherwise a resource limit takes precedence over unsupported
182188
policy-shape diagnostics. This ordering keeps validation work bounded for
183189
checked-in CI inputs.
184190

191+
### REST query constraints
192+
193+
Query keys and exact values must be ASCII without NUL. Exact strings and the whole
194+
`*` wildcard are supported; partial globs and `any` matchers return `unsupported`.
195+
The runtime uses `.` as a glob delimiter: `*` matches `a` and an empty value, but
196+
not `a.b`. A boundary `q: "*"` therefore does not contain a candidate `q: "a.b"`.
197+
198+
Every configured key must be present. All repeated values must match an allow
199+
constraint; any matching value satisfies each configured deny constraint. Thus
200+
`q=a&q=a.b` fails an allow `q: "*"` but matches a deny `q: "*"`.
201+
Unconfigured keys are unrestricted. Comparisons use decoded values and do not
202+
infer that one application operation includes another.
203+
185204
### Process and Landlock settings
186205

187206
Matching supported `run_as_user` and `run_as_group` values do not expand the

0 commit comments

Comments
 (0)