mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
refactor(isolation): make confirmation backend-neutral
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
Generated
+1
@@ -4397,6 +4397,7 @@ dependencies = [
|
||||
"openshell-core",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
@@ -69,8 +69,12 @@ replacement from granting authority.
|
||||
3. `openshell-supervisor` loads policy and runtime settings from the gateway,
|
||||
attaches to the sandbox, and verifies the driver's generation and evidence.
|
||||
4. The sandbox installs its seccomp notification broker and Landlock baseline,
|
||||
then reports measured confirmation. The supervisor must accept that evidence
|
||||
before it sends the launch permit.
|
||||
validates its mechanism-specific audit evidence, and reports backend-neutral
|
||||
enforcement properties. The supervisor must accept those properties and
|
||||
their immutable session and resource binding before it sends the launch
|
||||
permit. Other isolation backends may establish the same properties with
|
||||
different mechanisms and retain their detailed evidence in backend-owned
|
||||
audit data.
|
||||
5. The sandbox starts the canonical process through its single workload
|
||||
launcher. The supervisor starts SSH and registers its gateway session.
|
||||
6. Exec, signaling, PTY, DNS, TCP, and loopback-forwarding operations cross the
|
||||
|
||||
@@ -14,6 +14,7 @@ repository.workspace = true
|
||||
openshell-core = { path = "../openshell-core", default-features = false }
|
||||
async-trait = "0.1"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
//!
|
||||
//! Each transition consumes the prior state by value (`self: Box<Self>`).
|
||||
//! Trusted backend implementations construct confirmation through a validating
|
||||
//! constructor; the supervisor cannot obtain a ready boundary without evidence.
|
||||
//! constructor; the supervisor cannot obtain a ready boundary without confirmed
|
||||
//! backend-neutral enforcement properties.
|
||||
//! The supervisor holds no `match`/downcast on concrete backends: the
|
||||
//! registry is the only lookup by `backend_name`, and everything past it is a
|
||||
//! `Box<dyn _>` / `Arc<dyn _>`.
|
||||
@@ -385,46 +386,6 @@ pub trait BoundBoundary: Send {
|
||||
async fn confirm(self: Box<Self>) -> Result<ConfirmedBoundary, BackendError>;
|
||||
}
|
||||
|
||||
/// Capability masks measured from `/proc/<pid>/status`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CapabilityEvidence {
|
||||
pub inheritable: u64,
|
||||
pub permitted: u64,
|
||||
pub effective: u64,
|
||||
pub bounding: u64,
|
||||
pub ambient: u64,
|
||||
}
|
||||
|
||||
impl CapabilityEvidence {
|
||||
/// True only when every Linux capability set is empty.
|
||||
#[must_use]
|
||||
pub const fn is_empty(self) -> bool {
|
||||
self.inheritable == 0
|
||||
&& self.permitted == 0
|
||||
&& self.effective == 0
|
||||
&& self.bounding == 0
|
||||
&& self.ambient == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Active seccomp notification and socket-broker evidence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "each independently measured kernel operation is reported explicitly"
|
||||
)]
|
||||
pub struct SeccompEvidence {
|
||||
pub new_listener: bool,
|
||||
pub notification_round_trip: bool,
|
||||
pub id_validation: bool,
|
||||
pub addfd_send: bool,
|
||||
pub retained_socket_operation: bool,
|
||||
pub proc_fd_identity: bool,
|
||||
pub task_memory_read: bool,
|
||||
pub task_memory_write: bool,
|
||||
pub cancellation: bool,
|
||||
}
|
||||
|
||||
/// Driver-owned evidence that the mandatory outer network fence is installed.
|
||||
///
|
||||
/// The sandbox cannot observe the Docker daemon, Kubernetes API, or VM device
|
||||
@@ -512,29 +473,67 @@ impl DriverFenceEvidence {
|
||||
}
|
||||
}
|
||||
|
||||
/// Measured sandbox-owned evidence produced before agent launch.
|
||||
/// A backend-neutral security property established before agent launch.
|
||||
///
|
||||
/// `mechanism` is diagnostic and audit metadata. It never authorizes launch;
|
||||
/// the registered backend is responsible for validating its mechanism-specific
|
||||
/// evidence before setting `enforced`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "confirmation preserves independently measured security results"
|
||||
)]
|
||||
pub struct SandboxConfirmEvidence {
|
||||
pub struct EnforcedProperty {
|
||||
pub enforced: bool,
|
||||
pub mechanism: String,
|
||||
}
|
||||
|
||||
impl EnforcedProperty {
|
||||
#[must_use]
|
||||
pub fn new(enforced: bool, mechanism: impl Into<String>) -> Self {
|
||||
Self {
|
||||
enforced,
|
||||
mechanism: mechanism.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self, name: &str) -> Result<(), BackendError> {
|
||||
if self.enforced && !self.mechanism.trim().is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(BackendError::Confirm(format!(
|
||||
"{name} is not enforced or has no declared mechanism"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Security properties every isolation backend establishes before launch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BoundaryProperties {
|
||||
pub filesystem_confinement: EnforcedProperty,
|
||||
pub egress_interception: EnforcedProperty,
|
||||
pub request_attribution: EnforcedProperty,
|
||||
pub privilege_floor: EnforcedProperty,
|
||||
}
|
||||
|
||||
impl BoundaryProperties {
|
||||
fn validate(&self) -> Result<(), BackendError> {
|
||||
self.filesystem_confinement
|
||||
.validate("filesystem confinement")?;
|
||||
self.egress_interception.validate("egress interception")?;
|
||||
self.request_attribution.validate("request attribution")?;
|
||||
self.privilege_floor.validate("privilege floor")
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-boundary confirmation produced before agent launch.
|
||||
///
|
||||
/// Common validation binds the confirmation to the admitted workload and
|
||||
/// checks backend-neutral properties. `backend_audit` remains opaque to this
|
||||
/// crate; the registered backend owns its schema and validates it before
|
||||
/// constructing [`ConfirmedBoundary`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BoundaryConfirmation {
|
||||
pub generation: String,
|
||||
pub identity: ResolvedWorkloadIdentity,
|
||||
pub capabilities: CapabilityEvidence,
|
||||
pub no_new_privileges: bool,
|
||||
pub sandbox_dumpable: bool,
|
||||
pub child_dumpable: bool,
|
||||
pub core_limit_zero: bool,
|
||||
pub native_architecture: String,
|
||||
pub kernel_release: String,
|
||||
pub seccomp: SeccompEvidence,
|
||||
pub landlock_abi: u32,
|
||||
pub landlock_allow_deny: bool,
|
||||
pub udp_dns_round_trip: bool,
|
||||
pub tcp_dns_round_trip: bool,
|
||||
pub tcp_allow_round_trip: bool,
|
||||
pub tcp_deny_round_trip: bool,
|
||||
pub properties: BoundaryProperties,
|
||||
pub authenticated_supervisor: bool,
|
||||
pub session_id: SandboxSessionId,
|
||||
pub driver_fence: DriverFenceEvidence,
|
||||
@@ -542,33 +541,15 @@ pub struct SandboxConfirmEvidence {
|
||||
/// Sandbox Runtime exits.
|
||||
pub runtime_exit_terminates_workload: bool,
|
||||
pub resource_claims: BTreeMap<String, String>,
|
||||
pub backend_audit: serde_json::Value,
|
||||
}
|
||||
|
||||
impl SandboxConfirmEvidence {
|
||||
/// Validate the security-critical evidence required before launch.
|
||||
impl BoundaryConfirmation {
|
||||
/// Validate common security properties and immutable launch binding.
|
||||
pub fn validate(&self, expected: &ResolvedWorkloadIdentity) -> Result<(), BackendError> {
|
||||
self.driver_fence.validate()?;
|
||||
self.properties.validate()?;
|
||||
let complete = &self.identity == expected
|
||||
&& self.capabilities.is_empty()
|
||||
&& self.no_new_privileges
|
||||
&& !self.sandbox_dumpable
|
||||
&& self.child_dumpable
|
||||
&& self.core_limit_zero
|
||||
&& self.seccomp.new_listener
|
||||
&& self.seccomp.notification_round_trip
|
||||
&& self.seccomp.id_validation
|
||||
&& self.seccomp.addfd_send
|
||||
&& self.seccomp.retained_socket_operation
|
||||
&& self.seccomp.proc_fd_identity
|
||||
&& self.seccomp.task_memory_read
|
||||
&& self.seccomp.task_memory_write
|
||||
&& self.seccomp.cancellation
|
||||
&& self.landlock_abi >= 3
|
||||
&& self.landlock_allow_deny
|
||||
&& self.udp_dns_round_trip
|
||||
&& self.tcp_dns_round_trip
|
||||
&& self.tcp_allow_round_trip
|
||||
&& self.tcp_deny_round_trip
|
||||
&& self.authenticated_supervisor
|
||||
&& self.runtime_exit_terminates_workload
|
||||
&& !self.generation.is_empty();
|
||||
@@ -576,41 +557,45 @@ impl SandboxConfirmEvidence {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(BackendError::Confirm(
|
||||
"sandbox confirmation evidence is incomplete or mismatched".to_string(),
|
||||
"boundary confirmation is incomplete or mismatched".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ready boundary paired with the evidence measured by `confirm`.
|
||||
/// Ready boundary paired with the confirmation established by `confirm`.
|
||||
pub struct ConfirmedBoundary {
|
||||
boundary: Box<dyn ReadyBoundary>,
|
||||
evidence: SandboxConfirmEvidence,
|
||||
confirmation: BoundaryConfirmation,
|
||||
}
|
||||
|
||||
impl ConfirmedBoundary {
|
||||
/// Construct confirmation after checking measured evidence against the
|
||||
/// immutable identity admitted at attach time.
|
||||
/// Construct confirmation after checking backend-neutral properties and
|
||||
/// immutable identity binding.
|
||||
///
|
||||
/// Backend implementations are trusted to collect this evidence and bind
|
||||
/// it to their resource. This constructor enforces the common requirements
|
||||
/// without requiring those implementations to live in the interface crate.
|
||||
/// Backend implementations are trusted to validate their audit evidence and
|
||||
/// bind this confirmation to their resource. This constructor enforces the
|
||||
/// common requirements without requiring those implementations to live in
|
||||
/// the interface crate.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if evidence is incomplete or the identity does not match.
|
||||
/// Returns an error if confirmation is incomplete or the identity does not match.
|
||||
pub fn try_new(
|
||||
boundary: Box<dyn ReadyBoundary>,
|
||||
evidence: SandboxConfirmEvidence,
|
||||
confirmation: BoundaryConfirmation,
|
||||
expected: &ResolvedWorkloadIdentity,
|
||||
) -> Result<Self, BackendError> {
|
||||
evidence.validate(expected)?;
|
||||
Ok(Self { boundary, evidence })
|
||||
confirmation.validate(expected)?;
|
||||
Ok(Self {
|
||||
boundary,
|
||||
confirmation,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the measured evidence carried by this confirmed state.
|
||||
pub fn evidence(&self) -> &SandboxConfirmEvidence {
|
||||
&self.evidence
|
||||
/// Return the record carried by this confirmed state.
|
||||
pub fn confirmation(&self) -> &BoundaryConfirmation {
|
||||
&self.confirmation
|
||||
}
|
||||
|
||||
/// Consume confirmation and advance to the sole launch-capable state.
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
//! `start_agent` -> Running. Nothing untrusted runs inside the boundary until it
|
||||
//! is confirmed ready. This is enforced *by construction*: each transition
|
||||
//! consumes the prior state by value. Trusted backends construct confirmation
|
||||
//! through [`contract::ConfirmedBoundary::try_new`], which checks common evidence
|
||||
//! before the supervisor can obtain a [`contract::ReadyBoundary`].
|
||||
//! through [`contract::ConfirmedBoundary::try_new`], which checks common
|
||||
//! enforcement properties and immutable launch binding before the supervisor
|
||||
//! can obtain a [`contract::ReadyBoundary`]. Mechanism-specific evidence stays
|
||||
//! owned by the backend that can interpret it.
|
||||
//!
|
||||
//! [`AgentSpec`] is shared between the workload definition the supervisor
|
||||
//! submits and the [`contract::SandboxContext`] that `attach` binds to a
|
||||
|
||||
@@ -239,7 +239,7 @@ impl<K: MockKind> BoundBoundary for MockBound<K> {
|
||||
async fn confirm(self: Box<Self>) -> Result<ConfirmedBoundary, BackendError> {
|
||||
ConfirmedBoundary::try_new(
|
||||
Box::new(MockReady::<K> { _k: PhantomData }),
|
||||
confirmation_evidence(),
|
||||
confirmation(),
|
||||
&workload_identity(),
|
||||
)
|
||||
}
|
||||
@@ -368,40 +368,16 @@ fn workload_identity() -> ResolvedWorkloadIdentity {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn confirmation_evidence() -> SandboxConfirmEvidence {
|
||||
SandboxConfirmEvidence {
|
||||
fn confirmation() -> BoundaryConfirmation {
|
||||
BoundaryConfirmation {
|
||||
generation: "generation-1".to_string(),
|
||||
identity: workload_identity(),
|
||||
capabilities: CapabilityEvidence {
|
||||
inheritable: 0,
|
||||
permitted: 0,
|
||||
effective: 0,
|
||||
bounding: 0,
|
||||
ambient: 0,
|
||||
properties: BoundaryProperties {
|
||||
filesystem_confinement: EnforcedProperty::new(true, "mock-filesystem"),
|
||||
egress_interception: EnforcedProperty::new(true, "mock-egress"),
|
||||
request_attribution: EnforcedProperty::new(true, "mock-attribution"),
|
||||
privilege_floor: EnforcedProperty::new(true, "mock-privilege-floor"),
|
||||
},
|
||||
no_new_privileges: true,
|
||||
sandbox_dumpable: false,
|
||||
child_dumpable: true,
|
||||
core_limit_zero: true,
|
||||
native_architecture: std::env::consts::ARCH.to_string(),
|
||||
kernel_release: "test".to_string(),
|
||||
seccomp: SeccompEvidence {
|
||||
new_listener: true,
|
||||
notification_round_trip: true,
|
||||
id_validation: true,
|
||||
addfd_send: true,
|
||||
retained_socket_operation: true,
|
||||
proc_fd_identity: true,
|
||||
task_memory_read: true,
|
||||
task_memory_write: true,
|
||||
cancellation: true,
|
||||
},
|
||||
landlock_abi: 3,
|
||||
landlock_allow_deny: true,
|
||||
udp_dns_round_trip: true,
|
||||
tcp_dns_round_trip: true,
|
||||
tcp_allow_round_trip: true,
|
||||
tcp_deny_round_trip: true,
|
||||
authenticated_supervisor: true,
|
||||
session_id: SandboxSessionId::new(),
|
||||
driver_fence: DriverFenceEvidence::Vm {
|
||||
@@ -410,6 +386,7 @@ fn confirmation_evidence() -> SandboxConfirmEvidence {
|
||||
},
|
||||
runtime_exit_terminates_workload: true,
|
||||
resource_claims: BTreeMap::new(),
|
||||
backend_audit: serde_json::json!({"backend": "mock"}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +435,7 @@ async fn drive(
|
||||
let _ingress = bound.network_mediation_source();
|
||||
assert_eq!(bound.host_gateway_ip(), None);
|
||||
let confirmed = bound.confirm().await?;
|
||||
confirmed.evidence().validate(&sandbox_ctx().identity)?;
|
||||
confirmed.confirmation().validate(&sandbox_ctx().identity)?;
|
||||
confirmed.into_boundary().start_agent().await
|
||||
}
|
||||
|
||||
@@ -536,12 +513,12 @@ async fn one_driver_runs_both_backends() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_constructor_rejects_incomplete_evidence() {
|
||||
let mut evidence = confirmation_evidence();
|
||||
evidence.seccomp.cancellation = false;
|
||||
fn confirmation_constructor_rejects_unenforced_property() {
|
||||
let mut confirmation = confirmation();
|
||||
confirmation.properties.egress_interception.enforced = false;
|
||||
let result = ConfirmedBoundary::try_new(
|
||||
Box::new(MockReady::<Primary> { _k: PhantomData }),
|
||||
evidence,
|
||||
confirmation,
|
||||
&workload_identity(),
|
||||
);
|
||||
assert!(matches!(result, Err(BackendError::Confirm(_))));
|
||||
@@ -559,7 +536,7 @@ fn confirmation_constructor_rejects_another_workload_identity() {
|
||||
.unwrap();
|
||||
let result = ConfirmedBoundary::try_new(
|
||||
Box::new(MockReady::<Primary> { _k: PhantomData }),
|
||||
confirmation_evidence(),
|
||||
confirmation(),
|
||||
&expected,
|
||||
);
|
||||
assert!(matches!(result, Err(BackendError::Confirm(_))));
|
||||
@@ -842,16 +819,16 @@ fn workload_identity_rejects_root_and_normalizes_groups() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_evidence_rejects_identity_or_posture_drift() {
|
||||
fn confirmation_rejects_identity_or_property_drift() {
|
||||
let expected = workload_identity();
|
||||
let evidence = confirmation_evidence();
|
||||
evidence.validate(&expected).unwrap();
|
||||
let baseline = confirmation();
|
||||
baseline.validate(&expected).unwrap();
|
||||
|
||||
let mut drifted = confirmation_evidence();
|
||||
drifted.capabilities.effective = 1;
|
||||
let mut drifted = confirmation();
|
||||
drifted.properties.privilege_floor.enforced = false;
|
||||
assert!(drifted.validate(&expected).is_err());
|
||||
|
||||
let mut unmanaged = confirmation_evidence();
|
||||
let mut unmanaged = confirmation();
|
||||
unmanaged.runtime_exit_terminates_workload = false;
|
||||
assert!(unmanaged.validate(&expected).is_err());
|
||||
|
||||
@@ -863,7 +840,7 @@ fn confirmation_evidence_rejects_identity_or_posture_drift() {
|
||||
"sha256:test".into(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(evidence.validate(&different).is_err());
|
||||
assert!(baseline.validate(&different).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -23,8 +23,9 @@ use openshell_core::policy::{
|
||||
use openshell_isolation_interface::AgentSpec;
|
||||
use openshell_isolation_interface::contract::Sha256Digest;
|
||||
use openshell_isolation_interface::contract::{
|
||||
BackendDescriptor, BackendError, BinaryIdentity, BoundaryExitStatus, BoundarySignal,
|
||||
DriverFenceEvidence, ExecSpec, ResolveError, SandboxConfirmEvidence,
|
||||
BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus,
|
||||
BoundaryProperties, BoundarySignal, DriverFenceEvidence, EnforcedProperty, ExecSpec,
|
||||
ResolveError,
|
||||
};
|
||||
use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose};
|
||||
use serde::de::DeserializeOwned;
|
||||
@@ -42,6 +43,145 @@ pub const STREAM_STDIN_CLOSED: u8 = 4;
|
||||
pub const STREAM_NETWORK_DECISION: u8 = 5;
|
||||
pub const MAX_STREAM_FRAME_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Capability masks measured from `/proc/<pid>/status` by the `OpenShell`
|
||||
/// co-located runtime.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CapabilityEvidence {
|
||||
pub inheritable: u64,
|
||||
pub permitted: u64,
|
||||
pub effective: u64,
|
||||
pub bounding: u64,
|
||||
pub ambient: u64,
|
||||
}
|
||||
|
||||
impl CapabilityEvidence {
|
||||
#[must_use]
|
||||
pub const fn is_empty(self) -> bool {
|
||||
self.inheritable == 0
|
||||
&& self.permitted == 0
|
||||
&& self.effective == 0
|
||||
&& self.bounding == 0
|
||||
&& self.ambient == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Active seccomp notification and socket-broker measurements specific to the
|
||||
/// `OpenShell` co-located runtime.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "each independently measured kernel operation is reported explicitly"
|
||||
)]
|
||||
pub struct SeccompEvidence {
|
||||
pub new_listener: bool,
|
||||
pub notification_round_trip: bool,
|
||||
pub id_validation: bool,
|
||||
pub addfd_send: bool,
|
||||
pub retained_socket_operation: bool,
|
||||
pub proc_fd_identity: bool,
|
||||
pub task_memory_read: bool,
|
||||
pub task_memory_write: bool,
|
||||
pub cancellation: bool,
|
||||
}
|
||||
|
||||
/// Mechanism-specific audit evidence for the `OpenShell` co-located runtime.
|
||||
///
|
||||
/// This schema belongs to this backend rather than the generic isolation
|
||||
/// interface. The host-side backend validates it before constructing a
|
||||
/// backend-neutral `ConfirmedBoundary`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "audit evidence preserves independently measured security results"
|
||||
)]
|
||||
pub struct OpenShellSandboxAuditEvidence {
|
||||
pub capabilities: CapabilityEvidence,
|
||||
pub no_new_privileges: bool,
|
||||
pub sandbox_dumpable: bool,
|
||||
pub child_dumpable: bool,
|
||||
pub core_limit_zero: bool,
|
||||
pub native_architecture: String,
|
||||
pub kernel_release: String,
|
||||
pub seccomp: SeccompEvidence,
|
||||
pub landlock_abi: u32,
|
||||
pub landlock_allow_deny: bool,
|
||||
pub udp_dns_round_trip: bool,
|
||||
pub tcp_dns_round_trip: bool,
|
||||
pub tcp_allow_round_trip: bool,
|
||||
pub tcp_deny_round_trip: bool,
|
||||
}
|
||||
|
||||
impl OpenShellSandboxAuditEvidence {
|
||||
/// Validate the complete mechanism-specific posture required by this backend.
|
||||
pub fn validate(&self) -> Result<(), BackendError> {
|
||||
let complete = self.capabilities.is_empty()
|
||||
&& self.no_new_privileges
|
||||
&& !self.sandbox_dumpable
|
||||
&& self.child_dumpable
|
||||
&& self.core_limit_zero
|
||||
&& !self.native_architecture.is_empty()
|
||||
&& !self.kernel_release.is_empty()
|
||||
&& self.seccomp.new_listener
|
||||
&& self.seccomp.notification_round_trip
|
||||
&& self.seccomp.id_validation
|
||||
&& self.seccomp.addfd_send
|
||||
&& self.seccomp.retained_socket_operation
|
||||
&& self.seccomp.proc_fd_identity
|
||||
&& self.seccomp.task_memory_read
|
||||
&& self.seccomp.task_memory_write
|
||||
&& self.seccomp.cancellation
|
||||
&& self.landlock_abi >= 3
|
||||
&& self.landlock_allow_deny
|
||||
&& self.udp_dns_round_trip
|
||||
&& self.tcp_dns_round_trip
|
||||
&& self.tcp_allow_round_trip
|
||||
&& self.tcp_deny_round_trip;
|
||||
if complete {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(BackendError::Confirm(
|
||||
"OpenShell sandbox audit evidence is incomplete".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Project backend measurements into the common property contract.
|
||||
#[must_use]
|
||||
pub fn properties(&self) -> BoundaryProperties {
|
||||
BoundaryProperties {
|
||||
filesystem_confinement: EnforcedProperty::new(
|
||||
self.landlock_abi >= 3 && self.landlock_allow_deny,
|
||||
format!("landlock-v{}", self.landlock_abi),
|
||||
),
|
||||
egress_interception: EnforcedProperty::new(
|
||||
self.seccomp.new_listener
|
||||
&& self.seccomp.notification_round_trip
|
||||
&& self.seccomp.addfd_send
|
||||
&& self.udp_dns_round_trip
|
||||
&& self.tcp_dns_round_trip
|
||||
&& self.tcp_allow_round_trip
|
||||
&& self.tcp_deny_round_trip,
|
||||
"seccomp-notify",
|
||||
),
|
||||
request_attribution: EnforcedProperty::new(
|
||||
self.seccomp.id_validation
|
||||
&& self.seccomp.proc_fd_identity
|
||||
&& self.seccomp.task_memory_read
|
||||
&& self.seccomp.task_memory_write,
|
||||
"seccomp-notify-procfs",
|
||||
),
|
||||
privilege_floor: EnforcedProperty::new(
|
||||
self.capabilities.is_empty()
|
||||
&& self.no_new_privileges
|
||||
&& !self.sandbox_dumpable
|
||||
&& self.child_dumpable
|
||||
&& self.core_limit_zero,
|
||||
"linux-capability-free",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ephemeral identity of the supervisor process that owns one sandbox runtime.
|
||||
///
|
||||
/// The supervisor generates this value in memory and presents it on every
|
||||
@@ -704,8 +844,9 @@ pub enum Response {
|
||||
snapshot: SessionSnapshotWire,
|
||||
},
|
||||
Confirmed {
|
||||
/// Measured capability-free posture produced before workload launch.
|
||||
evidence: Box<SandboxConfirmEvidence>,
|
||||
/// Backend-neutral properties and backend-owned audit evidence produced
|
||||
/// before workload launch.
|
||||
confirmation: Box<BoundaryConfirmation>,
|
||||
},
|
||||
Started {
|
||||
process_id: String,
|
||||
@@ -1184,6 +1325,61 @@ pub enum FrameError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn complete_audit_evidence() -> OpenShellSandboxAuditEvidence {
|
||||
OpenShellSandboxAuditEvidence {
|
||||
capabilities: CapabilityEvidence {
|
||||
inheritable: 0,
|
||||
permitted: 0,
|
||||
effective: 0,
|
||||
bounding: 0,
|
||||
ambient: 0,
|
||||
},
|
||||
no_new_privileges: true,
|
||||
sandbox_dumpable: false,
|
||||
child_dumpable: true,
|
||||
core_limit_zero: true,
|
||||
native_architecture: "x86_64".to_string(),
|
||||
kernel_release: "6.12.0".to_string(),
|
||||
seccomp: SeccompEvidence {
|
||||
new_listener: true,
|
||||
notification_round_trip: true,
|
||||
id_validation: true,
|
||||
addfd_send: true,
|
||||
retained_socket_operation: true,
|
||||
proc_fd_identity: true,
|
||||
task_memory_read: true,
|
||||
task_memory_write: true,
|
||||
cancellation: true,
|
||||
},
|
||||
landlock_abi: 6,
|
||||
landlock_allow_deny: true,
|
||||
udp_dns_round_trip: true,
|
||||
tcp_dns_round_trip: true,
|
||||
tcp_allow_round_trip: true,
|
||||
tcp_deny_round_trip: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openshell_audit_evidence_projects_backend_neutral_properties() {
|
||||
let audit = complete_audit_evidence();
|
||||
audit.validate().unwrap();
|
||||
let properties = audit.properties();
|
||||
assert!(properties.filesystem_confinement.enforced);
|
||||
assert_eq!(properties.filesystem_confinement.mechanism, "landlock-v6");
|
||||
assert!(properties.egress_interception.enforced);
|
||||
assert!(properties.request_attribution.enforced);
|
||||
assert!(properties.privilege_floor.enforced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openshell_audit_evidence_rejects_mechanism_failure() {
|
||||
let mut audit = complete_audit_evidence();
|
||||
audit.seccomp.addfd_send = false;
|
||||
assert!(audit.validate().is_err());
|
||||
assert!(!audit.properties().egress_interception.enforced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_identity_wire_rejects_ambiguous_or_invalid_shapes() {
|
||||
for encoded in [
|
||||
|
||||
@@ -293,19 +293,29 @@ impl BoundBoundary for RemoteBound {
|
||||
|
||||
async fn confirm(self: Box<Self>) -> Result<ConfirmedBoundary, BackendError> {
|
||||
let response = self.client.call_idempotent(Request::Confirm).await?;
|
||||
let Response::Confirmed { evidence } = response else {
|
||||
return Err(unexpected_response("confirmed_with_evidence", &response));
|
||||
let Response::Confirmed { confirmation } = response else {
|
||||
return Err(unexpected_response("confirmed", &response));
|
||||
};
|
||||
if evidence.generation != self.generation
|
||||
|| evidence.session_id != self.session_id
|
||||
|| evidence.resource_claims != self.resource_claims
|
||||
|| evidence.driver_fence != self.driver_fence
|
||||
if confirmation.generation != self.generation
|
||||
|| confirmation.session_id != self.session_id
|
||||
|| confirmation.resource_claims != self.resource_claims
|
||||
|| confirmation.driver_fence != self.driver_fence
|
||||
{
|
||||
return Err(BackendError::Confirm(
|
||||
"sandbox confirmation generation, session, resource claims, or driver fence do not match runtime descriptor"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let audit: crate::boundary_protocol::OpenShellSandboxAuditEvidence =
|
||||
serde_json::from_value(confirmation.backend_audit.clone()).map_err(|error| {
|
||||
BackendError::Confirm(format!("decode OpenShell sandbox audit evidence: {error}"))
|
||||
})?;
|
||||
audit.validate()?;
|
||||
if confirmation.properties != audit.properties() {
|
||||
return Err(BackendError::Confirm(
|
||||
"sandbox confirmation properties do not match OpenShell audit evidence".to_string(),
|
||||
));
|
||||
}
|
||||
self.client.start_credential_monitor();
|
||||
ConfirmedBoundary::try_new(
|
||||
Box::new(RemoteReady {
|
||||
@@ -316,7 +326,7 @@ impl BoundBoundary for RemoteBound {
|
||||
ca_file_paths: self.ca_file_paths,
|
||||
provider_credentials: self.provider_credentials,
|
||||
}),
|
||||
*evidence,
|
||||
*confirmation,
|
||||
&self.identity,
|
||||
)
|
||||
}
|
||||
@@ -1939,7 +1949,7 @@ mod tests {
|
||||
},
|
||||
},
|
||||
Request::Confirm => Response::Confirmed {
|
||||
evidence: Box::new(test_confirmation_evidence()),
|
||||
confirmation: Box::new(test_confirmation()),
|
||||
},
|
||||
Request::OpenMediation if mediation_ready => Response::MediationReady,
|
||||
Request::OpenMediation => Response::Error {
|
||||
@@ -2437,12 +2447,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_confirmation_evidence()
|
||||
-> openshell_isolation_interface::contract::SandboxConfirmEvidence {
|
||||
openshell_isolation_interface::contract::SandboxConfirmEvidence {
|
||||
generation: "test-generation".to_string(),
|
||||
identity: sandbox().identity,
|
||||
capabilities: openshell_isolation_interface::contract::CapabilityEvidence {
|
||||
fn test_confirmation() -> openshell_isolation_interface::contract::BoundaryConfirmation {
|
||||
let audit = crate::boundary_protocol::OpenShellSandboxAuditEvidence {
|
||||
capabilities: crate::boundary_protocol::CapabilityEvidence {
|
||||
inheritable: 0,
|
||||
permitted: 0,
|
||||
effective: 0,
|
||||
@@ -2455,7 +2462,7 @@ mod tests {
|
||||
core_limit_zero: true,
|
||||
native_architecture: std::env::consts::ARCH.to_string(),
|
||||
kernel_release: "test".to_string(),
|
||||
seccomp: openshell_isolation_interface::contract::SeccompEvidence {
|
||||
seccomp: crate::boundary_protocol::SeccompEvidence {
|
||||
new_listener: true,
|
||||
notification_round_trip: true,
|
||||
id_validation: true,
|
||||
@@ -2472,11 +2479,17 @@ mod tests {
|
||||
tcp_dns_round_trip: true,
|
||||
tcp_allow_round_trip: true,
|
||||
tcp_deny_round_trip: true,
|
||||
};
|
||||
openshell_isolation_interface::contract::BoundaryConfirmation {
|
||||
generation: "test-generation".to_string(),
|
||||
identity: sandbox().identity,
|
||||
properties: audit.properties(),
|
||||
authenticated_supervisor: true,
|
||||
session_id: test_session_id(),
|
||||
driver_fence: test_driver_fence(),
|
||||
runtime_exit_terminates_workload: true,
|
||||
resource_claims: std::collections::BTreeMap::new(),
|
||||
backend_audit: serde_json::to_value(audit).expect("serialize audit evidence"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2594,7 +2607,7 @@ mod tests {
|
||||
.await
|
||||
.expect("TLS request"),
|
||||
Response::Confirmed {
|
||||
evidence: Box::new(test_confirmation_evidence()),
|
||||
confirmation: Box::new(test_confirmation()),
|
||||
}
|
||||
);
|
||||
server.abort();
|
||||
|
||||
@@ -36,9 +36,8 @@ mod linux {
|
||||
};
|
||||
use openshell_core::provider_credentials::ProviderCredentialState;
|
||||
use openshell_isolation_interface::contract::{
|
||||
BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal,
|
||||
CapabilityEvidence, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity,
|
||||
SandboxConfirmEvidence,
|
||||
BoundaryConfirmation, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess,
|
||||
BoundaryTerminal, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity,
|
||||
};
|
||||
use openshell_sandbox_backend::GPU_RESOURCE_CLAIM;
|
||||
use openshell_sandbox_backend::mediation::{
|
||||
@@ -60,11 +59,11 @@ mod linux {
|
||||
use openshell_sandbox_backend::boundary_protocol::{
|
||||
AgentSpecWire, BinaryIdentityWire, BoundaryConfig, BoundaryErrorKind,
|
||||
BoundaryListener as BoundaryListenerConfig, DnsQueryResultWire, ExecSpecWire,
|
||||
ExitStatusWire, MediationTimingWire, OutputWindowWire, ProcessKindWire,
|
||||
ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, STREAM_EXIT,
|
||||
STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, STREAM_STDOUT,
|
||||
SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame, read_frame,
|
||||
read_stream_frame, validate_resource_claims, write_frame, write_stream_frame,
|
||||
ExitStatusWire, MediationTimingWire, OpenShellSandboxAuditEvidence, OutputWindowWire,
|
||||
ProcessKindWire, ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope,
|
||||
STREAM_EXIT, STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED,
|
||||
STREAM_STDOUT, SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame,
|
||||
read_frame, read_stream_frame, validate_resource_claims, write_frame, write_stream_frame,
|
||||
};
|
||||
|
||||
const CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -2220,20 +2219,20 @@ mod linux {
|
||||
if let Err(error) = prepared.confirm(&self.process_runtime) {
|
||||
return guest_error(BoundaryErrorKind::Process, error);
|
||||
}
|
||||
let evidence = match self.measure_confirmation_evidence() {
|
||||
Ok(evidence) => evidence,
|
||||
let confirmation = match self.measure_confirmation() {
|
||||
Ok(confirmation) => confirmation,
|
||||
Err(error) => return guest_error(BoundaryErrorKind::Process, error),
|
||||
};
|
||||
*state = RuntimeState::Ready(prepared.clone());
|
||||
Response::Confirmed {
|
||||
evidence: Box::new(evidence),
|
||||
confirmation: Box::new(confirmation),
|
||||
}
|
||||
}
|
||||
RuntimeState::Ready(_) | RuntimeState::Running(_) => {
|
||||
self.measure_confirmation_evidence().map_or_else(
|
||||
self.measure_confirmation().map_or_else(
|
||||
|error| guest_error(BoundaryErrorKind::Process, error),
|
||||
|evidence| Response::Confirmed {
|
||||
evidence: Box::new(evidence),
|
||||
|confirmation| Response::Confirmed {
|
||||
confirmation: Box::new(confirmation),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2244,7 +2243,7 @@ mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_confirmation_evidence(&self) -> Result<SandboxConfirmEvidence, String> {
|
||||
fn measure_confirmation(&self) -> Result<BoundaryConfirmation, String> {
|
||||
validate_running_identity(
|
||||
&self.config.workload_identity,
|
||||
allows_runtime_supplementary_groups(&self.config),
|
||||
@@ -2257,7 +2256,7 @@ mod linux {
|
||||
}
|
||||
let status = std::fs::read_to_string("/proc/self/status")
|
||||
.map_err(|error| format!("read sandbox process status: {error}"))?;
|
||||
let capabilities = CapabilityEvidence {
|
||||
let capabilities = openshell_sandbox_backend::boundary_protocol::CapabilityEvidence {
|
||||
inheritable: parse_status_hex(&status, "CapInh")?,
|
||||
permitted: parse_status_hex(&status, "CapPrm")?,
|
||||
effective: parse_status_hex(&status, "CapEff")?,
|
||||
@@ -2278,9 +2277,7 @@ mod linux {
|
||||
// SAFETY: successful getrlimit initialized the value.
|
||||
let core_limit = unsafe { core_limit.assume_init() };
|
||||
let (native_architecture, kernel_release) = uname_values()?;
|
||||
Ok(SandboxConfirmEvidence {
|
||||
generation: self.config.generation.clone(),
|
||||
identity: self.config.workload_identity.clone(),
|
||||
let audit = OpenShellSandboxAuditEvidence {
|
||||
capabilities,
|
||||
no_new_privileges,
|
||||
sandbox_dumpable,
|
||||
@@ -2295,11 +2292,21 @@ mod linux {
|
||||
tcp_dns_round_trip: self.qualification.tcp_dns_round_trip,
|
||||
tcp_allow_round_trip: self.qualification.tcp_allow_round_trip,
|
||||
tcp_deny_round_trip: self.qualification.tcp_deny_round_trip,
|
||||
};
|
||||
audit.validate().map_err(|error| error.to_string())?;
|
||||
let properties = audit.properties();
|
||||
let backend_audit = serde_json::to_value(audit)
|
||||
.map_err(|error| format!("encode OpenShell sandbox audit evidence: {error}"))?;
|
||||
Ok(BoundaryConfirmation {
|
||||
generation: self.config.generation.clone(),
|
||||
identity: self.config.workload_identity.clone(),
|
||||
properties,
|
||||
authenticated_supervisor: true,
|
||||
session_id: self.config.session_id,
|
||||
driver_fence: self.config.driver_fence.clone(),
|
||||
runtime_exit_terminates_workload: true,
|
||||
resource_claims: self.config.resource_claims.clone(),
|
||||
backend_audit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4201,7 +4208,7 @@ mod linux {
|
||||
|
||||
fn test_runtime_qualification() -> crate::RuntimeQualification {
|
||||
crate::RuntimeQualification {
|
||||
seccomp: openshell_isolation_interface::contract::SeccompEvidence {
|
||||
seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence {
|
||||
new_listener: true,
|
||||
notification_round_trip: true,
|
||||
id_validation: true,
|
||||
|
||||
@@ -34,7 +34,7 @@ pub mod sandbox;
|
||||
reason = "qualification preserves independently exercised security results"
|
||||
)]
|
||||
pub struct RuntimeQualification {
|
||||
pub seccomp: openshell_isolation_interface::contract::SeccompEvidence,
|
||||
pub seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence,
|
||||
pub landlock_abi: u32,
|
||||
pub landlock_allow_deny: bool,
|
||||
pub udp_dns_round_trip: bool,
|
||||
|
||||
@@ -239,7 +239,7 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi
|
||||
wait_killable_recv: notification.wait_killable_recv,
|
||||
};
|
||||
let qualification = openshell_sandbox::RuntimeQualification {
|
||||
seccomp: openshell_isolation_interface::contract::SeccompEvidence {
|
||||
seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence {
|
||||
new_listener: notification.notification_round_trip(),
|
||||
notification_round_trip: notification.notification_round_trip(),
|
||||
id_validation: notification.notification_round_trip(),
|
||||
|
||||
Reference in New Issue
Block a user