Skip to content

Commit 5bce19a

Browse files
johnnygrecokirit93
andauthored
feat(prover): check process, Landlock, and destination IP containment (#3394)
* feat(prover): check process, landlock, and destination IP containment Signed-off-by: Kirit93 <kthadaka@nvidia.com> Signed-off-by: Johnny Greco <jogreco@nvidia.com> * fix(prover): reject ambiguous implicit IP modes Signed-off-by: Johnny Greco <jogreco@nvidia.com> * fix(prover): validate implicit IP modes policy-wide Signed-off-by: Johnny Greco <jogreco@nvidia.com> * fix(prover): tighten containment edge handling Signed-off-by: Johnny Greco <jogreco@nvidia.com> * test(prover): cover boundary v2 evidence shapes Signed-off-by: Johnny Greco <jogreco@nvidia.com> * docs(prover): document boundary v2 evidence limits Signed-off-by: Johnny Greco <jogreco@nvidia.com> * refactor(prover): simplify result version contract Signed-off-by: Johnny Greco <jogreco@nvidia.com> * refactor(prover): clarify coverage terminology Signed-off-by: Johnny Greco <jogreco@nvidia.com> --------- Signed-off-by: Kirit93 <kthadaka@nvidia.com> Signed-off-by: Johnny Greco <jogreco@nvidia.com> Co-authored-by: Kirit93 <kthadaka@nvidia.com>
1 parent 17ce738 commit 5bce19a

15 files changed

Lines changed: 1357 additions & 154 deletions

File tree

architecture/security-policy.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -389,15 +389,20 @@ final enforcement boundary.
389389

390390
The standalone `openshell-prover check` command compares a fully composed local
391391
candidate policy with an operator-supplied local boundary. It establishes
392-
`Allowed(candidate) ⊆ Allowed(boundary)` for the model scope reported in its
392+
`Allowed(candidate) ⊆ Allowed(boundary)` for the policy domains reported in its
393393
result. It does not fetch gateway state, compose provider rules, apply policy,
394394
or decide whether an in-boundary change is eligible for automatic approval.
395395

396-
The initial model covers filesystem paths, L4 network authority, and enforced
397-
REST method and path authority. It returns explicit unsupported or inconclusive
396+
The containment model covers filesystem paths, supported process identities,
397+
Landlock compatibility requirements, L4 destinations including IP ranges, and
398+
enforced REST method and path authority. Identity comparisons assume consistent
399+
user and group resolution. Compatibility checks compare requested enforcement
400+
requirements, not the actual kernel state of a running sandbox.
401+
It returns explicit unsupported or inconclusive
398402
results when a sound decision depends on authority or runtime context outside
399-
the model. The result records the model version and covered domains so callers
400-
can bind a successful check to those semantics.
403+
the model. The result records the covered domains so callers can require the
404+
authority relevant to their decision. The JSON `schema_version` versions the
405+
result contract, while `prover_version` identifies the producing implementation.
401406

402407
Before semantic validation, the checker observes cancellation and applies
403408
aggregate limits across both inputs. Oversized checks therefore return
@@ -409,8 +414,8 @@ The Rust containment API has an explicit extensibility contract: options and
409414
modeled-domain evidence permit additive growth, while the four `CheckResult`
410415
states remain exhaustive and authorization accepts only `Within`. This Rust
411416
source-compatibility boundary is separate from the CLI JSON schema and the
412-
reported containment model version. See the `openshell-prover` crate README for
413-
the supported construction and matching patterns.
417+
reported modeled domains. See the `openshell-prover` crate README for the
418+
supported construction and matching patterns.
414419

415420
This containment operation is separate from the proposal-risk queries below.
416421
See the [standalone policy prover documentation](../docs/reference/policy-prover.mdx)

crates/openshell-prover-cli/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,9 @@ cargo test -p openshell-prover-cli
3232
```
3333

3434
See the [policy prover reference](../../docs/reference/policy-prover.mdx) for installed usage and interpretation guidance.
35+
36+
JSON output uses a numeric `schema_version` for the result contract and a
37+
`prover_version` for the implementation that produced it. Consumers must inspect
38+
`coverage.domains` for the machine-readable modeled-domain declaration. A
39+
passing check compares configuration under the documented assumptions; it does
40+
not attest that a running sandbox installed its restrictions.

crates/openshell-prover-cli/src/main.rs

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::time::Duration;
1616

1717
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
1818
use openshell_prover::containment::{
19-
CheckOptions, CheckResult, CheckScope, ContainmentPolicy, Counterexample, parse_policy_str,
19+
CheckCoverage, CheckOptions, CheckResult, ContainmentPolicy, Counterexample, parse_policy_str,
2020
};
2121
use serde::Serialize;
2222

@@ -62,7 +62,7 @@ struct Envelope<'a> {
6262
schema_version: u32,
6363
prover_version: &'static str,
6464
check: &'static str,
65-
scope: Option<ScopeJson<'a>>,
65+
coverage: Option<CoverageJson<'a>>,
6666
result: &'static str,
6767
exit_code: u8,
6868
inputs: InputsJson,
@@ -72,9 +72,7 @@ struct Envelope<'a> {
7272
}
7373

7474
#[derive(Debug, Serialize)]
75-
struct ScopeJson<'a> {
76-
model_version: &'a str,
77-
policy_version: u32,
75+
struct CoverageJson<'a> {
7876
domains: Vec<&'a str>,
7977
}
8078

@@ -87,6 +85,15 @@ struct InputsJson {
8785
#[derive(Debug, Serialize)]
8886
#[serde(tag = "domain", rename_all = "snake_case")]
8987
enum CounterexampleJson<'a> {
88+
Process {
89+
field: &'a str,
90+
boundary: &'a str,
91+
candidate: &'a str,
92+
},
93+
Landlock {
94+
boundary: &'a str,
95+
candidate: &'a str,
96+
},
9097
Filesystem {
9198
access: &'a str,
9299
path: &'a str,
@@ -96,6 +103,8 @@ enum CounterexampleJson<'a> {
96103
ancestor_binary: Option<&'a str>,
97104
binary_identity_required: bool,
98105
host: &'a str,
106+
destination_ip: String,
107+
trusted_gateway: bool,
99108
port: u16,
100109
protocol: &'a str,
101110
method: Option<&'a str>,
@@ -284,7 +293,7 @@ fn render_input_error(
284293
schema_version: 1,
285294
prover_version: env!("CARGO_PKG_VERSION"),
286295
check: "boundary",
287-
scope: None,
296+
coverage: None,
288297
result: "error",
289298
exit_code: 2,
290299
inputs,
@@ -308,18 +317,20 @@ fn render_cancelled(output: OutputFormat, inputs: InputsJson) -> Result<u8, Stri
308317
}
309318

310319
fn result_envelope(result: &CheckResult, inputs: InputsJson) -> Result<Envelope<'_>, String> {
311-
let (scope, result_name, exit_code, counterexample, reason_code, reason) = match result {
312-
CheckResult::Within(evidence) => (evidence.scope(), "within_boundary", 0, None, None, None),
320+
let (coverage, result_name, exit_code, counterexample, reason_code, reason) = match result {
321+
CheckResult::Within(evidence) => {
322+
(evidence.coverage(), "within_boundary", 0, None, None, None)
323+
}
313324
CheckResult::Exceeds(evidence) => (
314-
evidence.scope(),
325+
evidence.coverage(),
315326
"exceeds_boundary",
316327
1,
317328
Some(counterexample_json(evidence.counterexample())?),
318329
None,
319330
None,
320331
),
321332
CheckResult::Unsupported(evidence) => (
322-
evidence.scope(),
333+
evidence.coverage(),
323334
"unsupported",
324335
3,
325336
None,
@@ -334,7 +345,7 @@ fn result_envelope(result: &CheckResult, inputs: InputsJson) -> Result<Envelope<
334345
3
335346
};
336347
(
337-
evidence.scope(),
348+
evidence.coverage(),
338349
"inconclusive",
339350
exit_code,
340351
None,
@@ -347,7 +358,7 @@ fn result_envelope(result: &CheckResult, inputs: InputsJson) -> Result<Envelope<
347358
schema_version: 1,
348359
prover_version: env!("CARGO_PKG_VERSION"),
349360
check: "boundary",
350-
scope: Some(scope_json(scope)),
361+
coverage: Some(coverage_json(coverage)),
351362
result: result_name,
352363
exit_code,
353364
inputs,
@@ -357,16 +368,36 @@ fn result_envelope(result: &CheckResult, inputs: InputsJson) -> Result<Envelope<
357368
})
358369
}
359370

360-
fn scope_json(scope: &CheckScope) -> ScopeJson<'_> {
361-
ScopeJson {
362-
model_version: scope.model_version,
363-
policy_version: scope.policy_version,
364-
domains: scope.domains.iter().map(|domain| domain.as_str()).collect(),
371+
fn coverage_json(coverage: &CheckCoverage) -> CoverageJson<'_> {
372+
CoverageJson {
373+
domains: coverage
374+
.domains
375+
.iter()
376+
.map(|domain| domain.as_str())
377+
.collect(),
365378
}
366379
}
367380

368381
fn counterexample_json(counterexample: &Counterexample) -> Result<CounterexampleJson<'_>, String> {
369382
let converted = match counterexample {
383+
Counterexample::Process {
384+
field,
385+
boundary,
386+
candidate,
387+
..
388+
} => CounterexampleJson::Process {
389+
field,
390+
boundary,
391+
candidate,
392+
},
393+
Counterexample::Landlock {
394+
boundary,
395+
candidate,
396+
..
397+
} => CounterexampleJson::Landlock {
398+
boundary,
399+
candidate,
400+
},
370401
Counterexample::Filesystem { access, path, .. } => CounterexampleJson::Filesystem {
371402
access: access.as_str(),
372403
path,
@@ -376,6 +407,8 @@ fn counterexample_json(counterexample: &Counterexample) -> Result<Counterexample
376407
ancestor_binary,
377408
binary_identity_required,
378409
host,
410+
destination_ip,
411+
trusted_gateway,
379412
port,
380413
protocol,
381414
method,
@@ -386,6 +419,8 @@ fn counterexample_json(counterexample: &Counterexample) -> Result<Counterexample
386419
ancestor_binary: ancestor_binary.as_deref(),
387420
binary_identity_required: *binary_identity_required,
388421
host,
422+
destination_ip: destination_ip.to_string(),
423+
trusted_gateway: *trusted_gateway,
389424
port: *port,
390425
protocol: protocol.as_str(),
391426
method: method.as_deref(),
@@ -412,18 +447,14 @@ fn render(output: OutputFormat, envelope: &Envelope<'_>) -> Result<(), String> {
412447
fn render_text(mut writer: impl Write, envelope: &Envelope<'_>) -> Result<(), String> {
413448
writeln!(writer, "result: {}", envelope.result)
414449
.map_err(|error| format!("failed to write output: {error}"))?;
415-
if let Some(scope) = &envelope.scope {
416-
writeln!(
417-
writer,
418-
"scope: model={} policy={} domains={}",
419-
escape_terminal(scope.model_version),
420-
scope.policy_version,
421-
scope.domains.join(",")
422-
)
423-
.map_err(|error| format!("failed to write output: {error}"))?;
450+
if let Some(coverage) = &envelope.coverage {
451+
writeln!(writer, "coverage: domains={}", coverage.domains.join(","))
452+
.map_err(|error| format!("failed to write output: {error}"))?;
424453
}
425454
if let Some(counterexample) = &envelope.counterexample {
426455
match counterexample {
456+
CounterexampleJson::Process { field, boundary, candidate } => writeln!(writer, "counterexample: process {} boundary={} candidate={}", escape_terminal(field), escape_terminal(boundary), escape_terminal(candidate)),
457+
CounterexampleJson::Landlock { boundary, candidate } => writeln!(writer, "counterexample: landlock boundary={} candidate={}", escape_terminal(boundary), escape_terminal(candidate)),
427458
CounterexampleJson::Filesystem { access, path } => writeln!(
428459
writer,
429460
"counterexample: filesystem {access} {}",
@@ -434,18 +465,22 @@ fn render_text(mut writer: impl Write, envelope: &Envelope<'_>) -> Result<(), St
434465
ancestor_binary,
435466
binary_identity_required,
436467
host,
468+
destination_ip,
469+
trusted_gateway,
437470
port,
438471
protocol,
439472
method,
440473
path,
441474
} => writeln!(
442475
writer,
443-
"counterexample: network binary={} ancestor_binary={} binary_identity_required={} host={}:{} protocol={} method={} path={}",
476+
"counterexample: network binary={} ancestor_binary={} binary_identity_required={} host={}:{} destination_ip={} trusted_gateway={} protocol={} method={} path={}",
444477
binary.map_or("-".to_owned(), escape_terminal),
445478
ancestor_binary.map_or("-".to_owned(), escape_terminal),
446479
binary_identity_required,
447480
escape_terminal(host),
448481
port,
482+
destination_ip,
483+
trusted_gateway,
449484
escape_terminal(protocol),
450485
method.map_or("-".to_owned(), escape_terminal),
451486
path.map_or("-".to_owned(), escape_terminal),
@@ -557,7 +592,7 @@ mod tests {
557592
schema_version: 1,
558593
prover_version: env!("CARGO_PKG_VERSION"),
559594
check: "boundary",
560-
scope: None,
595+
coverage: None,
561596
result: "within_boundary",
562597
exit_code: 0,
563598
inputs: InputsJson {

crates/openshell-prover-cli/tests/cli.rs

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,40 @@ fn contained_policy_returns_stable_json_and_zero() {
7878
assert_eq!(value["result"], "within_boundary");
7979
assert_eq!(value["exit_code"], 0);
8080
assert_eq!(
81-
value["scope"],
81+
value["coverage"],
8282
serde_json::json!({
83-
"model_version": "boundary-v1",
84-
"policy_version": 1,
85-
"domains": ["filesystem", "network_l4", "network_rest"]
83+
"domains": ["filesystem", "network_l4", "network_rest", "process", "landlock"]
8684
})
8785
);
86+
assert!(value.get("scope").is_none());
8887
assert!(value["counterexample"].is_null());
8988
}
9089

90+
#[test]
91+
fn contained_policy_returns_stable_text_coverage_and_zero() {
92+
let output = run(&[
93+
"check",
94+
fixture("candidate-contained.yaml")
95+
.to_str()
96+
.expect("UTF-8 fixture path"),
97+
"--boundary",
98+
fixture("boundary.yaml")
99+
.to_str()
100+
.expect("UTF-8 fixture path"),
101+
]);
102+
assert_eq!(
103+
output.status.code(),
104+
Some(0),
105+
"stderr: {}",
106+
String::from_utf8_lossy(&output.stderr)
107+
);
108+
assert!(output.stderr.is_empty());
109+
assert_eq!(
110+
String::from_utf8(output.stdout).expect("UTF-8 text output"),
111+
"result: within_boundary\ncoverage: domains=filesystem,network_l4,network_rest,process,landlock\n"
112+
);
113+
}
114+
91115
#[test]
92116
fn exceeding_policy_returns_counterexample_and_one() {
93117
let output = check_json("candidate-exceeds.yaml", "boundary-no-write.yaml");
@@ -103,6 +127,58 @@ fn exceeding_policy_returns_counterexample_and_one() {
103127
assert_eq!(value["counterexample"]["domain"], "filesystem");
104128
}
105129

130+
#[test]
131+
fn counterexamples_have_stable_json_shapes() {
132+
for (candidate, boundary, expected) in [
133+
(
134+
"candidate-process-root.yaml",
135+
"boundary-execution.yaml",
136+
serde_json::json!({
137+
"domain": "process",
138+
"field": "run_as_user",
139+
"boundary": "sandbox",
140+
"candidate": "root"
141+
}),
142+
),
143+
(
144+
"candidate-landlock-best-effort.yaml",
145+
"boundary-execution.yaml",
146+
serde_json::json!({
147+
"domain": "landlock",
148+
"boundary": "hard_requirement",
149+
"candidate": "best_effort"
150+
}),
151+
),
152+
(
153+
"candidate-ipv6.yaml",
154+
"boundary-empty.yaml",
155+
serde_json::json!({
156+
"domain": "network",
157+
"binary": null,
158+
"ancestor_binary": null,
159+
"binary_identity_required": false,
160+
"host": "api.example.com",
161+
"destination_ip": "2001:db8::",
162+
"trusted_gateway": false,
163+
"port": 443,
164+
"protocol": "l4",
165+
"method": null,
166+
"path": null
167+
}),
168+
),
169+
] {
170+
let output = check_json(candidate, boundary);
171+
assert_eq!(
172+
output.status.code(),
173+
Some(1),
174+
"candidate={candidate}, stderr={}",
175+
String::from_utf8_lossy(&output.stderr)
176+
);
177+
let value: Value = serde_json::from_slice(&output.stdout).expect("single JSON object");
178+
assert_eq!(value["counterexample"], expected, "candidate={candidate}");
179+
}
180+
}
181+
106182
#[test]
107183
fn unsupported_policy_returns_reason_and_three() {
108184
let output = check_json("unsupported.yaml", "boundary.yaml");
@@ -536,6 +612,9 @@ fn sigint_interrupts_the_check_with_exit_130() {
536612
policy(
537613
(0..300)
538614
.map(|index| format!("/route{index}/**/tail*"))
615+
// Cover the bounded concrete-witness sample so this fixture
616+
// still exercises interruption of the unrestricted solver.
617+
.chain(std::iter::once("/routea/**/tail*".to_owned()))
539618
.collect(),
540619
)
541620
.to_string(),
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
version: 1
5+
process:
6+
run_as_user: sandbox
7+
run_as_group: sandbox
8+
landlock:
9+
compatibility: hard_requirement

0 commit comments

Comments
 (0)