From 2c4b5b9155d446d8558fc6a0495246ac81ecd54c Mon Sep 17 00:00:00 2001 From: Alexander Watson Date: Wed, 8 Jul 2026 14:10:54 -0700 Subject: [PATCH] refactor(prover): remove legacy policy risk queries Signed-off-by: Alexander Watson --- .github/workflows/docker-build.yml | 2 +- .github/workflows/release-dev.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/rust-native-build.yml | 6 - Cargo.lock | 23 - crates/openshell-cli/Cargo.toml | 4 - crates/openshell-prover/Cargo.toml | 5 +- .../registry/apis/github.yaml | 65 --- .../registry/binaries/claude.yaml | 30 -- .../registry/binaries/curl.yaml | 20 - .../registry/binaries/gh.yaml | 19 - .../registry/binaries/git.yaml | 28 -- .../registry/binaries/nc.yaml | 18 - .../registry/binaries/node.yaml | 21 - .../registry/binaries/python3.yaml | 24 - .../registry/binaries/ssh.yaml | 18 - .../registry/binaries/wget.yaml | 17 - crates/openshell-prover/src/accepted_risks.rs | 157 ------ crates/openshell-prover/src/credentials.rs | 460 ------------------ crates/openshell-prover/src/finding.rs | 75 --- crates/openshell-prover/src/lib.rs | 289 +---------- crates/openshell-prover/src/model.rs | 394 --------------- crates/openshell-prover/src/policy.rs | 162 +----- crates/openshell-prover/src/queries.rs | 347 ------------- crates/openshell-prover/src/registry.rs | 278 ----------- crates/openshell-prover/src/report.rs | 367 -------------- .../testdata/accepted-risks.yaml | 7 - .../testdata/credentials.yaml | 12 - .../testdata/empty-policy.yaml | 4 - crates/openshell-prover/testdata/policy.yaml | 50 -- deploy/docker/Dockerfile.cli-macos | 9 +- deploy/docker/Dockerfile.python-wheels | 4 +- deploy/docker/Dockerfile.python-wheels-macos | 4 +- scripts/bin/openshell | 24 - tasks/ci.toml | 2 +- tasks/python.toml | 2 +- 36 files changed, 15 insertions(+), 2936 deletions(-) delete mode 100644 crates/openshell-prover/registry/apis/github.yaml delete mode 100644 crates/openshell-prover/registry/binaries/claude.yaml delete mode 100644 crates/openshell-prover/registry/binaries/curl.yaml delete mode 100644 crates/openshell-prover/registry/binaries/gh.yaml delete mode 100644 crates/openshell-prover/registry/binaries/git.yaml delete mode 100644 crates/openshell-prover/registry/binaries/nc.yaml delete mode 100644 crates/openshell-prover/registry/binaries/node.yaml delete mode 100644 crates/openshell-prover/registry/binaries/python3.yaml delete mode 100644 crates/openshell-prover/registry/binaries/ssh.yaml delete mode 100644 crates/openshell-prover/registry/binaries/wget.yaml delete mode 100644 crates/openshell-prover/src/accepted_risks.rs delete mode 100644 crates/openshell-prover/src/credentials.rs delete mode 100644 crates/openshell-prover/src/finding.rs delete mode 100644 crates/openshell-prover/src/model.rs delete mode 100644 crates/openshell-prover/src/queries.rs delete mode 100644 crates/openshell-prover/src/registry.rs delete mode 100644 crates/openshell-prover/src/report.rs delete mode 100644 crates/openshell-prover/testdata/accepted-risks.yaml delete mode 100644 crates/openshell-prover/testdata/credentials.yaml delete mode 100644 crates/openshell-prover/testdata/empty-policy.yaml delete mode 100644 crates/openshell-prover/testdata/policy.yaml diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 4c92bc385..0ed09d9d4 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -96,7 +96,7 @@ jobs: cli) binary_component=cli binary_name=openshell - features="bundled-z3" + features="" has_image=false ;; *) diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 20d4f0a0c..c30e879fa 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -292,7 +292,7 @@ jobs: sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - name: Build ${{ matrix.target }} - run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli --features bundled-z3 + run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli - name: sccache stats if: always() diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index ec95ab5ea..d27c0ab7f 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -324,7 +324,7 @@ jobs: sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml - name: Build ${{ matrix.target }} - run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli --features bundled-z3 + run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli - name: sccache stats if: always() diff --git a/.github/workflows/rust-native-build.yml b/.github/workflows/rust-native-build.yml index 89993ba12..cbcafd201 100644 --- a/.github/workflows/rust-native-build.yml +++ b/.github/workflows/rust-native-build.yml @@ -223,12 +223,6 @@ jobs: OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }} run: | set -euo pipefail - # z3 built with zig c++ uses libc++ symbols (std::__1::*). - # Override z3-sys default (stdc++) so Rust links the matching runtime. - if [[ "${{ inputs.component }}" == "cli" ]]; then - echo "CXXSTDLIB=c++" >> "$GITHUB_ENV" - fi - mise x -- rustup target add "${{ steps.target.outputs.target }}" cargo_cmd=(cargo build) diff --git a/Cargo.lock b/Cargo.lock index 13b670f55..0f0c8ba76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2429,25 +2429,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "include_dir" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" -dependencies = [ - "proc-macro2", - "quote", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -3597,7 +3578,6 @@ dependencies = [ "openshell-bootstrap", "openshell-core", "openshell-policy", - "openshell-prover", "openshell-providers", "openshell-tui", "owo-colors", @@ -3780,10 +3760,7 @@ dependencies = [ name = "openshell-prover" version = "0.0.0" dependencies = [ - "glob", - "include_dir", "miette", - "owo-colors", "serde", "serde_yml", "z3", diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index 8e4cb3fb2..1cbfd7d47 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -19,7 +19,6 @@ openshell-bootstrap = { path = "../openshell-bootstrap" } openshell-core = { path = "../openshell-core", default-features = false } openshell-policy = { path = "../openshell-policy" } openshell-providers = { path = "../openshell-providers" } -openshell-prover = { path = "../openshell-prover" } openshell-tui = { path = "../openshell-tui" } serde = { workspace = true } serde_json = { workspace = true } @@ -84,9 +83,6 @@ tracing-subscriber = { workspace = true } [lints] workspace = true -[features] -bundled-z3 = ["openshell-prover/bundled-z3"] - [dev-dependencies] futures = { workspace = true } rcgen = { version = "0.13", features = ["crypto", "pem"] } diff --git a/crates/openshell-prover/Cargo.toml b/crates/openshell-prover/Cargo.toml index ee815f3a3..ff53ed72e 100644 --- a/crates/openshell-prover/Cargo.toml +++ b/crates/openshell-prover/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "openshell-prover" -description = "Formal policy verification for OpenShell sandboxes" +description = "Managed maximum policy containment for OpenShell sandboxes" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -18,9 +18,6 @@ z3 = { workspace = true } serde = { workspace = true } serde_yml = { workspace = true } miette = { workspace = true } -owo-colors = { workspace = true } -include_dir = { workspace = true } -glob = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-prover/registry/apis/github.yaml b/crates/openshell-prover/registry/apis/github.yaml deleted file mode 100644 index 78fa15c93..000000000 --- a/crates/openshell-prover/registry/apis/github.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -api: github -host: api.github.com -port: 443 -credential_type: github-pat - -scope_capabilities: - repo: - - { method: GET, path: "/repos/**", action: read_repo } - - { method: GET, path: "/user/repos", action: list_repos } - - { method: POST, path: "/repos/*/issues", action: create_issue } - - { method: POST, path: "/repos/*/issues/*/comments", action: comment_issue } - - { method: POST, path: "/repos/*/pulls", action: create_pr } - - { method: PUT, path: "/repos/*/contents/**", action: write_file } - - { method: DELETE, path: "/repos/*/contents/**", action: delete_file } - - { method: DELETE, path: "/repos/*/*", action: delete_repo } - - { method: POST, path: "/repos/*/git/**", action: write_git_data } - - { method: POST, path: "/repos/*/forks", action: fork_repo } - - { method: PATCH, path: "/repos/*/*", action: update_repo } - - { method: POST, path: "/repos/*/releases", action: create_release } - - { method: POST, path: "/repos/*/dispatches", action: trigger_workflow } - "read:org": - - { method: GET, path: "/orgs/**", action: read_org } - - { method: GET, path: "/user/orgs", action: list_orgs } - "write:org": - - { method: GET, path: "/orgs/**", action: read_org } - - { method: POST, path: "/orgs/*/repos", action: create_org_repo } - - { method: PUT, path: "/orgs/*/memberships/**", action: manage_members } - gist: - - { method: GET, path: "/gists/**", action: read_gists } - - { method: POST, path: "/gists", action: create_gist } - - { method: PATCH, path: "/gists/*", action: update_gist } - - { method: DELETE, path: "/gists/*", action: delete_gist } - "admin:repo_hook": - - { method: GET, path: "/repos/*/hooks/**", action: read_hooks } - - { method: POST, path: "/repos/*/hooks", action: create_hook } - - { method: DELETE, path: "/repos/*/hooks/*", action: delete_hook } - -action_risk: - read_repo: low - list_repos: low - create_issue: medium - comment_issue: medium - create_pr: medium - write_file: high - delete_file: high - delete_repo: critical - write_git_data: high - fork_repo: medium - update_repo: high - create_release: high - trigger_workflow: critical - read_org: low - list_orgs: low - create_org_repo: high - manage_members: critical - read_gists: low - create_gist: medium - update_gist: medium - delete_gist: medium - read_hooks: low - create_hook: critical - delete_hook: high diff --git a/crates/openshell-prover/registry/binaries/claude.yaml b/crates/openshell-prover/registry/binaries/claude.yaml deleted file mode 100644 index e9d1b1d31..000000000 --- a/crates/openshell-prover/registry/binaries/claude.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/local/bin/claude -description: "Claude Code CLI — AI coding agent" -protocols: - - name: anthropic-api - transport: https - description: "Anthropic API calls for inference" - bypasses_l7: false - actions: - - { name: inference, type: write, description: "Send prompts and receive completions" } - - name: http-via-tools - transport: https - description: "Claude can make HTTP requests via tool use (Bash, WebFetch)" - bypasses_l7: false - actions: - - { name: get, type: read } - - { name: post, type: write } - - { name: put, type: write } -spawns: - - /usr/local/bin/node - - /usr/local/bin/python3.13 - - /usr/bin/git - - /usr/bin/curl - - /usr/bin/ssh - - /usr/bin/nc -can_exfiltrate: true -exfil_mechanism: "Can read files and send contents via tool use, API calls, or spawned subprocesses" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/curl.yaml b/crates/openshell-prover/registry/binaries/curl.yaml deleted file mode 100644 index 4cfadb8ac..000000000 --- a/crates/openshell-prover/registry/binaries/curl.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/curl -description: "Command-line HTTP client" -protocols: - - name: http - transport: https - description: "HTTP/HTTPS requests — can construct arbitrary method, path, headers, body" - bypasses_l7: false - actions: - - { name: get, type: read, description: "HTTP GET request" } - - { name: post, type: write, description: "HTTP POST with arbitrary body" } - - { name: put, type: write, description: "HTTP PUT with arbitrary body" } - - { name: delete, type: destructive, description: "HTTP DELETE request" } - - { name: upload, type: write, description: "File upload via multipart POST" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "POST/PUT file contents to any reachable endpoint, or encode in URL query parameters" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/gh.yaml b/crates/openshell-prover/registry/binaries/gh.yaml deleted file mode 100644 index 8ad7cb392..000000000 --- a/crates/openshell-prover/registry/binaries/gh.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/gh -description: "GitHub CLI — REST API client for GitHub" -protocols: - - name: github-rest - transport: https - description: "GitHub REST API via gh cli — uses standard HTTP, subject to L7 inspection" - bypasses_l7: false - actions: - - { name: api_read, type: read, description: "GET requests to GitHub API" } - - { name: api_write, type: write, description: "POST/PUT/PATCH requests (create issues, PRs, etc.)" } - - { name: api_delete, type: destructive, description: "DELETE requests (delete repos, branches, etc.)" } -spawns: - - /usr/bin/git -can_exfiltrate: true -exfil_mechanism: "Create gists, issues, or PRs with file contents" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/git.yaml b/crates/openshell-prover/registry/binaries/git.yaml deleted file mode 100644 index 2c3480cbe..000000000 --- a/crates/openshell-prover/registry/binaries/git.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/git -description: "Distributed version control system" -protocols: - - name: git-smart-http - transport: https - description: "Git smart HTTP protocol over HTTPS CONNECT tunnel — not REST, bypasses L7 HTTP inspection" - bypasses_l7: true - actions: - - { name: clone, type: read, description: "Clone/fetch repository" } - - { name: push, type: write, description: "Push commits to remote" } - - { name: force_push, type: destructive, description: "Force push, rewriting remote history" } - - name: git-ssh - transport: ssh - description: "Git over SSH" - bypasses_l7: true - actions: - - { name: clone, type: read } - - { name: push, type: write } - - { name: force_push, type: destructive } -spawns: - - /usr/lib/git-core/git-remote-https - - /usr/bin/ssh -can_exfiltrate: true -exfil_mechanism: "Commit data to repo and push, or encode in branch/tag names" -can_construct_http: false diff --git a/crates/openshell-prover/registry/binaries/nc.yaml b/crates/openshell-prover/registry/binaries/nc.yaml deleted file mode 100644 index 8f98d7639..000000000 --- a/crates/openshell-prover/registry/binaries/nc.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/nc -description: "Netcat — arbitrary TCP/UDP connections" -protocols: - - name: raw-tcp - transport: tcp - description: "Raw TCP connection — can send/receive arbitrary data" - bypasses_l7: true - actions: - - { name: connect, type: read, description: "Establish TCP connection" } - - { name: send, type: write, description: "Send arbitrary data over TCP" } - - { name: listen, type: read, description: "Listen for incoming connections" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "Stream arbitrary data over raw TCP connection" -can_construct_http: false diff --git a/crates/openshell-prover/registry/binaries/node.yaml b/crates/openshell-prover/registry/binaries/node.yaml deleted file mode 100644 index cfa83f2be..000000000 --- a/crates/openshell-prover/registry/binaries/node.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/local/bin/node -description: "Node.js runtime — general-purpose JavaScript runtime" -protocols: - - name: http-programmatic - transport: https - description: "Node can construct arbitrary HTTP requests via fetch, axios, http module" - bypasses_l7: false - actions: - - { name: get, type: read } - - { name: post, type: write } - - { name: put, type: write } - - { name: delete, type: destructive } -spawns: - - /usr/bin/curl - - /usr/bin/git -can_exfiltrate: true -exfil_mechanism: "Construct arbitrary HTTP requests, spawn subprocesses" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/python3.yaml b/crates/openshell-prover/registry/binaries/python3.yaml deleted file mode 100644 index 3cdf9cd3b..000000000 --- a/crates/openshell-prover/registry/binaries/python3.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/local/bin/python3.13 -description: "Python interpreter — general-purpose runtime" -protocols: - - name: http-programmatic - transport: https - description: "Python can construct arbitrary HTTP requests via urllib, httpx, requests" - bypasses_l7: false - actions: - - { name: get, type: read } - - { name: post, type: write } - - { name: put, type: write } - - { name: delete, type: destructive } -spawns: - - /usr/bin/curl - - /usr/bin/git - - /usr/bin/ssh - - /usr/bin/nc - - /usr/bin/wget -can_exfiltrate: true -exfil_mechanism: "Construct arbitrary HTTP requests, write to files, spawn subprocesses" -can_construct_http: true diff --git a/crates/openshell-prover/registry/binaries/ssh.yaml b/crates/openshell-prover/registry/binaries/ssh.yaml deleted file mode 100644 index b53065d88..000000000 --- a/crates/openshell-prover/registry/binaries/ssh.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/ssh -description: "OpenSSH client" -protocols: - - name: ssh - transport: ssh - description: "SSH protocol — encrypted tunnel, can forward ports and transfer files" - bypasses_l7: true - actions: - - { name: connect, type: read, description: "SSH shell connection" } - - { name: tunnel, type: write, description: "Port forwarding / tunnel" } - - { name: scp, type: write, description: "File transfer via SCP" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "SCP files or pipe data through SSH tunnel" -can_construct_http: false diff --git a/crates/openshell-prover/registry/binaries/wget.yaml b/crates/openshell-prover/registry/binaries/wget.yaml deleted file mode 100644 index 0741998b7..000000000 --- a/crates/openshell-prover/registry/binaries/wget.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -binary: /usr/bin/wget -description: "Non-interactive network downloader" -protocols: - - name: http - transport: https - description: "HTTP/HTTPS downloads — can also POST with --post-data" - bypasses_l7: false - actions: - - { name: download, type: read } - - { name: post, type: write, description: "POST via --post-data or --post-file" } -spawns: [] -can_exfiltrate: true -exfil_mechanism: "POST file contents via --post-file to any reachable endpoint" -can_construct_http: true diff --git a/crates/openshell-prover/src/accepted_risks.rs b/crates/openshell-prover/src/accepted_risks.rs deleted file mode 100644 index 8c28a4418..000000000 --- a/crates/openshell-prover/src/accepted_risks.rs +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Load and match accepted risk annotations against findings. - -use std::path::Path; - -use miette::{IntoDiagnostic, Result, WrapErr}; -use serde::Deserialize; - -use crate::finding::{Finding, FindingPath}; - -// --------------------------------------------------------------------------- -// Serde types -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct AcceptedRisksFile { - #[serde(default)] - accepted_risks: Vec, -} - -#[derive(Debug, Deserialize)] -struct AcceptedRiskDef { - #[serde(default)] - query: String, - #[serde(default)] - reason: String, - #[serde(default)] - accepted_by: String, - #[serde(default)] - binary: String, - #[serde(default)] - endpoint: String, -} - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/// An explicitly accepted risk annotation. -#[derive(Debug, Clone)] -pub struct AcceptedRisk { - pub query: String, - pub reason: String, - pub accepted_by: String, - pub binary: String, - pub endpoint: String, -} - -// --------------------------------------------------------------------------- -// Loading -// --------------------------------------------------------------------------- - -/// Load accepted risks from a YAML file. -pub fn load_accepted_risks(path: &Path) -> Result> { - let contents = std::fs::read_to_string(path) - .into_diagnostic() - .wrap_err_with(|| format!("reading accepted risks {}", path.display()))?; - let raw: AcceptedRisksFile = serde_yml::from_str(&contents) - .into_diagnostic() - .wrap_err("parsing accepted risks YAML")?; - - Ok(raw - .accepted_risks - .into_iter() - .map(|r| AcceptedRisk { - query: r.query, - reason: r.reason, - accepted_by: r.accepted_by, - binary: r.binary, - endpoint: r.endpoint, - }) - .collect()) -} - -// --------------------------------------------------------------------------- -// Matching -// --------------------------------------------------------------------------- - -/// Check if a single finding path matches an accepted risk. -fn path_matches_risk(path: &FindingPath, risk: &AcceptedRisk) -> bool { - let FindingPath::Exfil(p) = path; - if !risk.binary.is_empty() && p.binary != risk.binary { - return false; - } - if !risk.endpoint.is_empty() && p.endpoint_host != risk.endpoint { - return false; - } - true -} - -/// Mark findings as accepted where they match accepted risk annotations. -/// -/// A finding is accepted if **all** of its paths match at least one accepted -/// risk entry for that query. If only some paths match, the finding stays -/// active with the unmatched paths. -pub fn apply_accepted_risks(findings: Vec, accepted: &[AcceptedRisk]) -> Vec { - if accepted.is_empty() { - return findings; - } - - let mut result = Vec::new(); - for finding in findings { - let matching_risks: Vec<&AcceptedRisk> = accepted - .iter() - .filter(|r| r.query == finding.query) - .collect(); - - if matching_risks.is_empty() { - result.push(finding); - continue; - } - - if finding.paths.is_empty() { - // Pathless finding — accept if query matches. - result.push(Finding { - accepted: true, - accepted_reason: matching_risks[0].reason.clone(), - ..finding - }); - continue; - } - - let mut unmatched_paths = Vec::new(); - let mut matched_reason = String::new(); - for path in &finding.paths { - let mut path_accepted = false; - for risk in &matching_risks { - if path_matches_risk(path, risk) { - path_accepted = true; - matched_reason.clone_from(&risk.reason); - break; - } - } - if !path_accepted { - unmatched_paths.push(path.clone()); - } - } - - if unmatched_paths.is_empty() { - result.push(Finding { - accepted: true, - accepted_reason: matched_reason, - ..finding - }); - } else if unmatched_paths.len() < finding.paths.len() { - result.push(Finding { - paths: unmatched_paths, - ..finding - }); - } else { - result.push(finding); - } - } - result -} diff --git a/crates/openshell-prover/src/credentials.rs b/crates/openshell-prover/src/credentials.rs deleted file mode 100644 index 586d0fbbf..000000000 --- a/crates/openshell-prover/src/credentials.rs +++ /dev/null @@ -1,460 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Credential descriptors and API capability registries. - -use std::collections::HashMap; -use std::path::Path; - -use miette::{IntoDiagnostic, Result, WrapErr}; -use serde::Deserialize; - -// --------------------------------------------------------------------------- -// Serde types -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct CredentialsFile { - #[serde(default)] - credentials: Vec, -} - -#[derive(Debug, Deserialize)] -struct CredentialDef { - #[serde(default)] - name: String, - #[serde(default, rename = "type")] - cred_type: String, - #[serde(default)] - scopes: Vec, - #[serde(default)] - injected_via: String, - #[serde(default)] - target_hosts: Vec, -} - -#[derive(Debug, Deserialize)] -struct ApiRegistryDef { - #[serde(default)] - api: String, - #[serde(default)] - host: String, - #[serde(default)] - port: u32, - #[serde(default)] - credential_type: String, - #[serde(default)] - scope_capabilities: HashMap>, - #[serde(default)] - action_risk: HashMap, -} - -#[derive(Debug, Deserialize)] -struct ApiActionDef { - #[serde(default)] - method: String, - #[serde(default)] - path: String, - #[serde(default)] - action: String, -} - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/// A credential injected into the sandbox. -#[derive(Debug, Clone)] -pub struct Credential { - pub name: String, - pub cred_type: String, - pub scopes: Vec, - pub injected_via: String, - pub target_hosts: Vec, -} - -/// A single API action (HTTP method + path + semantic name). -#[derive(Debug, Clone)] -pub struct ApiAction { - pub method: String, - pub path: String, - pub action: String, -} - -/// Capability registry for an API (e.g., GitHub REST API). -#[derive(Debug, Clone)] -pub struct ApiCapability { - pub api: String, - pub host: String, - pub port: u32, - pub credential_type: String, - pub scope_capabilities: HashMap>, - pub action_risk: HashMap, -} - -impl ApiCapability { - /// All actions enabled by the given scopes. - pub fn actions_for_scopes(&self, scopes: &[String]) -> Vec<&ApiAction> { - let mut result = Vec::new(); - for scope in scopes { - if let Some(actions) = self.scope_capabilities.get(scope) { - result.extend(actions.iter()); - } - } - result - } - - /// Write actions (POST, PUT, PATCH, DELETE) enabled by the given scopes. - pub fn write_actions_for_scopes(&self, scopes: &[String]) -> Vec<&ApiAction> { - self.actions_for_scopes(scopes) - .into_iter() - .filter(|a| { - let m = a.method.to_uppercase(); - m == "POST" || m == "PUT" || m == "PATCH" || m == "DELETE" - }) - .collect() - } - - /// Destructive actions (high or critical risk) enabled by the given scopes. - pub fn destructive_actions_for_scopes(&self, scopes: &[String]) -> Vec<&ApiAction> { - self.actions_for_scopes(scopes) - .into_iter() - .filter(|a| { - let risk = self.action_risk.get(&a.action).map(String::as_str); - matches!(risk, Some("high" | "critical")) - }) - .collect() - } -} - -/// Combined set of credentials and API registries. -#[derive(Debug, Clone, Default)] -pub struct CredentialSet { - pub credentials: Vec, - pub api_registries: HashMap, -} - -impl CredentialSet { - /// Credentials that target a given host. Matching mirrors runtime host - /// policy semantics for exact names and first-label wildcards, so a - /// proposal for `*.github.com` is treated as credentialed when the - /// attached credential targets `api.github.com`. - pub fn credentials_for_host(&self, host: &str) -> Vec<&Credential> { - self.credentials - .iter() - .filter(|c| { - c.target_hosts - .iter() - .any(|target| host_patterns_overlap(host, target)) - }) - .collect() - } - - /// API capability registry for a given host. Exact matches win, then - /// wildcard host overlap is used so credentialed wildcard proposals can be - /// evaluated against concrete API capability registries. - pub fn api_for_host(&self, host: &str) -> Option<&ApiCapability> { - let needle = normalize_host(host); - self.api_registries - .values() - .find(|api| normalize_host(&api.host) == needle) - .or_else(|| { - self.api_registries - .values() - .find(|api| host_patterns_overlap(host, &api.host)) - }) - } -} - -fn normalize_host(host: &str) -> String { - host.trim().trim_end_matches('.').to_ascii_lowercase() -} - -fn host_patterns_overlap(left: &str, right: &str) -> bool { - let left = normalize_host(left); - let right = normalize_host(right); - if left.is_empty() || right.is_empty() { - return false; - } - left == right || host_pattern_covers(&left, &right) || host_pattern_covers(&right, &left) -} - -fn host_pattern_covers(pattern: &str, host: &str) -> bool { - let pattern_labels: Vec<&str> = pattern.split('.').collect(); - let host_labels: Vec<&str> = host.split('.').collect(); - let Some(first_pattern_label) = pattern_labels.first().copied() else { - return false; - }; - - if first_pattern_label == "**" { - let suffix = &pattern_labels[1..]; - let host_suffix = host_labels - .len() - .checked_sub(suffix.len()) - .map(|start| &host_labels[start..]); - return !suffix.is_empty() - && host_labels.len() > suffix.len() - && matches!(host_suffix, Some(host_suffix) if host_suffix == suffix); - } - - if !first_pattern_label.contains('*') { - return false; - } - - // Runtime host wildcards only apply in the first DNS label. Wildcards in - // later labels are not treated as policy globs here. - pattern_labels.len() == host_labels.len() - && pattern_labels[1..] == host_labels[1..] - && wildcard_label_matches(first_pattern_label, host_labels[0]) -} - -fn wildcard_label_matches(pattern: &str, label: &str) -> bool { - if pattern == "*" { - return !label.is_empty(); - } - if label.is_empty() || !pattern.contains('*') { - return false; - } - - let parts: Vec<&str> = pattern.split('*').collect(); - let mut remaining = label; - - if let Some(prefix) = parts.first().copied().filter(|part| !part.is_empty()) { - let Some(stripped) = remaining.strip_prefix(prefix) else { - return false; - }; - remaining = stripped; - } - - if parts.len() > 2 { - for part in parts[1..parts.len() - 1] - .iter() - .copied() - .filter(|part| !part.is_empty()) - { - let Some(offset) = remaining.find(part) else { - return false; - }; - remaining = &remaining[offset + part.len()..]; - } - } - - parts - .last() - .copied() - .filter(|suffix| !suffix.is_empty()) - .is_none_or(|suffix| remaining.ends_with(suffix)) -} - -// --------------------------------------------------------------------------- -// Loading -// --------------------------------------------------------------------------- - -/// Load credential descriptors from a YAML file. -pub fn load_credentials(path: &Path) -> Result> { - let contents = std::fs::read_to_string(path) - .into_diagnostic() - .wrap_err_with(|| format!("reading credentials file {}", path.display()))?; - let raw: CredentialsFile = serde_yml::from_str(&contents) - .into_diagnostic() - .wrap_err("parsing credentials YAML")?; - - Ok(raw - .credentials - .into_iter() - .map(|c| Credential { - name: c.name, - cred_type: c.cred_type, - scopes: c.scopes, - injected_via: c.injected_via, - target_hosts: c.target_hosts, - }) - .collect()) -} - -fn parse_api_registry(contents: &str, source: &str) -> Result { - let raw: ApiRegistryDef = serde_yml::from_str(contents) - .into_diagnostic() - .wrap_err_with(|| format!("parsing API registry {source}"))?; - - let scope_capabilities = raw - .scope_capabilities - .into_iter() - .map(|(scope, actions)| { - let actions = actions - .into_iter() - .map(|a| ApiAction { - method: a.method, - path: a.path, - action: a.action, - }) - .collect(); - (scope, actions) - }) - .collect(); - - Ok(ApiCapability { - api: raw.api, - host: raw.host, - port: raw.port, - credential_type: raw.credential_type, - scope_capabilities, - action_risk: raw.action_risk, - }) -} - -fn load_embedded_api_registries() -> Result> { - let registry = crate::registry::embedded_registry(); - let mut api_registries = HashMap::new(); - if let Some(dir) = registry.get_dir("apis") { - for file in dir.files() { - if file.path().extension().is_some_and(|ext| ext == "yaml") { - let contents = file.contents_utf8().ok_or_else(|| { - miette::miette!("non-UTF8 API registry file: {}", file.path().display()) - })?; - let api = parse_api_registry(contents, &file.path().display().to_string())?; - api_registries.insert(api.api.clone(), api); - } - } - } - Ok(api_registries) -} - -fn load_api_registries_from_dir(registry_dir: &Path) -> Result> { - let mut api_registries = HashMap::new(); - let apis_dir = registry_dir.join("apis"); - if apis_dir.is_dir() { - let entries = std::fs::read_dir(&apis_dir) - .into_diagnostic() - .wrap_err_with(|| format!("reading directory {}", apis_dir.display()))?; - for entry in entries { - let entry = entry.into_diagnostic()?; - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let contents = std::fs::read_to_string(&path) - .into_diagnostic() - .wrap_err_with(|| format!("reading {}", path.display()))?; - let api = parse_api_registry(&contents, &path.display().to_string())?; - api_registries.insert(api.api.clone(), api); - } - } - } - Ok(api_registries) -} - -/// Load credentials with API registries from the embedded registry. -pub fn load_credential_set_embedded(credentials_path: &Path) -> Result { - let creds = load_credentials(credentials_path)?; - let api_registries = load_embedded_api_registries()?; - Ok(CredentialSet { - credentials: creds, - api_registries, - }) -} - -/// Load credentials with API registries from a filesystem directory override. -pub fn load_credential_set_from_dir( - credentials_path: &Path, - registry_dir: &Path, -) -> Result { - let creds = load_credentials(credentials_path)?; - let api_registries = load_api_registries_from_dir(registry_dir)?; - Ok(CredentialSet { - credentials: creds, - api_registries, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn github_credential() -> Credential { - Credential { - name: "github-pat".to_string(), - cred_type: "github-pat".to_string(), - scopes: vec!["repo".to_string()], - injected_via: "GITHUB_TOKEN".to_string(), - target_hosts: vec!["api.github.com".to_string()], - } - } - - fn github_api() -> ApiCapability { - ApiCapability { - api: "github".to_string(), - host: "api.github.com".to_string(), - port: 443, - credential_type: "github-pat".to_string(), - scope_capabilities: HashMap::new(), - action_risk: HashMap::new(), - } - } - - #[test] - fn host_patterns_overlap_matches_exact_case_and_trailing_dot() { - assert!(host_patterns_overlap("API.GITHUB.COM.", "api.github.com")); - assert!(!host_patterns_overlap( - "api.github.com", - "uploads.github.com" - )); - } - - #[test] - fn host_patterns_overlap_matches_first_label_wildcard_only() { - assert!(host_patterns_overlap("*.github.com", "api.github.com")); - assert!(!host_patterns_overlap("*.github.com", "github.com")); - assert!(!host_patterns_overlap( - "*.github.com", - "deep.api.github.com" - )); - } - - #[test] - fn host_patterns_overlap_matches_intra_label_first_label_wildcard() { - assert!(host_patterns_overlap( - "api-*.github.com", - "api-v3.github.com" - )); - assert!(!host_patterns_overlap( - "api-*.github.com", - "uploads.github.com" - )); - assert!(!host_patterns_overlap( - "api.*.github.com", - "api.v3.github.com" - )); - } - - #[test] - fn host_patterns_overlap_matches_recursive_first_label_wildcard() { - assert!(host_patterns_overlap("**.github.com", "api.github.com")); - assert!(host_patterns_overlap( - "**.github.com", - "deep.api.github.com" - )); - assert!(!host_patterns_overlap("**.github.com", "github.com")); - } - - #[test] - fn wildcard_policy_host_finds_credentialed_concrete_target() { - let set = CredentialSet { - credentials: vec![github_credential()], - api_registries: HashMap::new(), - }; - - let creds = set.credentials_for_host("*.github.com"); - assert_eq!(creds.len(), 1); - assert_eq!(creds[0].name, "github-pat"); - } - - #[test] - fn wildcard_policy_host_finds_concrete_api_registry() { - let set = CredentialSet { - credentials: Vec::new(), - api_registries: HashMap::from([("github".to_string(), github_api())]), - }; - - let api = set.api_for_host("*.github.com").expect("github API"); - assert_eq!(api.host, "api.github.com"); - } -} diff --git a/crates/openshell-prover/src/finding.rs b/crates/openshell-prover/src/finding.rs deleted file mode 100644 index 4e06d1b4e..000000000 --- a/crates/openshell-prover/src/finding.rs +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Finding types emitted by verification queries. -//! -//! The prover answers four formal questions about a proposed policy and -//! emits one finding category per "yes" answer. Findings are categorical -//! (not severity-graded): the reviewer reads the category name and the -//! structured evidence to decide. The auto-approval gate is binary — -//! delta empty = candidate for auto-approval; any finding = human review. -//! -//! Categories: -//! -//! - `credential_reach_expansion` — a binary gained credentialed reach to -//! a (host, port) it could not reach before. -//! - `capability_expansion` — on a (binary, host, port) that already had -//! credentialed reach, a new HTTP method was added. -//! - `l7_bypass_credentialed` — a binary using a wire protocol the L7 -//! proxy cannot inspect (`git-remote-https`, `ssh`, `nc`) gained reach -//! to a host where a credential is in scope. -//! - `link_local_reach` — any reach to a link-local IP range -//! (`169.254.0.0/16`, `fe80::/10`), unconditional. Cloud metadata -//! endpoints serve credentials regardless of the sandbox's own -//! credential state. - -/// Stable category names. Used as the `query` field on [`Finding`] and -/// in the per-path key used by `finding_delta`. -pub mod category { - pub const CREDENTIAL_REACH_EXPANSION: &str = "credential_reach_expansion"; - pub const CAPABILITY_EXPANSION: &str = "capability_expansion"; - pub const L7_BYPASS_CREDENTIALED: &str = "l7_bypass_credentialed"; - pub const LINK_LOCAL_REACH: &str = "link_local_reach"; -} - -/// A concrete path through which the prover observed a tracked property. -/// -/// One `ExfilPath` per (binary, host, port, category) tuple — plus -/// `method` for `capability_expansion` so the gateway's per-path delta -/// surfaces the specific method that was added. -#[derive(Debug, Clone)] -pub struct ExfilPath { - pub binary: String, - pub endpoint_host: String, - pub endpoint_port: u16, - pub mechanism: String, - pub policy_name: String, - /// Category name (see `category::*` constants). - pub category: String, - /// HTTP method, populated only for `capability_expansion` paths. - /// Empty string for the other categories. - pub method: String, -} - -/// Concrete evidence attached to a [`Finding`]. -#[derive(Debug, Clone)] -pub enum FindingPath { - Exfil(ExfilPath), -} - -/// A single verification finding. -/// -/// `query` is the category name (one of the `category::*` constants). -/// Each finding carries one or more `paths` with the structured evidence -/// the reviewer needs to decide. There is no severity field — the -/// category itself is the signal. -#[derive(Debug, Clone)] -pub struct Finding { - pub query: String, - pub title: String, - pub description: String, - pub paths: Vec, - pub remediation: Vec, - pub accepted: bool, - pub accepted_reason: String, -} diff --git a/crates/openshell-prover/src/lib.rs b/crates/openshell-prover/src/lib.rs index 04e19034d..e94dd150f 100644 --- a/crates/openshell-prover/src/lib.rs +++ b/crates/openshell-prover/src/lib.rs @@ -1,294 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Formal policy verification for `OpenShell` sandboxes. -//! -//! Encodes sandbox policies, binary capabilities, and credential scopes as Z3 -//! SMT constraints, then checks reachability queries to detect data exfiltration -//! paths and write-bypass violations. +//! Semantic containment for managed `OpenShell` maximum policies. -pub mod accepted_risks; -pub mod credentials; pub mod envelope; -pub mod finding; -pub mod model; pub mod policy; -pub mod queries; -pub mod registry; -pub mod report; - -use std::path::Path; - -use miette::Result; - -use accepted_risks::{apply_accepted_risks, load_accepted_risks}; -use model::build_model; -use policy::parse_policy; -use queries::run_all_queries; -use report::{render_compact, render_report}; - -/// Run the prover end-to-end and return an exit code. -/// -/// - `0` — pass (no critical/high findings, or all accepted) -/// - `1` — fail (critical or high findings present) -/// - `2` — input error -/// -/// Binary and API capability registries are embedded at compile time. -/// Pass `registry_dir` to override with a custom filesystem registry. -pub fn prove( - policy_path: &str, - credentials_path: &str, - registry_dir: Option<&str>, - accepted_risks_path: Option<&str>, - compact: bool, -) -> Result { - let policy = parse_policy(Path::new(policy_path))?; - - let (credential_set, binary_registry) = match registry_dir { - Some(dir) => { - let dir = Path::new(dir); - ( - credentials::load_credential_set_from_dir(Path::new(credentials_path), dir)?, - registry::load_binary_registry_from_dir(dir)?, - ) - } - None => ( - credentials::load_credential_set_embedded(Path::new(credentials_path))?, - registry::load_embedded_binary_registry()?, - ), - }; - - let z3_model = build_model(policy, credential_set, binary_registry); - let mut findings = run_all_queries(&z3_model); - - if let Some(ar_path) = accepted_risks_path { - let accepted = load_accepted_risks(Path::new(ar_path))?; - findings = apply_accepted_risks(findings, &accepted); - } - - let exit_code = if compact { - render_compact(&findings, policy_path, credentials_path) - } else { - render_report(&findings, policy_path, credentials_path) - }; - - Ok(exit_code) -} - -// =========================================================================== -// Tests -// =========================================================================== - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - fn testdata_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata") - } - - // 1. Parse testdata/policy.yaml, verify structure. - #[test] - fn test_parse_policy() { - let path = testdata_dir().join("policy.yaml"); - let model = parse_policy(&path).expect("failed to parse policy"); - assert_eq!(model.version, 1); - assert!(model.network_policies.contains_key("github_api")); - let rule = &model.network_policies["github_api"]; - assert_eq!(rule.name, "github-api"); - assert_eq!(rule.endpoints.len(), 2); - assert!(rule.binaries.len() >= 4); - } - - // 2. Verify readable_paths. - #[test] - fn test_filesystem_policy() { - let path = testdata_dir().join("policy.yaml"); - let model = parse_policy(&path).expect("failed to parse policy"); - let readable = model.filesystem_policy.readable_paths(); - assert!(readable.contains(&"/usr".to_owned())); - assert!(readable.contains(&"/sandbox".to_owned())); - assert!(readable.contains(&"/tmp".to_owned())); - } - - // 3. Workdir NOT included by default (matches runtime behavior). - #[test] - fn test_include_workdir_default() { - let yaml = r" -version: 1 -filesystem_policy: - read_only: - - /usr -"; - let model = policy::parse_policy_str(yaml).expect("parse"); - let readable = model.filesystem_policy.readable_paths(); - assert!(!readable.contains(&"/sandbox".to_owned())); - } - - // 4. Workdir excluded when include_workdir: false. - #[test] - fn test_include_workdir_false() { - let yaml = r" -version: 1 -filesystem_policy: - include_workdir: false - read_only: - - /usr -"; - let model = policy::parse_policy_str(yaml).expect("parse"); - let readable = model.filesystem_policy.readable_paths(); - assert!(!readable.contains(&"/sandbox".to_owned())); - } - - // 5. No duplicate when workdir already in read_write. - #[test] - fn test_include_workdir_no_duplicate() { - let yaml = r" -version: 1 -filesystem_policy: - include_workdir: true - read_write: - - /sandbox - - /tmp -"; - let model = policy::parse_policy_str(yaml).expect("parse"); - let readable = model.filesystem_policy.readable_paths(); - let sandbox_count = readable.iter().filter(|p| *p == "/sandbox").count(); - assert_eq!(sandbox_count, 1); - } - - // 6. End-to-end: testdata policy with a github credential in scope and a - // bypass-L7 binary (git) emits an `l7_bypass_credentialed` finding. - // The prover output is categorical, not severity-graded. - #[test] - fn test_findings_for_github_policy() { - use finding::category; - - let policy_path = testdata_dir().join("policy.yaml"); - let creds_path = testdata_dir().join("credentials.yaml"); - - let pol = parse_policy(&policy_path).expect("parse policy"); - let cred_set = credentials::load_credential_set_embedded(&creds_path).expect("load creds"); - let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); - - let z3_model = build_model(pol, cred_set, bin_reg); - let findings = run_all_queries(&z3_model); - - let categories: std::collections::HashSet<&str> = - findings.iter().map(|f| f.query.as_str()).collect(); - assert!( - categories.contains(category::L7_BYPASS_CREDENTIALED), - "expected l7_bypass_credentialed finding for bypass-L7 binary with credential in scope; \ - got categories: {categories:?}" - ); - // Every emitted category must be one of the four v1 categories. - let allowed: std::collections::HashSet<&str> = [ - category::LINK_LOCAL_REACH, - category::L7_BYPASS_CREDENTIALED, - category::CREDENTIAL_REACH_EXPANSION, - category::CAPABILITY_EXPANSION, - ] - .into_iter() - .collect(); - for c in &categories { - assert!( - allowed.contains(*c), - "unexpected category {c} emitted by the prover" - ); - } - } - - #[test] - fn test_wildcard_endpoint_covering_credential_host_emits_credential_reach() { - use finding::{FindingPath, category}; - - let policy = policy::parse_policy_str( - r#" -version: 1 -network_policies: - github_wildcard: - name: github-wildcard - endpoints: - - host: "*.github.com" - port: 443 - protocol: rest - enforcement: enforce - access: read-write - binaries: - - path: /usr/bin/curl -"#, - ) - .expect("parse policy"); - let cred_set = - credentials::load_credential_set_embedded(&testdata_dir().join("credentials.yaml")) - .expect("load creds"); - let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); - - let z3_model = build_model(policy, cred_set, bin_reg); - let findings = run_all_queries(&z3_model); - - let reach = findings - .iter() - .find(|finding| finding.query == category::CREDENTIAL_REACH_EXPANSION) - .expect("wildcard host covering api.github.com must be credentialed"); - assert!(reach.paths.iter().any(|path| matches!( - path, - FindingPath::Exfil(exfil) - if exfil.endpoint_host == "*.github.com" && exfil.binary == "/usr/bin/curl" - ))); - } - - #[test] - fn test_known_metadata_hostname_emits_link_local_finding() { - use finding::{FindingPath, category}; - - let policy = policy::parse_policy_str( - r" -version: 1 -network_policies: - metadata: - name: metadata - endpoints: - - host: metadata.google.internal - port: 80 - binaries: - - path: /usr/bin/curl -", - ) - .expect("parse policy"); - let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); - - let z3_model = build_model(policy, credentials::CredentialSet::default(), bin_reg); - let findings = run_all_queries(&z3_model); - - let link_local = findings - .iter() - .find(|finding| finding.query == category::LINK_LOCAL_REACH) - .expect("known metadata hostname must emit link-local/metadata finding"); - assert!(link_local.paths.iter().any(|path| matches!( - path, - FindingPath::Exfil(exfil) - if exfil.endpoint_host == "metadata.google.internal" - ))); - } - - // 7. Empty policy produces no findings. - #[test] - fn test_empty_policy_no_findings() { - let policy_path = testdata_dir().join("empty-policy.yaml"); - let creds_path = testdata_dir().join("credentials.yaml"); - - let pol = parse_policy(&policy_path).expect("parse policy"); - let cred_set = credentials::load_credential_set_embedded(&creds_path).expect("load creds"); - let bin_reg = registry::load_embedded_binary_registry().expect("load registry"); - - let z3_model = build_model(pol, cred_set, bin_reg); - let findings = run_all_queries(&z3_model); - - assert!( - findings.is_empty(), - "deny-all policy should produce no findings, got: {findings:?}" - ); - } -} diff --git a/crates/openshell-prover/src/model.rs b/crates/openshell-prover/src/model.rs deleted file mode 100644 index bf52993d4..000000000 --- a/crates/openshell-prover/src/model.rs +++ /dev/null @@ -1,394 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Z3 constraint model encoding policy, credentials, and binary capabilities. - -use std::collections::{HashMap, HashSet}; - -use z3::ast::Bool; -use z3::{Context, SatResult, Solver}; - -use crate::credentials::CredentialSet; -use crate::policy::{PolicyModel, WRITE_METHODS}; -use crate::registry::BinaryRegistry; - -/// Unique identifier for a network endpoint in the model. -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct EndpointId { - pub policy_name: String, - pub host: String, - pub port: u16, -} - -impl EndpointId { - /// Stable string key used for Z3 variable naming. - pub fn key(&self) -> String { - format!("{}:{}:{}", self.policy_name, self.host, self.port) - } -} - -/// Z3-backed reachability model for an `OpenShell` sandbox policy. -pub struct ReachabilityModel { - pub policy: PolicyModel, - pub credentials: CredentialSet, - pub binary_registry: BinaryRegistry, - - // Indexed facts - pub endpoints: Vec, - pub binary_paths: Vec, - - // Z3 solver - solver: Solver, - - // Boolean variable maps - policy_allows: HashMap, - l7_enforced: HashMap, - l7_allows_write: HashMap, - binary_bypasses_l7: HashMap, - binary_can_write: HashMap, - binary_can_exfil: HashMap, - binary_can_construct_http: HashMap, - credential_has_write: HashMap, - #[allow(dead_code)] - credential_has_destructive: HashMap, - #[allow(dead_code)] - filesystem_readable: HashMap, -} - -impl ReachabilityModel { - /// Build a new reachability model from the given inputs. - pub fn new( - policy: PolicyModel, - credentials: CredentialSet, - binary_registry: BinaryRegistry, - ) -> Self { - let solver = Solver::new(); - let mut model = Self { - policy, - credentials, - binary_registry, - endpoints: Vec::new(), - binary_paths: Vec::new(), - solver, - policy_allows: HashMap::new(), - l7_enforced: HashMap::new(), - l7_allows_write: HashMap::new(), - binary_bypasses_l7: HashMap::new(), - binary_can_write: HashMap::new(), - binary_can_exfil: HashMap::new(), - binary_can_construct_http: HashMap::new(), - credential_has_write: HashMap::new(), - credential_has_destructive: HashMap::new(), - filesystem_readable: HashMap::new(), - }; - model.build(); - model - } - - fn build(&mut self) { - self.index_endpoints(); - self.index_binaries(); - self.encode_policy_allows(); - self.encode_l7_enforcement(); - self.encode_binary_capabilities(); - self.encode_credentials(); - self.encode_filesystem(); - } - - fn index_endpoints(&mut self) { - for (policy_name, rule) in &self.policy.network_policies { - for ep in &rule.endpoints { - for port in ep.effective_ports() { - self.endpoints.push(EndpointId { - policy_name: policy_name.clone(), - host: ep.host.clone(), - port, - }); - } - } - } - } - - fn index_binaries(&mut self) { - let mut seen = HashSet::new(); - for rule in self.policy.network_policies.values() { - for b in &rule.binaries { - if seen.insert(b.path.clone()) { - self.binary_paths.push(b.path.clone()); - } - } - } - } - - fn encode_policy_allows(&mut self) { - for (policy_name, rule) in &self.policy.network_policies { - for ep in &rule.endpoints { - for port in ep.effective_ports() { - let eid = EndpointId { - policy_name: policy_name.clone(), - host: ep.host.clone(), - port, - }; - for b in &rule.binaries { - let key = format!("{}:{}", b.path, eid.key()); - let var = Bool::new_const(format!("policy_allows_{key}")); - self.solver.assert(&var); - self.policy_allows.insert(key, var); - } - } - } - } - } - - fn encode_l7_enforcement(&mut self) { - for (policy_name, rule) in &self.policy.network_policies { - for ep in &rule.endpoints { - for port in ep.effective_ports() { - let eid = EndpointId { - policy_name: policy_name.clone(), - host: ep.host.clone(), - port, - }; - let ek = eid.key(); - - // L7 enforced? - let l7_var = Bool::new_const(format!("l7_enforced_{ek}")); - if ep.is_l7_enforced() { - self.solver.assert(&l7_var); - } else { - self.solver.assert(&!l7_var.clone()); - } - self.l7_enforced.insert(ek.clone(), l7_var); - - // L7 allows write? - let allowed = ep.allowed_methods(); - let write_set: HashSet<&str> = WRITE_METHODS.iter().copied().collect(); - let has_write = if allowed.is_empty() { - true // L4-only: all methods pass - } else { - allowed.iter().any(|m| write_set.contains(m.as_str())) - }; - - let l7_write_var = Bool::new_const(format!("l7_allows_write_{ek}")); - if ep.is_l7_enforced() { - if has_write { - self.solver.assert(&l7_write_var); - } else { - self.solver.assert(&!l7_write_var.clone()); - } - } else { - // L4-only: all methods pass through - self.solver.assert(&l7_write_var); - } - self.l7_allows_write.insert(ek, l7_write_var); - } - } - } - } - - fn encode_binary_capabilities(&mut self) { - for bpath in &self.binary_paths.clone() { - let cap = self.binary_registry.get_or_unknown(bpath); - - let bypass_var = Bool::new_const(format!("binary_bypasses_l7_{bpath}")); - if cap.bypasses_l7() { - self.solver.assert(&bypass_var); - } else { - self.solver.assert(&!bypass_var.clone()); - } - self.binary_bypasses_l7.insert(bpath.clone(), bypass_var); - - let write_var = Bool::new_const(format!("binary_can_write_{bpath}")); - if cap.can_write() { - self.solver.assert(&write_var); - } else { - self.solver.assert(&!write_var.clone()); - } - self.binary_can_write.insert(bpath.clone(), write_var); - - let exfil_var = Bool::new_const(format!("binary_can_exfil_{bpath}")); - if cap.can_exfiltrate { - self.solver.assert(&exfil_var); - } else { - self.solver.assert(&!exfil_var.clone()); - } - self.binary_can_exfil.insert(bpath.clone(), exfil_var); - - let http_var = Bool::new_const(format!("binary_can_construct_http_{bpath}")); - if cap.can_construct_http { - self.solver.assert(&http_var); - } else { - self.solver.assert(&!http_var.clone()); - } - self.binary_can_construct_http - .insert(bpath.clone(), http_var); - } - } - - fn encode_credentials(&mut self) { - let hosts: HashSet = self.endpoints.iter().map(|e| e.host.clone()).collect(); - - for host in &hosts { - let creds = self.credentials.credentials_for_host(host); - let api = self.credentials.api_for_host(host); - - let mut has_write = false; - let mut has_destructive = false; - - for cred in &creds { - if let Some(api) = api { - if !api.write_actions_for_scopes(&cred.scopes).is_empty() { - has_write = true; - } - if !api.destructive_actions_for_scopes(&cred.scopes).is_empty() { - has_destructive = true; - } - } else if !cred.scopes.is_empty() { - has_write = true; - } - } - - let cw_var = Bool::new_const(format!("credential_has_write_{host}")); - if has_write { - self.solver.assert(&cw_var); - } else { - self.solver.assert(&!cw_var.clone()); - } - self.credential_has_write.insert(host.clone(), cw_var); - - let destructive_var = Bool::new_const(format!("credential_has_destructive_{host}")); - if has_destructive { - self.solver.assert(&destructive_var); - } else { - self.solver.assert(&!destructive_var.clone()); - } - self.credential_has_destructive - .insert(host.clone(), destructive_var); - } - } - - fn encode_filesystem(&mut self) { - for path in self.policy.filesystem_policy.readable_paths() { - let var = Bool::new_const(format!("fs_readable_{path}")); - self.solver.assert(&var); - self.filesystem_readable.insert(path, var); - } - } - - // --- Query helpers --- - - fn false_val() -> Bool { - Bool::from_bool(false) - } - - /// Build a Z3 expression for whether a binary can write to an endpoint. - pub fn can_write_to_endpoint(&self, bpath: &str, eid: &EndpointId) -> Bool { - let ek = eid.key(); - let access_key = format!("{bpath}:{ek}"); - - let has_access = match self.policy_allows.get(&access_key) { - Some(v) => v.clone(), - None => return Self::false_val(), - }; - - let bypass = self - .binary_bypasses_l7 - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - let l7_enforced = self - .l7_enforced - .get(&ek) - .cloned() - .unwrap_or_else(Self::false_val); - let l7_write = self - .l7_allows_write - .get(&ek) - .cloned() - .unwrap_or_else(Self::false_val); - let binary_write = self - .binary_can_write - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - let cred_write = self - .credential_has_write - .get(&eid.host) - .cloned() - .unwrap_or_else(Self::false_val); - - Bool::and(&[ - has_access, - binary_write, - Bool::or(&[!l7_enforced, l7_write, bypass]), - cred_write, - ]) - } - - /// Build a Z3 expression for whether data can be exfiltrated via this path. - pub fn can_exfil_via_endpoint(&self, bpath: &str, eid: &EndpointId) -> Bool { - let ek = eid.key(); - let access_key = format!("{bpath}:{ek}"); - - let has_access = match self.policy_allows.get(&access_key) { - Some(v) => v.clone(), - None => return Self::false_val(), - }; - - let exfil = self - .binary_can_exfil - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - let bypass = self - .binary_bypasses_l7 - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - let l7_enforced = self - .l7_enforced - .get(&ek) - .cloned() - .unwrap_or_else(Self::false_val); - let l7_write = self - .l7_allows_write - .get(&ek) - .cloned() - .unwrap_or_else(Self::false_val); - let http = self - .binary_can_construct_http - .get(bpath) - .cloned() - .unwrap_or_else(Self::false_val); - - Bool::and(&[ - has_access, - exfil, - Bool::or(&[ - Bool::and(&[!l7_enforced, http.clone()]), - Bool::and(&[l7_write, http]), - bypass, - ]), - ]) - } - - /// Check satisfiability of an expression against the base constraints. - pub fn check_sat(&self, expr: &Bool) -> SatResult { - self.solver.push(); - self.solver.assert(expr); - let result = self.solver.check(); - self.solver.pop(1); - result - } -} - -/// Build a reachability model from the given inputs. -pub fn build_model( - policy: PolicyModel, - credentials: CredentialSet, - binary_registry: BinaryRegistry, -) -> ReachabilityModel { - // Ensure the thread-local Z3 context is initialized - let _ctx = Context::thread_local(); - ReachabilityModel::new(policy, credentials, binary_registry) -} diff --git a/crates/openshell-prover/src/policy.rs b/crates/openshell-prover/src/policy.rs index c95285816..cb00525c9 100644 --- a/crates/openshell-prover/src/policy.rs +++ b/crates/openshell-prover/src/policy.rs @@ -7,45 +7,12 @@ //! types) because the prover needs fields like `access`, `protocol`, and //! individual L7 rules that the proto representation strips. -use std::collections::{BTreeMap, HashSet}; -use std::path::Path; +use std::collections::BTreeMap; use miette::{IntoDiagnostic, Result, WrapErr}; use serde::Deserialize; use serde::de::IgnoredAny; -// --------------------------------------------------------------------------- -// Policy intent -// --------------------------------------------------------------------------- - -/// The inferred access intent for an endpoint. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PolicyIntent { - L4Only, - ReadOnly, - ReadWrite, - Full, - Custom, -} - -impl std::fmt::Display for PolicyIntent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::L4Only => write!(f, "l4_only"), - Self::ReadOnly => write!(f, "read_only"), - Self::ReadWrite => write!(f, "read_write"), - Self::Full => write!(f, "full"), - Self::Custom => write!(f, "custom"), - } - } -} - -/// HTTP methods considered to be write operations. -pub const WRITE_METHODS: &[&str] = &["POST", "PUT", "PATCH", "DELETE"]; - -/// All standard HTTP methods. -const ALL_METHODS: &[&str] = &["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"]; - // --------------------------------------------------------------------------- // Serde types — mirrors the YAML schema // --------------------------------------------------------------------------- @@ -246,36 +213,6 @@ impl Endpoint { !self.protocol.is_empty() } - /// The inferred access intent. - pub fn intent(&self) -> PolicyIntent { - if self.protocol.is_empty() { - return PolicyIntent::L4Only; - } - match self.access.as_str() { - "read-only" => PolicyIntent::ReadOnly, - "read-write" => PolicyIntent::ReadWrite, - "full" => PolicyIntent::Full, - _ => { - if self.rules.is_empty() { - return PolicyIntent::Custom; - } - let methods: HashSet = - self.rules.iter().map(|r| r.method.to_uppercase()).collect(); - let read_only: HashSet = ["GET", "HEAD", "OPTIONS"] - .iter() - .map(|s| (*s).to_owned()) - .collect(); - if methods.is_subset(&read_only) { - PolicyIntent::ReadOnly - } else if !methods.contains("DELETE") { - PolicyIntent::ReadWrite - } else { - PolicyIntent::Full - } - } - } - } - /// The effective list of ports for this endpoint. pub fn effective_ports(&self) -> Vec { if !self.ports.is_empty() { @@ -286,38 +223,6 @@ impl Endpoint { } vec![] } - - /// The set of HTTP methods this endpoint allows. Empty means all (L4-only). - pub fn allowed_methods(&self) -> HashSet { - if self.protocol.is_empty() { - return HashSet::new(); // L4-only: all traffic passes - } - match self.access.as_str() { - "read-only" => ["GET", "HEAD", "OPTIONS"] - .iter() - .map(|s| (*s).to_owned()) - .collect(), - "read-write" => ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"] - .iter() - .map(|s| (*s).to_owned()) - .collect(), - "full" => ALL_METHODS.iter().map(|s| (*s).to_owned()).collect(), - _ => { - if !self.rules.is_empty() { - let mut methods = HashSet::new(); - for r in &self.rules { - let m = r.method.to_uppercase(); - if m == "*" { - return ALL_METHODS.iter().map(|s| (*s).to_owned()).collect(); - } - methods.insert(m); - } - return methods; - } - HashSet::new() - } - } - } } /// A binary path entry in a network policy rule. @@ -345,23 +250,6 @@ pub struct FilesystemPolicy { pub extra_fields: Vec, } -impl FilesystemPolicy { - /// All readable paths (union of `read_only` and `read_write`), with workdir - /// added when `include_workdir` is true and not already present. - pub fn readable_paths(&self) -> Vec { - let mut paths: Vec = self - .read_only - .iter() - .chain(self.read_write.iter()) - .cloned() - .collect(); - if self.include_workdir && !paths.iter().any(|p| p == "/sandbox") { - paths.push("/sandbox".to_owned()); - } - paths - } -} - /// The top-level policy model used by the prover. #[derive(Debug, Clone)] pub struct PolicyModel { @@ -395,58 +283,10 @@ impl Default for PolicyModel { } } -impl PolicyModel { - /// All (`policy_name`, endpoint) pairs. - pub fn all_endpoints(&self) -> Vec<(&str, &Endpoint)> { - let mut result = Vec::new(); - for (name, rule) in &self.network_policies { - for ep in &rule.endpoints { - result.push((name.as_str(), ep)); - } - } - result - } - - /// Deduplicated list of all binary paths across all policies. - pub fn all_binaries(&self) -> Vec<&Binary> { - let mut seen = HashSet::new(); - let mut result = Vec::new(); - for rule in self.network_policies.values() { - for b in &rule.binaries { - if seen.insert(&b.path) { - result.push(b); - } - } - } - result - } - - /// All (binary, `policy_name`, endpoint) triples. - pub fn binary_endpoint_pairs(&self) -> Vec<(&Binary, &str, &Endpoint)> { - let mut result = Vec::new(); - for (name, rule) in &self.network_policies { - for b in &rule.binaries { - for ep in &rule.endpoints { - result.push((b, name.as_str(), ep)); - } - } - } - result - } -} - // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- -/// Parse an `OpenShell` policy YAML file into a [`PolicyModel`]. -pub fn parse_policy(path: &Path) -> Result { - let contents = std::fs::read_to_string(path) - .into_diagnostic() - .wrap_err_with(|| format!("reading policy file {}", path.display()))?; - parse_policy_str(&contents) -} - /// Parse a policy YAML string into a [`PolicyModel`]. pub fn parse_policy_str(yaml: &str) -> Result { let raw: PolicyFile = serde_yml::from_str(yaml) diff --git a/crates/openshell-prover/src/queries.rs b/crates/openshell-prover/src/queries.rs deleted file mode 100644 index 24e1402b7..000000000 --- a/crates/openshell-prover/src/queries.rs +++ /dev/null @@ -1,347 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Verification queries. -//! -//! The prover answers four formal questions about a policy and emits one -//! finding category per "yes" answer (see -//! [`crate::finding::category`] for the canonical names). The output is -//! categorical — there is no severity grade. The gateway's -//! `finding_delta` decides which findings are *new* relative to a -//! baseline, and the auto-approval gate triggers when no new findings -//! exist. -//! -//! Categories: -//! -//! 1. **Link-local reach** — any reachable path to a host in -//! `169.254.0.0/16` or `fe80::/10`. Emitted unconditionally: -//! cloud-metadata endpoints serve credentials, so reachability alone -//! is the risk. -//! 2. **L7-bypass + credential** — a binary whose wire protocol the L7 -//! proxy cannot inspect (`git-remote-https`, `ssh`, `nc`) gains reach -//! to a host where a sandbox credential is in scope. -//! 3. **Credential reach expansion** — a binary gains credentialed reach -//! to a host:port it could not reach before. The gateway's delta -//! surfaces only newly-reachable tuples. -//! 4. **Capability expansion** — on a (binary, host, port) that already -//! had credentialed reach, the policy adds a new HTTP method. The -//! gateway's delta surfaces only newly-allowed methods. -//! -//! These categories are intended to be (mostly) mutually exclusive per -//! underlying change: at the gateway, `capability_expansion` paths whose -//! `(binary, host, port)` is also in the `credential_reach_expansion` -//! delta are suppressed, so a brand-new credentialed reach surfaces as -//! one `credential_reach_expansion` finding rather than that plus N -//! capability findings. See `crates/openshell-server/src/grpc/policy.rs`. - -use std::collections::HashSet; -use std::net::IpAddr; - -use z3::SatResult; - -use crate::finding::{ExfilPath, Finding, FindingPath, category}; -use crate::model::ReachabilityModel; - -/// Return true iff the host string parses as an IP in a reserved -/// link-local range (IPv4 `169.254.0.0/16` or IPv6 `fe80::/10`). -/// -/// Hostname-only strings (not parseable as IPs) return false. We don't -/// perform DNS resolution at validation time; the model evaluates the -/// policy as written. -pub(crate) fn is_link_local(host: &str) -> bool { - match host.parse::() { - Ok(IpAddr::V4(v4)) => v4.is_link_local(), - Ok(IpAddr::V6(v6)) => v6.is_unicast_link_local(), - Err(_) => false, - } -} - -/// Return true for static cloud metadata hostnames that should be treated like -/// link-local metadata reach without performing DNS resolution. -pub(crate) fn is_known_metadata_hostname(host: &str) -> bool { - let normalized = host.trim().trim_end_matches('.').to_ascii_lowercase(); - matches!(normalized.as_str(), "metadata.google.internal") -} - -fn is_link_local_or_metadata_host(host: &str) -> bool { - is_link_local(host) || is_known_metadata_hostname(host) -} - -/// Run all four formal queries against the model and emit one finding -/// per category that has at least one path. -/// -/// We deliberately do NOT gate on `filesystem_policy.readable_paths()` -/// being non-empty: the credential itself is the lever for the tracked -/// risks, not anything in `/etc/`. -pub fn check_credential_safety(model: &ReachabilityModel) -> Vec { - let mut reach_paths: Vec = Vec::new(); - let mut capability_paths: Vec = Vec::new(); - let mut bypass_paths: Vec = Vec::new(); - let mut link_local_paths: Vec = Vec::new(); - - for bpath in &model.binary_paths { - let cap = model.binary_registry.get_or_unknown(bpath); - if !cap.can_exfiltrate { - continue; - } - - for eid in &model.endpoints { - let expr = model.can_exfil_via_endpoint(bpath, eid); - if model.check_sat(&expr) != SatResult::Sat { - continue; - } - - let host_is_link_local = is_link_local_or_metadata_host(&eid.host); - let has_credential = !model.credentials.credentials_for_host(&eid.host).is_empty(); - - // Tier 1: link-local/metadata. Unconditional. Other categories - // are not emitted on these hosts — the metadata signal is the - // story. - if host_is_link_local { - link_local_paths.push(ExfilPath { - binary: bpath.clone(), - endpoint_host: eid.host.clone(), - endpoint_port: eid.port, - mechanism: format!( - "Link-local endpoint — {bpath} can reach the host's metadata range \ - (cloud-credential exfiltration territory regardless of declared scopes)" - ), - policy_name: eid.policy_name.clone(), - category: category::LINK_LOCAL_REACH.to_string(), - method: String::new(), - }); - continue; - } - - // Un-credentialed reach is not a tracked risk. - if !has_credential { - continue; - } - - // Tier 2: bypass-L7 binary on a credentialed host. Wire - // protocol cannot be inspected; mark and move on. - if cap.bypasses_l7() { - bypass_paths.push(ExfilPath { - binary: bpath.clone(), - endpoint_host: eid.host.clone(), - endpoint_port: eid.port, - mechanism: format!( - "{} — uses non-HTTP protocol, bypasses L7 inspection, and a credential \ - is in scope for this host", - cap.description - ), - policy_name: eid.policy_name.clone(), - category: category::L7_BYPASS_CREDENTIALED.to_string(), - method: String::new(), - }); - continue; - } - - // Tiers 3 + 4: credentialed L7 reach. We emit both - // credential_reach_expansion and capability_expansion paths - // here; the gateway's delta will keep only the relevant - // category (see `finding_delta` and the suppression rule). - reach_paths.push(ExfilPath { - binary: bpath.clone(), - endpoint_host: eid.host.clone(), - endpoint_port: eid.port, - mechanism: format!( - "Binary {bpath} has credentialed reach to {host}:{port}", - host = eid.host, - port = eid.port, - ), - policy_name: eid.policy_name.clone(), - category: category::CREDENTIAL_REACH_EXPANSION.to_string(), - method: String::new(), - }); - - // One capability_expansion path per allowed method on this - // (binary, host:port) under this specific rule. - let methods = endpoint_allowed_methods_in_rule( - &model.policy, - &eid.policy_name, - &eid.host, - eid.port, - ); - for method in methods { - capability_paths.push(ExfilPath { - binary: bpath.clone(), - endpoint_host: eid.host.clone(), - endpoint_port: eid.port, - mechanism: format!( - "Method {method} allowed for {bpath} on {host}:{port}", - host = eid.host, - port = eid.port, - ), - policy_name: eid.policy_name.clone(), - category: category::CAPABILITY_EXPANSION.to_string(), - method, - }); - } - } - } - - let mut findings = Vec::new(); - if !link_local_paths.is_empty() { - findings.push(build_finding( - category::LINK_LOCAL_REACH, - "Link-Local or Metadata Reach", - "Reach to a host in a link-local range or known metadata hostname — cloud-metadata territory.", - link_local_paths, - vec![ - "Endpoint host is in a link-local range or known metadata hostname \ - (cloud-metadata territory). Sandboxes should not reach these \ - endpoints — reaching them can return host credentials the sandbox \ - should not have." - .to_owned(), - ], - )); - } - if !bypass_paths.is_empty() { - findings.push(build_finding( - category::L7_BYPASS_CREDENTIALED, - "L7-Bypass Binary with Credential in Scope", - "A binary using a wire protocol the L7 proxy cannot inspect has reach to \ - a host where a sandbox credential is in scope.", - bypass_paths, - vec![ - "Binaries using non-HTTP protocols (git, ssh, nc) bypass L7 inspection. \ - Remove these binaries from the policy if credentialed write access is \ - not intended." - .to_owned(), - ], - )); - } - if !reach_paths.is_empty() { - findings.push(build_finding( - category::CREDENTIAL_REACH_EXPANSION, - "Credentialed Reach Expansion", - "A binary gained credentialed reach to a (host, port) it could not reach \ - before.", - reach_paths, - vec![ - "Credentialed reach is a privileged action surface. A human reviewer \ - should confirm the binary should be able to authenticate to this host \ - at all." - .to_owned(), - ], - )); - } - if !capability_paths.is_empty() { - findings.push(build_finding( - category::CAPABILITY_EXPANSION, - "Capability Expansion on Credentialed Host", - "New methods were added on a (binary, host, port) that already had \ - credentialed reach. The agent is changing what the sandbox can do with \ - its credentials.", - capability_paths, - vec![ - "A capability expansion is a stated intent change. The reviewer should \ - confirm the new methods (especially mutating methods like PUT, POST, \ - PATCH, DELETE) are part of the agent's task." - .to_owned(), - ], - )); - } - findings -} - -fn build_finding( - query: &str, - title: &str, - description: &str, - paths: Vec, - remediation: Vec, -) -> Finding { - let n = paths.len(); - Finding { - query: query.to_owned(), - title: title.to_owned(), - // Per-finding description prefixes the count with the category's - // canonical sentence so the audit string is self-describing. - description: format!("{description} ({n} path(s).)"), - paths: paths.into_iter().map(FindingPath::Exfil).collect(), - remediation, - accepted: false, - accepted_reason: String::new(), - } -} - -/// Run all queries (single entry point for end-to-end callers). -pub fn run_all_queries(model: &ReachabilityModel) -> Vec { - check_credential_safety(model) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Allowed HTTP methods for the endpoint in `policy.network_policies[policy_name]` -/// matching `(host, port)`. Returns empty when the rule or endpoint is not -/// found (e.g. SAT path threaded through a stale model). -fn endpoint_allowed_methods_in_rule( - policy: &crate::policy::PolicyModel, - policy_name: &str, - host: &str, - port: u16, -) -> HashSet { - let Some(rule) = policy.network_policies.get(policy_name) else { - return HashSet::new(); - }; - for ep in &rule.endpoints { - if ep.host.eq_ignore_ascii_case(host) && ep.effective_ports().contains(&port) { - return ep.allowed_methods(); - } - } - HashSet::new() -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn is_link_local_recognises_ipv4_169_254() { - assert!(is_link_local("169.254.169.254")); - assert!(is_link_local("169.254.0.1")); - assert!(is_link_local("169.254.255.255")); - } - - #[test] - fn is_link_local_recognises_ipv6_fe80() { - assert!(is_link_local("fe80::1")); - assert!(is_link_local("fe80::abcd:ef01")); - } - - #[test] - fn is_link_local_rejects_non_link_local_ips() { - assert!(!is_link_local("8.8.8.8")); - assert!(!is_link_local("10.0.0.1")); - assert!(!is_link_local("192.168.1.1")); - assert!(!is_link_local("::1")); - assert!(!is_link_local("2001:db8::1")); - } - - #[test] - fn is_link_local_rejects_hostnames() { - assert!(!is_link_local("api.github.com")); - assert!(!is_link_local("")); - } - - #[test] - fn is_known_metadata_hostname_recognises_gcp_variants() { - assert!(is_known_metadata_hostname("metadata.google.internal")); - assert!(is_known_metadata_hostname("METADATA.GOOGLE.INTERNAL")); - assert!(is_known_metadata_hostname("metadata.google.internal.")); - } - - #[test] - fn is_known_metadata_hostname_rejects_other_hostnames() { - assert!(!is_known_metadata_hostname("api.github.com")); - assert!(!is_known_metadata_hostname("")); - } -} diff --git a/crates/openshell-prover/src/registry.rs b/crates/openshell-prover/src/registry.rs deleted file mode 100644 index 63a3fce3b..000000000 --- a/crates/openshell-prover/src/registry.rs +++ /dev/null @@ -1,278 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Binary capability registry — loads YAML descriptors that describe what each -//! binary can do (protocols, exfiltration, HTTP construction, etc.). -//! -//! The built-in registry is embedded at compile time via `include_dir!`. -//! A filesystem override can be provided at runtime for custom registries. - -use std::collections::HashMap; - -use include_dir::{Dir, include_dir}; -use miette::{IntoDiagnostic, Result, WrapErr}; -use serde::Deserialize; - -static EMBEDDED_REGISTRY: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/registry"); - -// --------------------------------------------------------------------------- -// Serde types -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct BinaryCapabilityDef { - binary: String, - #[serde(default)] - description: String, - #[serde(default)] - protocols: Vec, - #[serde(default)] - spawns: Vec, - #[serde(default)] - can_exfiltrate: bool, - #[serde(default)] - exfil_mechanism: String, - #[serde(default)] - can_construct_http: bool, -} - -#[derive(Debug, Deserialize)] -struct BinaryProtocolDef { - #[serde(default)] - name: String, - #[serde(default)] - transport: String, - #[serde(default)] - description: String, - #[serde(default)] - bypasses_l7: bool, - #[serde(default)] - actions: Vec, -} - -#[derive(Debug, Deserialize)] -struct BinaryActionDef { - #[serde(default)] - name: String, - #[serde(default, rename = "type")] - action_type: String, - #[serde(default)] - description: String, -} - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/// Type of action a binary can perform. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ActionType { - Read, - Write, - Destructive, -} - -impl ActionType { - fn from_str(s: &str) -> Self { - match s { - "write" => Self::Write, - "destructive" => Self::Destructive, - _ => Self::Read, - } - } -} - -/// A single action a binary protocol supports. -#[derive(Debug, Clone)] -pub struct BinaryAction { - pub name: String, - pub action_type: ActionType, - pub description: String, -} - -/// A protocol supported by a binary. -#[derive(Debug, Clone)] -pub struct BinaryProtocol { - pub name: String, - pub transport: String, - pub description: String, - pub bypasses_l7: bool, - pub actions: Vec, -} - -impl BinaryProtocol { - /// Whether any action in this protocol is a write or destructive action. - pub fn can_write(&self) -> bool { - self.actions - .iter() - .any(|a| matches!(a.action_type, ActionType::Write | ActionType::Destructive)) - } -} - -/// Capability descriptor for a single binary. -#[derive(Debug, Clone)] -pub struct BinaryCapability { - pub path: String, - pub description: String, - pub protocols: Vec, - pub spawns: Vec, - pub can_exfiltrate: bool, - pub exfil_mechanism: String, - pub can_construct_http: bool, -} - -impl BinaryCapability { - /// Whether any protocol bypasses L7 inspection. - pub fn bypasses_l7(&self) -> bool { - self.protocols.iter().any(|p| p.bypasses_l7) - } - - /// Whether the binary can perform write actions. - pub fn can_write(&self) -> bool { - self.protocols.iter().any(BinaryProtocol::can_write) || self.can_construct_http - } - - /// Short mechanisms by which this binary can write. - pub fn write_mechanisms(&self) -> Vec { - let mut mechanisms = Vec::new(); - for p in &self.protocols { - if p.can_write() { - for a in &p.actions { - if matches!(a.action_type, ActionType::Write | ActionType::Destructive) { - mechanisms.push(format!("{}: {}", p.name, a.name)); - } - } - } - } - if self.can_construct_http { - mechanisms.push("arbitrary HTTP request construction".to_owned()); - } - mechanisms - } -} - -/// Registry of binary capability descriptors. -#[derive(Debug, Clone, Default)] -pub struct BinaryRegistry { - binaries: HashMap, -} - -impl BinaryRegistry { - /// Look up a binary by exact path. - pub fn get(&self, path: &str) -> Option<&BinaryCapability> { - self.binaries.get(path) - } - - /// Look up a binary, falling back to glob matching, then to a conservative - /// unknown descriptor. - pub fn get_or_unknown(&self, path: &str) -> BinaryCapability { - if let Some(cap) = self.binaries.get(path) { - return cap.clone(); - } - for (reg_path, cap) in &self.binaries { - if reg_path.contains('*') - && let Ok(pattern) = glob::Pattern::new(reg_path) - && pattern.matches(path) - { - return cap.clone(); - } - } - BinaryCapability { - path: path.to_owned(), - description: "Unknown binary — not in registry".to_owned(), - protocols: Vec::new(), - spawns: Vec::new(), - can_exfiltrate: true, - exfil_mechanism: String::new(), - can_construct_http: true, - } - } -} - -// --------------------------------------------------------------------------- -// Loading -// --------------------------------------------------------------------------- - -fn parse_binary_capability(contents: &str, source: &str) -> Result { - let raw: BinaryCapabilityDef = serde_yml::from_str(contents) - .into_diagnostic() - .wrap_err_with(|| format!("parsing binary descriptor {source}"))?; - - let protocols = raw - .protocols - .into_iter() - .map(|p| { - let actions = p - .actions - .into_iter() - .map(|a| BinaryAction { - name: a.name, - action_type: ActionType::from_str(&a.action_type), - description: a.description, - }) - .collect(); - BinaryProtocol { - name: p.name, - transport: p.transport, - description: p.description, - bypasses_l7: p.bypasses_l7, - actions, - } - }) - .collect(); - - Ok(BinaryCapability { - path: raw.binary, - description: raw.description, - protocols, - spawns: raw.spawns, - can_exfiltrate: raw.can_exfiltrate, - exfil_mechanism: raw.exfil_mechanism, - can_construct_http: raw.can_construct_http, - }) -} - -/// Load binary registry from the compile-time embedded registry data. -pub fn load_embedded_binary_registry() -> Result { - let mut binaries = HashMap::new(); - if let Some(dir) = EMBEDDED_REGISTRY.get_dir("binaries") { - for file in dir.files() { - if file.path().extension().is_some_and(|ext| ext == "yaml") { - let contents = file.contents_utf8().ok_or_else(|| { - miette::miette!("non-UTF8 registry file: {}", file.path().display()) - })?; - let cap = parse_binary_capability(contents, &file.path().display().to_string())?; - binaries.insert(cap.path.clone(), cap); - } - } - } - Ok(BinaryRegistry { binaries }) -} - -/// Load binary registry from a filesystem directory override. -pub fn load_binary_registry_from_dir(registry_dir: &std::path::Path) -> Result { - let mut binaries = HashMap::new(); - let binaries_dir = registry_dir.join("binaries"); - if binaries_dir.is_dir() { - let entries = std::fs::read_dir(&binaries_dir) - .into_diagnostic() - .wrap_err_with(|| format!("reading directory {}", binaries_dir.display()))?; - for entry in entries { - let entry = entry.into_diagnostic()?; - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "yaml") { - let contents = std::fs::read_to_string(&path) - .into_diagnostic() - .wrap_err_with(|| format!("reading {}", path.display()))?; - let cap = parse_binary_capability(&contents, &path.display().to_string())?; - binaries.insert(cap.path.clone(), cap); - } - } - } - Ok(BinaryRegistry { binaries }) -} - -/// Accessor for the embedded registry (used by credentials module for API descriptors). -pub fn embedded_registry() -> &'static Dir<'static> { - &EMBEDDED_REGISTRY -} diff --git a/crates/openshell-prover/src/report.rs b/crates/openshell-prover/src/report.rs deleted file mode 100644 index f250eb1cd..000000000 --- a/crates/openshell-prover/src/report.rs +++ /dev/null @@ -1,367 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Terminal report rendering (full and compact). -//! -//! The prover output is categorical, not severity-graded. Each finding -//! names *what* the policy change does (e.g., `capability_expansion`); -//! per-path evidence carries the structured detail. There is no HIGH / -//! MEDIUM / CRITICAL grade — the category itself is the signal. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; - -use owo_colors::OwoColorize; - -use crate::finding::{Finding, FindingPath, category}; - -// --------------------------------------------------------------------------- -// Category labels (display strings keyed off `Finding.query`) -// --------------------------------------------------------------------------- - -fn category_label(query: &str) -> &str { - match query { - category::LINK_LOCAL_REACH => "link-local reach", - category::L7_BYPASS_CREDENTIALED => "L7-bypass binary with credential", - category::CREDENTIAL_REACH_EXPANSION => "credentialed reach expansion", - category::CAPABILITY_EXPANSION => "capability expansion on credentialed host", - _ => "unknown finding", - } -} - -// --------------------------------------------------------------------------- -// One-line shorthand (used by the gateway's `validation_result`) -// --------------------------------------------------------------------------- - -/// Render a finding as one or more single-line strings, suitable for -/// embedding in the gateway `validation_result`, demo output, and logs. -/// -/// Shape: `: ` — one line per path. The -/// gateway concatenates these into the chunk's `validation_result` so -/// the reviewer reads what changed without parsing the category enum. -pub fn finding_shorthand(finding: &Finding) -> String { - let mut lines = Vec::new(); - for path in &finding.paths { - let FindingPath::Exfil(p) = path; - lines.push(format_path_line(&finding.query, p)); - } - lines.join("\n ") -} - -fn format_path_line(query: &str, p: &crate::finding::ExfilPath) -> String { - let endpoint = format!("{}:{}", p.endpoint_host, p.endpoint_port); - match query { - category::LINK_LOCAL_REACH => { - format!("link_local_reach: {endpoint} via {}", p.binary) - } - category::L7_BYPASS_CREDENTIALED => { - format!("l7_bypass_credentialed: {endpoint} via {}", p.binary) - } - category::CREDENTIAL_REACH_EXPANSION => { - format!("credential_reach_expansion: {endpoint} via {}", p.binary) - } - category::CAPABILITY_EXPANSION => { - format!( - "capability_expansion: {method} on {endpoint} via {bin}", - method = p.method, - bin = p.binary - ) - } - _ => format!("{query}: {endpoint} via {}", p.binary), - } -} - -// --------------------------------------------------------------------------- -// Compact output (CLI lint mode) -// --------------------------------------------------------------------------- - -/// Render compact output (one-line-per-finding-line for demos and CI). -/// Returns exit code: 0 = pass, 1 = any findings present. -pub fn render_compact(findings: &[Finding], _policy_path: &str, _credentials_path: &str) -> i32 { - let active: Vec<&Finding> = findings.iter().filter(|f| !f.accepted).collect(); - let accepted: Vec<&Finding> = findings.iter().filter(|f| f.accepted).collect(); - - for finding in &active { - for path in &finding.paths { - let FindingPath::Exfil(p) = path; - println!(" {} {}", "•".yellow(), format_path_line(&finding.query, p)); - } - if !finding.paths.is_empty() { - println!(); - } - } - - for finding in &accepted { - println!( - " {} {}", - "ACCEPTED".dimmed(), - category_label(&finding.query).dimmed() - ); - } - if !accepted.is_empty() { - println!(); - } - - let accepted_note = if accepted.is_empty() { - String::new() - } else { - format!(", {} accepted", accepted.len()) - }; - - let path_count: usize = active.iter().map(|f| f.paths.len()).sum(); - if path_count > 0 { - println!( - " {} {path_count} finding path(s) require review{accepted_note}", - " REVIEW ".black().bold().on_yellow() - ); - 1 - } else { - println!( - " {} no findings{accepted_note}", - " PASS ".white().bold().on_green() - ); - 0 - } -} - -// --------------------------------------------------------------------------- -// Full terminal report -// --------------------------------------------------------------------------- - -/// Render a full terminal report with finding panels. -/// Returns exit code: 0 = pass, 1 = any findings present. -pub fn render_report(findings: &[Finding], policy_path: &str, credentials_path: &str) -> i32 { - let policy_name = Path::new(policy_path) - .file_name() - .map_or("policy.yaml", |n| n.to_str().unwrap_or("policy.yaml")); - let creds_name = Path::new(credentials_path) - .file_name() - .map_or("credentials.yaml", |n| { - n.to_str().unwrap_or("credentials.yaml") - }); - - println!(); - println!( - "{}", - "\u{250c}\u{2500}\u{2500} OpenShell Policy Prover \u{2500}\u{2500}\u{2510}".blue() - ); - println!(" Policy: {policy_name}"); - println!(" Credentials: {creds_name}"); - println!(); - - let active: Vec<&Finding> = findings.iter().filter(|f| !f.accepted).collect(); - let accepted: Vec<&Finding> = findings.iter().filter(|f| f.accepted).collect(); - - // Per-category summary - let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); - for f in &active { - *counts.entry(f.query.as_str()).or_default() += f.paths.len(); - } - - if active.is_empty() && accepted.is_empty() { - println!("{}", "No findings. Policy posture is clean.".green().bold()); - return 0; - } - - println!("{}", "Finding Summary".bold().underline()); - for (query, count) in &counts { - println!(" {:>40} {count} path(s)", category_label(query).yellow()); - } - if !accepted.is_empty() { - println!(" {:>40} {}", "ACCEPTED".dimmed(), accepted.len()); - } - println!(); - - for (i, finding) in active.iter().enumerate() { - println!( - "--- Finding #{} [{}] ---", - i + 1, - category_label(&finding.query) - ); - println!(" {}", finding.title.bold()); - println!(" {}", finding.description); - println!(); - render_paths(&finding.paths); - if !finding.remediation.is_empty() { - println!(" {}", "Remediation:".bold()); - for r in &finding.remediation { - println!(" - {r}"); - } - println!(); - } - } - - if !accepted.is_empty() { - println!("{}", "--- Accepted Findings ---".dimmed()); - for finding in &accepted { - println!( - " {} {}", - category_label(&finding.query).dimmed(), - finding.title.dimmed() - ); - println!( - " {}", - format!("Reason: {}", finding.accepted_reason).dimmed() - ); - println!(); - } - } - - let path_count: usize = active.iter().map(|f| f.paths.len()).sum(); - let accepted_note = if accepted.is_empty() { - String::new() - } else { - format!(" ({} accepted)", accepted.len()) - }; - if path_count > 0 { - println!( - "{}{accepted_note}", - "REVIEW \u{2014} prover findings require human attention." - .bold() - .yellow() - ); - 1 - } else { - println!( - "{}{accepted_note}", - "PASS \u{2014} All findings accepted.".bold().green() - ); - 0 - } -} - -fn render_paths(paths: &[FindingPath]) { - if paths.is_empty() { - return; - } - // Group paths by binary for compact display. - let mut by_binary: BTreeMap<&str, Vec<&crate::finding::ExfilPath>> = BTreeMap::new(); - for path in paths { - let FindingPath::Exfil(p) = path; - by_binary.entry(&p.binary).or_default().push(p); - } - for (binary, ps) in &by_binary { - println!(" Binary: {}", binary.cyan()); - let mut endpoints: BTreeSet = BTreeSet::new(); - let mut methods: BTreeSet = BTreeSet::new(); - for p in ps { - endpoints.insert(format!("{}:{}", p.endpoint_host, p.endpoint_port)); - if !p.method.is_empty() { - methods.insert(p.method.clone()); - } - } - println!( - " Endpoints: {}", - endpoints.iter().cloned().collect::>().join(", ") - ); - if !methods.is_empty() { - println!( - " Methods: {}", - methods.iter().cloned().collect::>().join(", ") - ); - } - } - println!(); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::finding::ExfilPath; - - fn exfil_path(category_name: &str, method: &str, host: &str, port: u16) -> ExfilPath { - ExfilPath { - binary: "/usr/bin/curl".to_owned(), - endpoint_host: host.to_owned(), - endpoint_port: port, - mechanism: String::new(), - policy_name: "rule".to_owned(), - category: category_name.to_owned(), - method: method.to_owned(), - } - } - - fn finding_with(category_name: &str, paths: Vec) -> Finding { - Finding { - query: category_name.to_owned(), - title: "test".to_owned(), - description: String::new(), - paths: paths.into_iter().map(FindingPath::Exfil).collect(), - remediation: vec![], - accepted: false, - accepted_reason: String::new(), - } - } - - #[test] - fn shorthand_renders_capability_expansion_with_method() { - let f = finding_with( - category::CAPABILITY_EXPANSION, - vec![exfil_path( - category::CAPABILITY_EXPANSION, - "PUT", - "api.github.com", - 443, - )], - ); - assert_eq!( - finding_shorthand(&f), - "capability_expansion: PUT on api.github.com:443 via /usr/bin/curl" - ); - } - - #[test] - fn shorthand_renders_credential_reach_expansion() { - let f = finding_with( - category::CREDENTIAL_REACH_EXPANSION, - vec![exfil_path( - category::CREDENTIAL_REACH_EXPANSION, - "", - "uploads.github.com", - 443, - )], - ); - assert_eq!( - finding_shorthand(&f), - "credential_reach_expansion: uploads.github.com:443 via /usr/bin/curl" - ); - } - - #[test] - fn shorthand_renders_link_local() { - let f = finding_with( - category::LINK_LOCAL_REACH, - vec![exfil_path( - category::LINK_LOCAL_REACH, - "", - "169.254.169.254", - 80, - )], - ); - assert_eq!( - finding_shorthand(&f), - "link_local_reach: 169.254.169.254:80 via /usr/bin/curl" - ); - } - - #[test] - fn shorthand_renders_l7_bypass() { - let f = finding_with( - category::L7_BYPASS_CREDENTIALED, - vec![exfil_path( - category::L7_BYPASS_CREDENTIALED, - "", - "github.com", - 443, - )], - ); - assert_eq!( - finding_shorthand(&f), - "l7_bypass_credentialed: github.com:443 via /usr/bin/curl" - ); - } -} diff --git a/crates/openshell-prover/testdata/accepted-risks.yaml b/crates/openshell-prover/testdata/accepted-risks.yaml deleted file mode 100644 index 6af5c7146..000000000 --- a/crates/openshell-prover/testdata/accepted-risks.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -accepted_risks: - - query: inference_relay - reason: "Demo environment — inference.local not configured." - accepted_by: demo diff --git a/crates/openshell-prover/testdata/credentials.yaml b/crates/openshell-prover/testdata/credentials.yaml deleted file mode 100644 index b186ccf9a..000000000 --- a/crates/openshell-prover/testdata/credentials.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -credentials: - - name: github-pat - type: github-pat - scopes: - - repo - injected_via: GITHUB_TOKEN - target_hosts: - - api.github.com - - github.com diff --git a/crates/openshell-prover/testdata/empty-policy.yaml b/crates/openshell-prover/testdata/empty-policy.yaml deleted file mode 100644 index 458bab0e4..000000000 --- a/crates/openshell-prover/testdata/empty-policy.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -version: 1 diff --git a/crates/openshell-prover/testdata/policy.yaml b/crates/openshell-prover/testdata/policy.yaml deleted file mode 100644 index e98d95319..000000000 --- a/crates/openshell-prover/testdata/policy.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# 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 - - /proc - - /dev/urandom - - /app - - /etc - - /var/log - read_write: - - /sandbox - - /tmp - - /dev/null - -landlock: - compatibility: best_effort - -process: - run_as_user: sandbox - run_as_group: sandbox - -# Intent: read-only GitHub access for the agent. -# The REST API has L7 enforcement — only GET allowed. -# github.com is included for git clone — "it just needs connectivity." -network_policies: - github_api: - name: github-api - endpoints: - # REST API — L7 enforced, read-only - - host: api.github.com - port: 443 - protocol: rest - enforcement: enforce - access: read-only - # github.com — for git clone. - # No protocol field — L4 only. - - host: github.com - port: 443 - binaries: - - { path: /usr/local/bin/claude } - - { path: /usr/bin/git } - - { path: /usr/bin/curl } - - { path: /usr/bin/gh } - - { path: /sandbox/.uv/python/**/python3* } diff --git a/deploy/docker/Dockerfile.cli-macos b/deploy/docker/Dockerfile.cli-macos index 7dce3a63d..ee6aa88ad 100644 --- a/deploy/docker/Dockerfile.cli-macos +++ b/deploy/docker/Dockerfile.cli-macos @@ -70,34 +70,31 @@ COPY crates/openshell-core/Cargo.toml crates/openshell-core/Cargo.toml COPY crates/openshell-policy/Cargo.toml crates/openshell-policy/Cargo.toml COPY crates/openshell-providers/Cargo.toml crates/openshell-providers/Cargo.toml COPY crates/openshell-tui/Cargo.toml crates/openshell-tui/Cargo.toml -COPY crates/openshell-prover/Cargo.toml crates/openshell-prover/Cargo.toml COPY crates/openshell-core/build.rs crates/openshell-core/build.rs COPY proto/ proto/ # Scope workspace to CLI crates only — avoids compiling aws-lc-sys (pulled # by russh in openshell-sandbox/openshell-server) which is difficult to # cross-compile and unnecessary for the CLI binary. -RUN sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-cli", "crates/openshell-core", "crates/openshell-bootstrap", "crates/openshell-policy", "crates/openshell-prover", "crates/openshell-providers", "crates/openshell-tui"]|' Cargo.toml +RUN sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-cli", "crates/openshell-core", "crates/openshell-bootstrap", "crates/openshell-policy", "crates/openshell-providers", "crates/openshell-tui"]|' Cargo.toml RUN mkdir -p crates/openshell-cli/src \ crates/openshell-core/src \ crates/openshell-bootstrap/src \ crates/openshell-policy/src \ crates/openshell-providers/src \ - crates/openshell-prover/src \ crates/openshell-tui/src && \ echo "fn main() {}" > crates/openshell-cli/src/main.rs && \ touch crates/openshell-core/src/lib.rs && \ touch crates/openshell-bootstrap/src/lib.rs && \ touch crates/openshell-policy/src/lib.rs && \ touch crates/openshell-providers/src/lib.rs && \ - touch crates/openshell-prover/src/lib.rs && \ touch crates/openshell-tui/src/lib.rs RUN --mount=type=cache,id=cargo-registry-cli-macos,sharing=locked,target=/root/.cargo/registry \ --mount=type=cache,id=cargo-git-cli-macos,sharing=locked,target=/root/.cargo/git \ --mount=type=cache,id=cargo-target-cli-macos-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - cargo build --release --target aarch64-apple-darwin -p openshell-cli --features bundled-z3 2>/dev/null || true + cargo build --release --target aarch64-apple-darwin -p openshell-cli 2>/dev/null || true # --------------------------------------------------------------------------- # Stage 2: real build @@ -126,7 +123,7 @@ RUN --mount=type=cache,id=cargo-registry-cli-macos,sharing=locked,target=/root/. if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ fi && \ - cargo build --release --target aarch64-apple-darwin -p openshell-cli --features bundled-z3 && \ + cargo build --release --target aarch64-apple-darwin -p openshell-cli && \ cp target/aarch64-apple-darwin/release/openshell /openshell FROM scratch AS binary diff --git a/deploy/docker/Dockerfile.python-wheels b/deploy/docker/Dockerfile.python-wheels index e93bf8f22..fe58e3fcc 100644 --- a/deploy/docker/Dockerfile.python-wheels +++ b/deploy/docker/Dockerfile.python-wheels @@ -75,7 +75,7 @@ RUN --mount=type=cache,id=cargo-registry-python-wheels-${TARGETARCH},sharing=loc --mount=type=cache,id=cargo-git-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ --mount=type=cache,id=cargo-target-python-wheels-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ --mount=type=cache,id=sccache-python-wheels-${TARGETARCH},sharing=locked,target=/tmp/sccache \ - . cross-build.sh && cargo_cross_build --release -p openshell-cli --features bundled-z3 2>/dev/null || true + . cross-build.sh && cargo_cross_build --release -p openshell-cli 2>/dev/null || true # Copy actual source code and Python packaging files. COPY crates/ crates/ @@ -109,7 +109,7 @@ RUN --mount=type=cache,id=cargo-registry-python-wheels-${TARGETARCH},sharing=loc if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ fi && \ - maturin build --release --target "${CARGO_BUILD_TARGET}" --features bundled-z3 --out /wheels + maturin build --release --target "${CARGO_BUILD_TARGET}" --out /wheels FROM scratch AS wheels COPY --from=builder /wheels/*.whl / diff --git a/deploy/docker/Dockerfile.python-wheels-macos b/deploy/docker/Dockerfile.python-wheels-macos index 45194b069..15ba58e0e 100644 --- a/deploy/docker/Dockerfile.python-wheels-macos +++ b/deploy/docker/Dockerfile.python-wheels-macos @@ -88,7 +88,7 @@ RUN mkdir -p crates/openshell-cli/src crates/openshell-core/src crates/openshell RUN --mount=type=cache,id=cargo-registry-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \ --mount=type=cache,id=cargo-git-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/git \ --mount=type=cache,id=cargo-target-python-wheels-macos-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ - cargo build --release --target aarch64-apple-darwin -p openshell-cli --features bundled-z3 2>/dev/null || true + cargo build --release --target aarch64-apple-darwin -p openshell-cli 2>/dev/null || true # Copy actual source code and Python packaging files. COPY crates/ crates/ @@ -120,7 +120,7 @@ RUN --mount=type=cache,id=cargo-registry-python-wheels-macos-${TARGETARCH},shari if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \ fi && \ - maturin build --release --target aarch64-apple-darwin --features bundled-z3 --out /wheels + maturin build --release --target aarch64-apple-darwin --out /wheels FROM scratch AS wheels COPY --from=builder /wheels/*.whl / diff --git a/scripts/bin/openshell b/scripts/bin/openshell index 23000bc31..d4878b809 100755 --- a/scripts/bin/openshell +++ b/scripts/bin/openshell @@ -45,8 +45,6 @@ else return 0 ;; crates/openshell-cli/*|crates/openshell-core/*|crates/openshell-bootstrap/*) return 0 ;; - crates/openshell-prover/*) - return 0 ;; crates/openshell-policy/*|crates/openshell-providers/*|crates/openshell-tui/*) return 0 ;; *) @@ -95,26 +93,6 @@ fi if [[ "$needs_build" == "1" ]]; then echo "Recompiling openshell..." >&2 build_args=(--package openshell-cli --quiet) - if ! command -v pkg-config >/dev/null 2>&1 || ! pkg-config --exists z3 >/dev/null 2>&1; then - z3_prefix="" - if command -v brew >/dev/null 2>&1; then - z3_prefix=$(brew --prefix z3 2>/dev/null || true) - fi - - for candidate in "$z3_prefix" /opt/homebrew/opt/z3 /usr/local/opt/z3; do - if [[ -n "$candidate" && -f "$candidate/include/z3.h" && -d "$candidate/lib" ]]; then - echo "Using local Z3 from ${candidate} for CLI build." >&2 - export Z3_SYS_Z3_HEADER="${candidate}/include/z3.h" - export Z3_LIBRARY_PATH_OVERRIDE="${candidate}/lib" - break - fi - done - - if [[ -z "${Z3_SYS_Z3_HEADER:-}" ]]; then - echo "Falling back to bundled Z3 for local CLI build." >&2 - build_args+=(--features bundled-z3) - fi - fi cargo build "${build_args[@]}" # Persist state after successful build mkdir -p "$(dirname "$STATE_FILE")" @@ -139,8 +117,6 @@ if [[ "$needs_build" == "1" ]]; then return 0 ;; crates/openshell-cli/*|crates/openshell-core/*|crates/openshell-bootstrap/*) return 0 ;; - crates/openshell-prover/*) - return 0 ;; crates/openshell-policy/*|crates/openshell-providers/*|crates/openshell-tui/*) return 0 ;; *) diff --git a/tasks/ci.toml b/tasks/ci.toml index 9696517af..6b3b8b215 100644 --- a/tasks/ci.toml +++ b/tasks/ci.toml @@ -30,7 +30,7 @@ hide = true ["build:rust:snap"] description = "Build release Rust binaries consumed by the hand-staged snap" run = [ - "cargo build --release -p openshell-cli --features bundled-z3", + "cargo build --release -p openshell-cli", "cargo build --release -p openshell-server --features bundled-z3", "cargo build --release -p openshell-sandbox", ] diff --git a/tasks/python.toml b/tasks/python.toml index b95d96671..554132aa0 100644 --- a/tasks/python.toml +++ b/tasks/python.toml @@ -76,7 +76,7 @@ fi rm -rf "$WHEEL_OUTPUT_DIR" mkdir -p "$WHEEL_OUTPUT_DIR" -uv run maturin build --release --features bundled-z3 --out "$WHEEL_OUTPUT_DIR" +uv run maturin build --release --out "$WHEEL_OUTPUT_DIR" ls -la "$WHEEL_OUTPUT_DIR"/*.whl '''