Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion crates/openshell-driver-docker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ use openshell_core::proto_struct::{
use openshell_core::{
AppArmorProfile, Error, ImagePullPolicy, Result as CoreResult, UpstreamProxyConfig,
};
use openshell_isolation_interface::contract::ResolvedWorkloadIdentity;
use openshell_isolation_interface::contract::{
IdentityComponentOrigin, ResolvedWorkloadIdentity, root_identity_rejection_message,
};
use openshell_sandbox_backend::boundary_protocol::{
BoundaryConfig, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig,
SandboxTlsServerConfig, generate_sandbox_tls_material,
Expand Down Expand Up @@ -692,6 +694,39 @@ fn resolve_docker_identity_from_accounts(
} else {
"image"
};
// Where each numeric component came from, for an actionable rejection
// message. This does not change which identity is resolved or enforced.
let uid_origin = if requested_user.is_empty() {
IdentityComponentOrigin::ImageUser
} else {
IdentityComponentOrigin::Policy
};
let gid_origin = if !requested_group.is_empty() {
IdentityComponentOrigin::Policy
} else if !group_selector.is_empty() {
// The group came from the image's `USER` in its `user:group` form.
IdentityComponentOrigin::ImageUser
} else {
// The group was inherited from the user's `/etc/passwd` entry.
IdentityComponentOrigin::ImagePasswd
};
let image_reference = sandbox
.spec
.as_ref()
.and_then(|spec| spec.template.as_ref())
.map(|template| template.image.trim())
.filter(|reference| !reference.is_empty())
.unwrap_or(image.id.as_str());
if let Some(message) = root_identity_rejection_message(
image_reference,
uid,
uid_origin,
gid,
gid_origin,
&supplementary_gids,
) {
return Err(Status::failed_precondition(message));
}
ResolvedWorkloadIdentity::new(
uid,
gid,
Expand Down
58 changes: 57 additions & 1 deletion crates/openshell-driver-docker/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1711,7 +1711,63 @@ fn docker_identity_resolution_honors_policy_selectors_and_rejects_root() {
b"root:x:0:\n",
)
.unwrap_err();
assert!(error.message().contains("UID or GID zero"));
let message = error.message();
assert!(!message.contains("descriptor error"));
assert!(message.contains("resolves to a root workload"));
assert!(message.contains("the policy's `process.run_as_user`"));
assert!(message.contains("nvcr.io/nvidia/base/ubuntu:24.04"));
}

#[test]
fn docker_identity_resolution_rejects_image_user_root_with_actionable_error() {
// An off-the-shelf image that declares `USER root` and no policy identity.
let sandbox = test_sandbox();
let image = DockerImageMetadata {
id: "sha256:image".to_string(),
user: "root".to_string(),
working_dir: "/root".to_string(),
volumes: Vec::new(),
};
let error = resolve_docker_identity_from_accounts(
&sandbox,
&image,
b"root:x:0:0:root:/root:/bin/sh\n",
b"root:x:0:\n",
)
.unwrap_err();
let message = error.message();
// Names the image, blames the image USER, and gives a remediation path.
assert!(message.contains("nvcr.io/nvidia/base/ubuntu:24.04"));
assert!(message.contains("the image's `USER`"));
assert!(message.contains("UID 0"));
assert!(message.contains("non-root"));
assert!(message.contains("process.run_as_user"));
// The internal `descriptor error:` prefix must not leak to users.
assert!(!message.contains("descriptor error"));
}

#[test]
fn docker_identity_resolution_rejects_supplementary_group_zero_from_etc_group() {
// A non-root user whose /etc/group membership puts it in group 0.
let sandbox = test_sandbox();
let image = DockerImageMetadata {
id: "sha256:image".to_string(),
user: "agent".to_string(),
working_dir: "/sandbox".to_string(),
volumes: Vec::new(),
};
let error = resolve_docker_identity_from_accounts(
&sandbox,
&image,
b"root:x:0:0:root:/root:/bin/sh\nagent:x:10001:10002::/sandbox:/bin/sh\n",
b"root:x:0:agent\nagent:x:10002:\n",
)
.unwrap_err();
let message = error.message();
assert!(message.contains("group 0 (root)"));
assert!(message.contains("/etc/group"));
assert!(message.contains("run_as_group"));
assert!(!message.contains("descriptor error"));
}

#[test]
Expand Down
45 changes: 43 additions & 2 deletions crates/openshell-driver-podman/src/isolation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ use std::path::PathBuf;
use openshell_core::ComputeDriverError;
use openshell_core::proto::compute::v1::DriverSandbox;
use openshell_isolation_interface::contract::{
OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity,
IdentityComponentOrigin, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity,
root_identity_rejection_message,
};
use openshell_sandbox_backend::ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM;
use openshell_sandbox_backend::boundary_protocol::{
Expand Down Expand Up @@ -178,6 +179,39 @@ pub fn resolve_identity(
} else {
"policy"
};
// Where each numeric component came from, for an actionable rejection
// message. This does not change which identity is resolved or enforced.
let uid_origin = if requested_user.is_empty() {
IdentityComponentOrigin::ImageUser
} else {
IdentityComponentOrigin::Policy
};
let gid_origin = if !requested_group.is_empty() {
IdentityComponentOrigin::Policy
} else if !group.is_empty() {
// The group came from the image's `USER` in its `user:group` form.
IdentityComponentOrigin::ImageUser
} else {
// The group was inherited from the user's `/etc/passwd` entry.
IdentityComponentOrigin::ImagePasswd
};
let image_reference = sandbox
.spec
.as_ref()
.and_then(|spec| spec.template.as_ref())
.map(|template| template.image.trim())
.filter(|reference| !reference.is_empty())
.unwrap_or(image_id);
if let Some(message) = root_identity_rejection_message(
image_reference,
uid,
uid_origin,
gid,
gid_origin,
&supplemental,
) {
return Err(invalid(message));
}
ResolvedWorkloadIdentity::new(uid, gid, supplemental, source.into(), image_id.into())
.map_err(invalid)
}
Expand Down Expand Up @@ -464,7 +498,14 @@ mod tests {
assert_eq!((identity.uid, identity.gid), (1000, 1001));
assert_eq!(identity.supplementary_gids, vec![2000]);
assert_eq!(identity.resource_digest, "sha256:pinned");
assert!(resolve_identity(&sandbox, "sha256:pinned", "root", passwd, groups).is_err());
let error = resolve_identity(&sandbox, "sha256:pinned", "root", passwd, groups)
.expect_err("a root image USER is rejected");
let message = error.to_string();
assert!(message.contains("sha256:pinned"));
assert!(message.contains("the image's `USER`"));
assert!(message.contains("UID 0"));
assert!(message.contains("process.run_as_user"));
assert!(!message.contains("descriptor error"));
assert!(resolve_identity(&sandbox, "sha256:pinned", "2000", passwd, groups).is_err());
}

Expand Down
155 changes: 155 additions & 0 deletions crates/openshell-isolation-interface/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,77 @@ impl ResolvedWorkloadIdentity {
}
}

/// Where a rejected workload-identity component's numeric value came from.
///
/// Used only to build an actionable diagnostic when a resolved identity
/// contains UID or GID 0. It describes provenance for the error message and
/// does not influence how identities are resolved or enforced.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdentityComponentOrigin {
/// Selected from the image's OCI `USER` directive.
ImageUser,
/// Resolved through the image's `/etc/passwd`.
ImagePasswd,
/// Resolved through the image's `/etc/group`.
ImageGroup,
/// Selected by the sandbox policy (`process.run_as_user` / `run_as_group`).
Policy,
}

/// Build an actionable error message when a resolved workload identity contains
/// UID or GID 0.
///
/// The Docker and Podman drivers never run a workload as root, so they
/// reject any resolved identity with a zero user, primary group, or
/// supplementary group. That numeric rejection is the invariant enforced by
/// [`ResolvedWorkloadIdentity::new`]; this helper changes nothing about it. It
/// only explains an already-rejected identity in terms a user can act on,
/// naming the image, which component is zero, and where its value came from.
///
/// Returns `None` when no component is zero.
#[must_use]
pub fn root_identity_rejection_message(
image_reference: &str,
uid: u32,
uid_origin: IdentityComponentOrigin,
gid: u32,
gid_origin: IdentityComponentOrigin,
supplementary_gids: &[u32],
) -> Option<String> {
use IdentityComponentOrigin::{ImageGroup, ImagePasswd, ImageUser, Policy};

let remediation = "OpenShell never runs a workload as root. Use an image with a non-root `USER`, \
or set `process.run_as_user` and `process.run_as_group` to a non-root identity in the creation policy.";

if uid == 0 {
let origin = match uid_origin {
Policy => "the policy's `process.run_as_user`",
_ => "the image's `USER`",
};
return Some(format!(
"image '{image_reference}' resolves to a root workload: {origin} selects UID 0. {remediation}"
));
}
if gid == 0 {
let origin = match gid_origin {
Policy => "the policy's `process.run_as_group`",
ImageUser => "the image's `USER`",
ImagePasswd => "the image's `/etc/passwd`",
ImageGroup => "the image's `/etc/group`",
};
return Some(format!(
"image '{image_reference}' resolves to a root primary group: {origin} selects GID 0. {remediation}"
));
}
if supplementary_gids.contains(&0) {
return Some(format!(
"image '{image_reference}' lists the workload user in group 0 (root) through its `/etc/group`. \
Overriding `process.run_as_group` alone does not remove this supplementary membership. {remediation}"
));
}
None
}

/// The trusted sandbox context, constructed by trusted common code after the
/// control plane assigns the resource to the admitted sandbox.
///
Expand Down Expand Up @@ -1073,3 +1144,87 @@ pub struct PendingDnsQuery {
/// Single-use response channel owned by the backend adapter.
pub response: oneshot::Sender<Result<Vec<u8>, BackendError>>,
}

#[cfg(test)]
mod root_identity_rejection_tests {
use super::{IdentityComponentOrigin, root_identity_rejection_message};

#[test]
fn image_user_root_names_image_and_remediation() {
let message = root_identity_rejection_message(
"nicolaka/netshoot:latest",
0,
IdentityComponentOrigin::ImageUser,
0,
IdentityComponentOrigin::ImageUser,
&[],
)
.expect("root UID is rejected");
assert!(message.contains("nicolaka/netshoot:latest"));
assert!(message.contains("the image's `USER`"));
assert!(message.contains("UID 0"));
assert!(message.contains("process.run_as_user"));
assert!(message.contains("process.run_as_group"));
}

#[test]
fn policy_user_zero_blames_policy() {
let message = root_identity_rejection_message(
"example:1.0",
0,
IdentityComponentOrigin::Policy,
1000,
IdentityComponentOrigin::Policy,
&[],
)
.expect("root UID is rejected");
assert!(message.contains("the policy's `process.run_as_user`"));
}

#[test]
fn primary_group_zero_reports_passwd_origin() {
let message = root_identity_rejection_message(
"example:1.0",
1000,
IdentityComponentOrigin::ImageUser,
0,
IdentityComponentOrigin::ImagePasswd,
&[],
)
.expect("root primary GID is rejected");
assert!(message.contains("root primary group"));
assert!(message.contains("the image's `/etc/passwd`"));
assert!(message.contains("GID 0"));
}

#[test]
fn supplementary_group_zero_mentions_etc_group_and_override_limit() {
let message = root_identity_rejection_message(
"example:1.0",
1000,
IdentityComponentOrigin::ImageUser,
1000,
IdentityComponentOrigin::ImagePasswd,
&[0],
)
.expect("supplementary group 0 is rejected");
assert!(message.contains("group 0 (root)"));
assert!(message.contains("/etc/group"));
assert!(message.contains("does not remove this supplementary membership"));
}

#[test]
fn non_root_identity_is_accepted() {
assert!(
root_identity_rejection_message(
"example:1.0",
1000,
IdentityComponentOrigin::ImageUser,
1000,
IdentityComponentOrigin::ImagePasswd,
&[10, 20],
)
.is_none()
);
}
}
2 changes: 1 addition & 1 deletion docs/how-it-works/sandboxes/runtimes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ On Docker and Podman, set `process.run_as_user` and `process.run_as_group` in th

| Driver | Default identity |
|---|---|
| Docker, Podman | The image's `USER`. Images without `USER` must set both fields in policy. |
| Docker, Podman | The image's `USER`, or UID and GID `1000` when the image declares no `USER`. A root `USER` is rejected unless the policy sets a non-root `run_as_user` and `run_as_group`. |
| Kubernetes | OpenShift SCC namespace annotations, otherwise `1000`. Override with `sandbox_uid` and `sandbox_gid`. |
| MicroVM | The image's `sandbox` account, otherwise `1000`. Override with `sandbox_uid` and `sandbox_gid`. |

Expand Down
Loading