mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-22 06:19:47 +08:00
Narrow OPP to two yes/no security questions: 1. Can data leave this sandbox? (exfiltration) 2. Can the agent write despite read-only intent? (write bypass) Removes 4 advisory/lint queries (overpermissive methods, L4 gaps, binary inheritance, inference relay), 3 output modes (Mermaid, topology, HTML), and fixes 3 bugs (include_workdir not used in exfil analysis, __all__ broken when protos missing, CLI PYTHONPATH). ~40% line reduction (2900 -> 1769 lines). Signed-off-by: Alexander Watson <zredlined@gmail.com>
225 lines
6.7 KiB
Python
225 lines
6.7 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Parse OpenShell sandbox policy YAML into typed dataclasses."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import TYPE_CHECKING
|
|
|
|
import yaml
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
class PolicyIntent(Enum):
|
|
L4_ONLY = "l4_only"
|
|
READ_ONLY = "read_only"
|
|
READ_WRITE = "read_write"
|
|
FULL = "full"
|
|
CUSTOM = "custom"
|
|
|
|
|
|
@dataclass
|
|
class L7Rule:
|
|
method: str
|
|
path: str
|
|
command: str = ""
|
|
|
|
|
|
@dataclass
|
|
class Endpoint:
|
|
host: str
|
|
port: int
|
|
ports: list[int] = field(default_factory=list)
|
|
protocol: str = ""
|
|
tls: str = ""
|
|
enforcement: str = "audit"
|
|
access: str = ""
|
|
rules: list[L7Rule] = field(default_factory=list)
|
|
allowed_ips: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def is_l7_enforced(self) -> bool:
|
|
return bool(self.protocol)
|
|
|
|
@property
|
|
def intent(self) -> PolicyIntent:
|
|
if not self.protocol:
|
|
return PolicyIntent.L4_ONLY
|
|
if self.access == "read-only":
|
|
return PolicyIntent.READ_ONLY
|
|
if self.access == "read-write":
|
|
return PolicyIntent.READ_WRITE
|
|
if self.access == "full":
|
|
return PolicyIntent.FULL
|
|
if self.rules:
|
|
methods = {r.method.upper() for r in self.rules}
|
|
if methods <= {"GET", "HEAD", "OPTIONS"}:
|
|
return PolicyIntent.READ_ONLY
|
|
if "DELETE" not in methods:
|
|
return PolicyIntent.READ_WRITE
|
|
return PolicyIntent.FULL
|
|
return PolicyIntent.CUSTOM
|
|
|
|
@property
|
|
def effective_ports(self) -> list[int]:
|
|
if self.ports:
|
|
return self.ports
|
|
if self.port:
|
|
return [self.port]
|
|
return []
|
|
|
|
@property
|
|
def allowed_methods(self) -> set[str]:
|
|
"""Return the set of HTTP methods this endpoint allows. Empty means all (L4-only)."""
|
|
if not self.protocol:
|
|
return set() # L4-only: all traffic passes
|
|
if self.access == "read-only":
|
|
return {"GET", "HEAD", "OPTIONS"}
|
|
if self.access == "read-write":
|
|
return {"GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"}
|
|
if self.access == "full":
|
|
return {"GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"}
|
|
if self.rules:
|
|
methods = set()
|
|
for r in self.rules:
|
|
m = r.method.upper()
|
|
if m == "*":
|
|
return {"GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"}
|
|
methods.add(m)
|
|
return methods
|
|
return set()
|
|
|
|
|
|
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
|
|
|
|
@dataclass
|
|
class Binary:
|
|
path: str
|
|
|
|
|
|
@dataclass
|
|
class NetworkPolicyRule:
|
|
name: str
|
|
endpoints: list[Endpoint] = field(default_factory=list)
|
|
binaries: list[Binary] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class FilesystemPolicy:
|
|
include_workdir: bool = True
|
|
workdir: str = "/sandbox"
|
|
read_only: list[str] = field(default_factory=list)
|
|
read_write: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def readable_paths(self) -> list[str]:
|
|
paths = self.read_only + self.read_write
|
|
if self.include_workdir and self.workdir not in paths:
|
|
paths = [self.workdir, *paths]
|
|
return paths
|
|
|
|
|
|
@dataclass
|
|
class PolicyModel:
|
|
version: int = 1
|
|
filesystem_policy: FilesystemPolicy = field(default_factory=FilesystemPolicy)
|
|
network_policies: dict[str, NetworkPolicyRule] = field(default_factory=dict)
|
|
|
|
@property
|
|
def all_endpoints(self) -> list[tuple[str, Endpoint]]:
|
|
"""Return all (policy_name, endpoint) pairs."""
|
|
result = []
|
|
for name, rule in self.network_policies.items():
|
|
for ep in rule.endpoints:
|
|
result.append((name, ep))
|
|
return result
|
|
|
|
@property
|
|
def all_binaries(self) -> list[Binary]:
|
|
"""Return deduplicated list of all binaries across all policies."""
|
|
seen = set()
|
|
result = []
|
|
for rule in self.network_policies.values():
|
|
for b in rule.binaries:
|
|
if b.path not in seen:
|
|
seen.add(b.path)
|
|
result.append(b)
|
|
return result
|
|
|
|
@property
|
|
def binary_endpoint_pairs(self) -> list[tuple[Binary, str, Endpoint]]:
|
|
"""Return all (binary, policy_name, endpoint) triples."""
|
|
result = []
|
|
for name, rule in self.network_policies.items():
|
|
for b in rule.binaries:
|
|
for ep in rule.endpoints:
|
|
result.append((b, name, ep))
|
|
return result
|
|
|
|
|
|
def parse_policy(path: Path) -> PolicyModel:
|
|
"""Parse an OpenShell policy YAML file into a PolicyModel."""
|
|
with open(path) as f: # noqa: PTH123
|
|
raw = yaml.safe_load(f)
|
|
|
|
if not raw:
|
|
return PolicyModel()
|
|
|
|
fs_raw = raw.get("filesystem_policy", {}) or {}
|
|
fs = FilesystemPolicy(
|
|
include_workdir=fs_raw.get("include_workdir", True),
|
|
workdir=fs_raw.get("workdir", "/sandbox"),
|
|
read_only=fs_raw.get("read_only", []),
|
|
read_write=fs_raw.get("read_write", []),
|
|
)
|
|
|
|
network_policies = {}
|
|
for key, rule_raw in (raw.get("network_policies") or {}).items():
|
|
endpoints = []
|
|
for ep_raw in rule_raw.get("endpoints", []):
|
|
rules = []
|
|
for r in ep_raw.get("rules", []):
|
|
allow = r.get("allow", {})
|
|
rules.append(
|
|
L7Rule(
|
|
method=allow.get("method", ""),
|
|
path=allow.get("path", ""),
|
|
command=allow.get("command", ""),
|
|
)
|
|
)
|
|
endpoints.append(
|
|
Endpoint(
|
|
host=ep_raw.get("host", ""),
|
|
port=ep_raw.get("port", 0),
|
|
ports=ep_raw.get("ports", []),
|
|
protocol=ep_raw.get("protocol", ""),
|
|
tls=ep_raw.get("tls", ""),
|
|
enforcement=ep_raw.get("enforcement", "audit"),
|
|
access=ep_raw.get("access", ""),
|
|
rules=rules,
|
|
allowed_ips=ep_raw.get("allowed_ips", []),
|
|
)
|
|
)
|
|
|
|
binaries = []
|
|
for b_raw in rule_raw.get("binaries", []):
|
|
binaries.append(Binary(path=b_raw.get("path", "")))
|
|
|
|
network_policies[key] = NetworkPolicyRule(
|
|
name=rule_raw.get("name", key),
|
|
endpoints=endpoints,
|
|
binaries=binaries,
|
|
)
|
|
|
|
return PolicyModel(
|
|
version=raw.get("version", 1),
|
|
filesystem_policy=fs,
|
|
network_policies=network_policies,
|
|
)
|