mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-26 06:52:57 +08:00
fix(sandbox): bound control admission and retire stale mediation
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
@@ -27,6 +27,17 @@ TCP Service, or VM vsock channel. Independent bidirectional `Exchange` RPCs
|
||||
carry lifecycle, exec, TCP, and forwarding traffic, while one persistent
|
||||
bidirectional `Mediate` RPC carries multiplexed DNS traffic. General application
|
||||
UDP is unsupported; UDP DNS remains mediated by the supervisor.
|
||||
The sandbox probes HTTP/2 connection liveness every five seconds and closes
|
||||
connections that miss a ten-second acknowledgement deadline. Closing a
|
||||
connection cancels its stream bridges before releasing the exclusive DNS
|
||||
mediation lease; an authenticated replacement waits for release instead of
|
||||
preempting a live supervisor. Idle healthy connections remain usable.
|
||||
Unauthenticated TLS handshakes have a separate bounded asynchronous pool and
|
||||
five-second deadline, never consuming authenticated control slots or threads.
|
||||
The socket broker reserves the TCP control-listener port against workload
|
||||
connections, including loopback aliases. Unix control listeners reject workload
|
||||
descendants using kernel peer credentials and process ancestry, while ordinary
|
||||
workload loopback and Unix services remain available.
|
||||
NetworkPolicy is an outer reachability fence, not a confidentiality boundary.
|
||||
Each sandbox generation receives a fresh CA and distinct server/client leaves;
|
||||
both endpoints bind the same workload identity and immutable driver resource
|
||||
|
||||
@@ -59,6 +59,11 @@ mod linux {
|
||||
};
|
||||
|
||||
const CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const CONTROL_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const CONTROL_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const CONTROL_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MEDIATION_REPLACEMENT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const MAX_PENDING_HANDSHAKES: usize = 32;
|
||||
const MAX_CONTROL_CONNECTIONS: usize = 128;
|
||||
const MAX_REPLAY_LEDGER_ENTRIES: usize = 4096;
|
||||
const MAX_RETAINED_EXEC_PROCESSES: usize = 64;
|
||||
@@ -119,7 +124,11 @@ mod linux {
|
||||
.map_err(|error| format!("install sandbox process prelude: {error}"))?;
|
||||
let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start()
|
||||
.map_err(|error| format!("start sandbox workload launcher: {error}"))?;
|
||||
let network_broker = NetworkBroker::start(listener)
|
||||
let protected_control_port = match &config.listener {
|
||||
BoundaryListenerConfig::TlsTcp { address, .. } => Some(address.port()),
|
||||
BoundaryListenerConfig::Unix { .. } | BoundaryListenerConfig::Vsock { .. } => None,
|
||||
};
|
||||
let network_broker = NetworkBroker::start(listener, protected_control_port)
|
||||
.map_err(|error| format!("start sandbox network broker: {error}"))?;
|
||||
let process_runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
@@ -349,6 +358,7 @@ mod linux {
|
||||
let listener = ControlListener::bind(config)
|
||||
.map_err(|error| format!("bind boundary control listener: {error}"))?;
|
||||
let active_connections = Arc::new(AtomicUsize::new(0));
|
||||
let pending_handshakes = Arc::new(tokio::sync::Semaphore::new(MAX_PENDING_HANDSHAKES));
|
||||
tracing::info!(?config, "Boundary control listener ready");
|
||||
loop {
|
||||
if BOUNDARY_TERMINATION_REQUESTED.load(Ordering::Acquire) {
|
||||
@@ -357,41 +367,31 @@ mod linux {
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok(stream) => {
|
||||
let Some(slot) = acquire_control_connection_slot(&active_connections) else {
|
||||
tracing::warn!(
|
||||
limit = MAX_CONTROL_CONNECTIONS,
|
||||
"Boundary control connection limit reached"
|
||||
);
|
||||
// Unauthenticated sockets use only a bounded async TLS
|
||||
// task, never an OS thread or an authenticated session slot.
|
||||
let Ok(pending) = pending_handshakes.clone().try_acquire_owned() else {
|
||||
continue;
|
||||
};
|
||||
let active_connections = active_connections.clone();
|
||||
let runtime = runtime.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _slot = slot;
|
||||
let stream = match stream.establish(&runtime.process_runtime) {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "Boundary control transport handshake failed");
|
||||
return;
|
||||
runtime.process_runtime.spawn({
|
||||
let runtime = runtime.clone();
|
||||
async move {
|
||||
if let Err(error) = serve_control_connection(
|
||||
stream,
|
||||
runtime,
|
||||
pending,
|
||||
active_connections,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(%error, "Boundary control connection ended");
|
||||
}
|
||||
};
|
||||
let result = {
|
||||
let stream = match stream.into_tokio() {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "prepare boundary gRPC session");
|
||||
return;
|
||||
}
|
||||
};
|
||||
runtime
|
||||
.process_runtime
|
||||
.block_on(serve_grpc(stream, runtime.clone()))
|
||||
};
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(%error, "Boundary control session failed: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
@@ -400,12 +400,39 @@ mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_control_connection(
|
||||
stream: ControlStream,
|
||||
runtime: Arc<BoundaryRuntime>,
|
||||
pending: tokio::sync::OwnedSemaphorePermit,
|
||||
active_connections: Arc<AtomicUsize>,
|
||||
) -> Result<(), String> {
|
||||
let stream = stream
|
||||
.establish_async(&runtime.process_runtime)
|
||||
.await
|
||||
.map_err(|error| format!("authenticate boundary transport: {error}"))?;
|
||||
drop(pending);
|
||||
let Some(_slot) = acquire_control_connection_slot(&active_connections) else {
|
||||
return Err("authenticated control connection limit reached".to_string());
|
||||
};
|
||||
serve_grpc(stream.into_tokio()?, runtime).await
|
||||
}
|
||||
|
||||
async fn serve_grpc(
|
||||
stream: openshell_isolation_interface::contract::BoundaryDuplexStream,
|
||||
runtime: Arc<BoundaryRuntime>,
|
||||
) -> Result<(), String> {
|
||||
let incoming = tokio_stream::iter([Ok::<_, io::Error>(GrpcServerIo(stream))]);
|
||||
let (connection_alive, connection_closed) = tokio::sync::watch::channel(());
|
||||
let incoming = tokio_stream::StreamExt::chain(
|
||||
tokio_stream::iter([Ok::<_, io::Error>(GrpcServerIo {
|
||||
stream,
|
||||
_connection_alive: connection_alive,
|
||||
})]),
|
||||
tokio_stream::pending(),
|
||||
);
|
||||
let mut shutdown = connection_closed.clone();
|
||||
tonic::transport::Server::builder()
|
||||
.http2_keepalive_interval(Some(CONTROL_KEEPALIVE_INTERVAL))
|
||||
.http2_keepalive_timeout(Some(CONTROL_KEEPALIVE_TIMEOUT))
|
||||
.max_concurrent_streams(
|
||||
u32::try_from(MAX_CONTROL_CONNECTIONS)
|
||||
.map_err(|error| format!("invalid control connection limit: {error}"))?,
|
||||
@@ -413,16 +440,26 @@ mod linux {
|
||||
.initial_stream_window_size(16 * 1024 * 1024)
|
||||
.initial_connection_window_size(16 * 1024 * 1024)
|
||||
.add_service(
|
||||
IsolationBoundaryServer::new(GrpcBoundaryService { runtime })
|
||||
.max_decoding_message_size(64 * 1024)
|
||||
.max_encoding_message_size(64 * 1024),
|
||||
IsolationBoundaryServer::new(GrpcBoundaryService {
|
||||
runtime,
|
||||
connection_closed,
|
||||
})
|
||||
.max_decoding_message_size(64 * 1024)
|
||||
.max_encoding_message_size(64 * 1024),
|
||||
)
|
||||
.serve_with_incoming(incoming)
|
||||
.serve_with_incoming_shutdown(incoming, async move {
|
||||
let _ = shutdown.changed().await;
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("serve boundary gRPC connection: {error}"))
|
||||
}
|
||||
|
||||
struct GrpcServerIo(openshell_isolation_interface::contract::BoundaryDuplexStream);
|
||||
struct GrpcServerIo {
|
||||
stream: openshell_isolation_interface::contract::BoundaryDuplexStream,
|
||||
// Dropping the actual HTTP/2 transport stops all detached stream
|
||||
// bridges, including on keepalive failure or task cancellation.
|
||||
_connection_alive: tokio::sync::watch::Sender<()>,
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncRead for GrpcServerIo {
|
||||
fn poll_read(
|
||||
@@ -430,7 +467,7 @@ mod linux {
|
||||
context: &mut Context<'_>,
|
||||
buffer: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.0).poll_read(context, buffer)
|
||||
Pin::new(&mut self.stream).poll_read(context, buffer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,18 +477,18 @@ mod linux {
|
||||
context: &mut Context<'_>,
|
||||
buffer: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
Pin::new(&mut self.0).poll_write(context, buffer)
|
||||
Pin::new(&mut self.stream).poll_write(context, buffer)
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.0).poll_flush(context)
|
||||
Pin::new(&mut self.stream).poll_flush(context)
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
mut self: Pin<&mut Self>,
|
||||
context: &mut Context<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.0).poll_shutdown(context)
|
||||
Pin::new(&mut self.stream).poll_shutdown(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +501,7 @@ mod linux {
|
||||
#[derive(Clone)]
|
||||
struct GrpcBoundaryService {
|
||||
runtime: Arc<BoundaryRuntime>,
|
||||
connection_closed: tokio::sync::watch::Receiver<()>,
|
||||
}
|
||||
|
||||
type GrpcResponseStream = ReceiverStream<Result<BoundaryChunk, tonic::Status>>;
|
||||
@@ -477,7 +515,8 @@ mod linux {
|
||||
&self,
|
||||
request: tonic::Request<tonic::Streaming<BoundaryChunk>>,
|
||||
) -> Result<tonic::Response<Self::ExchangeStream>, tonic::Status> {
|
||||
let (stream, response) = bridge_grpc_server_stream(request.into_inner());
|
||||
let (stream, response) =
|
||||
bridge_grpc_server_stream(request.into_inner(), self.connection_closed.clone());
|
||||
let runtime = self.runtime.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let stream = ControlStream::Grpc {
|
||||
@@ -495,7 +534,8 @@ mod linux {
|
||||
&self,
|
||||
request: tonic::Request<tonic::Streaming<BoundaryChunk>>,
|
||||
) -> Result<tonic::Response<Self::MediateStream>, tonic::Status> {
|
||||
let (stream, response) = bridge_grpc_server_stream(request.into_inner());
|
||||
let (stream, response) =
|
||||
bridge_grpc_server_stream(request.into_inner(), self.connection_closed.clone());
|
||||
let runtime = self.runtime.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = serve_persistent_mediation(stream, runtime).await {
|
||||
@@ -508,12 +548,17 @@ mod linux {
|
||||
|
||||
fn bridge_grpc_server_stream(
|
||||
mut inbound: tonic::Streaming<BoundaryChunk>,
|
||||
connection_closed: tokio::sync::watch::Receiver<()>,
|
||||
) -> (tokio::io::DuplexStream, GrpcResponseStream) {
|
||||
let (application, bridge) = tokio::io::duplex(256 * 1024);
|
||||
let (mut reader, mut writer) = tokio::io::split(bridge);
|
||||
let (outbound, outbound_rx) =
|
||||
tokio::sync::mpsc::channel::<Result<BoundaryChunk, tonic::Status>>(64);
|
||||
let mut inbound_closed = connection_closed.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = inbound_closed.changed() => {},
|
||||
() = async {
|
||||
loop {
|
||||
match inbound.message().await {
|
||||
Ok(Some(chunk)) => {
|
||||
@@ -531,8 +576,14 @@ mod linux {
|
||||
}
|
||||
}
|
||||
}
|
||||
} => {},
|
||||
}
|
||||
});
|
||||
let mut outbound_closed = connection_closed;
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = outbound_closed.changed() => {},
|
||||
() = async {
|
||||
let mut buffer = vec![0_u8; 16 * 1024];
|
||||
loop {
|
||||
let read = match reader.read(&mut buffer).await {
|
||||
@@ -555,6 +606,8 @@ mod linux {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} => {},
|
||||
}
|
||||
});
|
||||
(application, ReceiverStream::new(outbound_rx))
|
||||
}
|
||||
@@ -574,41 +627,38 @@ mod linux {
|
||||
>,
|
||||
>;
|
||||
|
||||
struct MediationLease(Arc<BoundaryRuntime>);
|
||||
|
||||
impl Drop for MediationLease {
|
||||
fn drop(&mut self) {
|
||||
self.0.mediation_active.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_persistent_mediation(
|
||||
mut stream: tokio::io::DuplexStream,
|
||||
runtime: Arc<BoundaryRuntime>,
|
||||
) -> Result<(), String> {
|
||||
let request: RequestEnvelope =
|
||||
openshell_isolation_interface::boundary_protocol::read_frame_async(&mut stream)
|
||||
.await
|
||||
.map_err(|error| format!("read mediation attach: {error}"))?;
|
||||
let request: RequestEnvelope = tokio::time::timeout(
|
||||
CONTROL_IO_TIMEOUT,
|
||||
openshell_isolation_interface::boundary_protocol::read_frame_async(&mut stream),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "mediation attach timed out".to_string())?
|
||||
.map_err(|error| format!("read mediation attach: {error}"))?;
|
||||
let request_id = request.request_id.clone();
|
||||
if !matches!(request.request, Request::OpenMediation) {
|
||||
return Err("persistent mediation stream omitted OpenMediation".to_string());
|
||||
}
|
||||
let mut response = runtime.dispatch(request);
|
||||
let lease = if matches!(response, Response::MediationReady) {
|
||||
if runtime
|
||||
.mediation_active
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
Some(MediationLease(runtime.clone()))
|
||||
} else {
|
||||
response = guest_error(
|
||||
BoundaryErrorKind::Denied,
|
||||
"a mediation session is already active",
|
||||
);
|
||||
None
|
||||
}
|
||||
tokio::time::timeout(
|
||||
MEDIATION_REPLACEMENT_TIMEOUT,
|
||||
runtime.mediation_active.lock(),
|
||||
)
|
||||
.await
|
||||
.map_or_else(
|
||||
|_| {
|
||||
response = guest_error(
|
||||
BoundaryErrorKind::Denied,
|
||||
"a mediation session is already active",
|
||||
);
|
||||
None
|
||||
},
|
||||
Some,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -629,7 +679,7 @@ mod linux {
|
||||
return Ok(());
|
||||
};
|
||||
let broker = runtime.network_accept_context()?;
|
||||
run_boundary_mediation(stream, runtime, broker).await
|
||||
run_boundary_mediation(stream, runtime.clone(), broker).await
|
||||
}
|
||||
|
||||
async fn run_boundary_mediation(
|
||||
@@ -967,7 +1017,7 @@ mod linux {
|
||||
/// any launch input or start a second workload.
|
||||
started_agent: Mutex<Option<StartedAgent>>,
|
||||
next_exec_id: AtomicU64,
|
||||
mediation_active: AtomicBool,
|
||||
mediation_active: tokio::sync::Mutex<()>,
|
||||
next_mediation_stream_id: AtomicU64,
|
||||
exec_handles: Mutex<std::collections::HashMap<String, ExecHandle>>,
|
||||
/// Never evicted within a boundary generation. Reclaiming process I/O
|
||||
@@ -1164,7 +1214,7 @@ mod linux {
|
||||
attached_policy: Mutex::new(None),
|
||||
started_agent: Mutex::new(None),
|
||||
next_exec_id: AtomicU64::new(1),
|
||||
mediation_active: AtomicBool::new(false),
|
||||
mediation_active: tokio::sync::Mutex::new(()),
|
||||
next_mediation_stream_id: AtomicU64::new(1),
|
||||
exec_handles: Mutex::new(std::collections::HashMap::new()),
|
||||
exec_requests: Mutex::new(std::collections::HashSet::new()),
|
||||
@@ -2480,6 +2530,7 @@ mod linux {
|
||||
server_config,
|
||||
} => {
|
||||
let (stream, _) = listener.accept()?;
|
||||
reject_workload_unix_peer(&stream)?;
|
||||
Ok(ControlStream::PendingTls {
|
||||
stream: PlainControlStream::Unix(stream),
|
||||
server_config: server_config.clone(),
|
||||
@@ -2502,6 +2553,74 @@ mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_workload_unix_peer(stream: &std::os::unix::net::UnixStream) -> io::Result<()> {
|
||||
let mut credentials = libc::ucred {
|
||||
pid: 0,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
};
|
||||
let mut length =
|
||||
libc::socklen_t::try_from(size_of::<libc::ucred>()).map_err(io::Error::other)?;
|
||||
// SAFETY: both output pointers reference initialized storage of the
|
||||
// declared length, and stream owns the connected Unix descriptor.
|
||||
if unsafe {
|
||||
libc::getsockopt(
|
||||
stream.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_PEERCRED,
|
||||
(&raw mut credentials).cast(),
|
||||
&raw mut length,
|
||||
)
|
||||
} != 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let peer = u32::try_from(credentials.pid)
|
||||
.map_err(|_| io::Error::from_raw_os_error(libc::EACCES))?;
|
||||
// Linux reports PID zero for a peer outside our PID namespace. Such a
|
||||
// peer still must authenticate with the per-sandbox mTLS certificate.
|
||||
if peer != 0
|
||||
&& peer != std::process::id()
|
||||
&& is_process_descendant(peer, std::process::id())
|
||||
.map_err(|_| io::Error::from_raw_os_error(libc::EACCES))?
|
||||
{
|
||||
return Err(io::Error::from_raw_os_error(libc::EACCES));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_process_descendant(mut process: u32, ancestor: u32) -> io::Result<bool> {
|
||||
// Drivers run the sandbox as workload PID 1, so orphaned descendants
|
||||
// reparent to it and cannot escape this check by double-forking.
|
||||
// Read kernel-owned ancestry, never workload-supplied paths or UIDs.
|
||||
// If a peer exits during inspection, fail closed for that connection.
|
||||
for _ in 0..1024 {
|
||||
if process == ancestor {
|
||||
return Ok(true);
|
||||
}
|
||||
if process == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let stat = std::fs::read_to_string(format!("/proc/{process}/stat"))?;
|
||||
let parent = stat
|
||||
.rsplit_once(')')
|
||||
.and_then(|(_, fields)| fields.split_whitespace().nth(1))
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "missing peer process parent")
|
||||
})?
|
||||
.parse::<u32>()
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
if parent == process {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"cyclic peer process ancestry",
|
||||
));
|
||||
}
|
||||
process = parent;
|
||||
}
|
||||
Err(io::Error::from_raw_os_error(libc::EACCES))
|
||||
}
|
||||
|
||||
fn remove_owned_stale_control_socket(socket_path: &Path) -> io::Result<()> {
|
||||
let metadata = match std::fs::symlink_metadata(socket_path) {
|
||||
Ok(metadata) => metadata,
|
||||
@@ -2639,7 +2758,12 @@ mod linux {
|
||||
}
|
||||
|
||||
impl ControlStream {
|
||||
#[cfg(test)]
|
||||
fn establish(self, runtime: &tokio::runtime::Handle) -> io::Result<Self> {
|
||||
runtime.block_on(self.establish_async(runtime))
|
||||
}
|
||||
|
||||
async fn establish_async(self, runtime: &tokio::runtime::Handle) -> io::Result<Self> {
|
||||
let Self::PendingTls {
|
||||
stream,
|
||||
server_config,
|
||||
@@ -2652,14 +2776,14 @@ mod linux {
|
||||
stream.into_tokio()?
|
||||
};
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(server_config);
|
||||
let stream = runtime.block_on(async {
|
||||
tokio::time::timeout(CONTROL_IO_TIMEOUT, acceptor.accept(stream))
|
||||
let stream = {
|
||||
tokio::time::timeout(CONTROL_HANDSHAKE_TIMEOUT, acceptor.accept(stream))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::TimedOut, "boundary TLS handshake timed out")
|
||||
})?
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
||||
})?;
|
||||
}?;
|
||||
Ok(Self::Tls {
|
||||
stream: Box::new(stream),
|
||||
runtime: runtime.clone(),
|
||||
@@ -2996,7 +3120,7 @@ mod linux {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_connection_slots_bound_unauthenticated_threads() {
|
||||
fn control_connection_slots_bound_authenticated_sessions() {
|
||||
let active = Arc::new(AtomicUsize::new(MAX_CONTROL_CONNECTIONS - 1));
|
||||
let slot = acquire_control_connection_slot(&active).expect("last available slot");
|
||||
assert!(acquire_control_connection_slot(&active).is_none());
|
||||
@@ -3004,6 +3128,273 @@ mod linux {
|
||||
assert_eq!(active.load(Ordering::Acquire), MAX_CONTROL_CONNECTIONS - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_control_rejects_workload_descendants_before_admission() {
|
||||
const CHILD_SOCKET: &str = "OPENSHELL_TEST_CONTROL_PEER_SOCKET";
|
||||
if let Some(path) = std::env::var_os(CHILD_SOCKET) {
|
||||
let mut stream = std::os::unix::net::UnixStream::connect(path).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
assert_eq!(stream.read(&mut [0_u8; 1]).unwrap(), 0);
|
||||
return;
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("control.sock");
|
||||
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "boundary_server::linux::tests::unix_control_rejects_workload_descendants_before_admission", "--nocapture"])
|
||||
.env(CHILD_SOCKET, &path).spawn().unwrap();
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
assert_eq!(
|
||||
reject_workload_unix_peer(&stream)
|
||||
.unwrap_err()
|
||||
.raw_os_error(),
|
||||
Some(libc::EACCES)
|
||||
);
|
||||
drop(stream);
|
||||
assert!(child.wait().unwrap().success());
|
||||
assert!(!is_process_descendant(std::process::id(), child.id()).unwrap());
|
||||
// Trusted same-process connections and external ancestors remain
|
||||
// eligible for mTLS; we do not equate same UID with workload trust.
|
||||
let client = std::os::unix::net::UnixStream::connect(&path).unwrap();
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
reject_workload_unix_peer(&stream).unwrap();
|
||||
drop(client);
|
||||
}
|
||||
|
||||
fn availability_test_runtime() -> Arc<BoundaryRuntime> {
|
||||
let (broker, launcher) = test_network_broker();
|
||||
Arc::new(BoundaryRuntime::new(
|
||||
BoundaryConfig {
|
||||
boundary_id: "availability".to_string(),
|
||||
generation: "generation-1".to_string(),
|
||||
session_epoch: "session-1".to_string(),
|
||||
bootstrap_token: "a".repeat(32),
|
||||
listener: BoundaryListenerConfig::TlsTcp {
|
||||
address: "127.0.0.1:5500".parse().unwrap(),
|
||||
tls: placeholder_server_tls(),
|
||||
},
|
||||
resource_claims: std::collections::BTreeMap::new(),
|
||||
resource_claim_files: std::collections::BTreeMap::new(),
|
||||
workload_identity: test_workload_identity(),
|
||||
driver_fence: test_driver_fence(),
|
||||
child_env: std::collections::HashMap::new(),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
broker,
|
||||
launcher,
|
||||
test_runtime_qualification(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn idle_uds_and_tcp_handshakes_do_not_consume_authenticated_slots() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (tls, _) = stage_test_tls(directory.path(), "pending");
|
||||
let server_config = Arc::new(load_tls_server_config(&tls).unwrap());
|
||||
let runtime = availability_test_runtime();
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let pending = Arc::new(tokio::sync::Semaphore::new(MAX_PENDING_HANDSHAKES));
|
||||
let mut clients: Vec<Box<dyn std::any::Any>> = Vec::new();
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
for index in 0..MAX_PENDING_HANDSHAKES {
|
||||
let stream = if index % 2 == 0 {
|
||||
let (server, client) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||
clients.push(Box::new(client));
|
||||
PlainControlStream::Unix(server)
|
||||
} else {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
clients.push(Box::new(
|
||||
std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(),
|
||||
));
|
||||
PlainControlStream::Tcp(listener.accept().unwrap().0)
|
||||
};
|
||||
let permit = pending.clone().try_acquire_owned().unwrap();
|
||||
tasks.spawn(serve_control_connection(
|
||||
ControlStream::PendingTls {
|
||||
stream,
|
||||
server_config: server_config.clone(),
|
||||
},
|
||||
runtime.clone(),
|
||||
permit,
|
||||
active.clone(),
|
||||
));
|
||||
}
|
||||
assert!(pending.clone().try_acquire_owned().is_err());
|
||||
assert_eq!(active.load(Ordering::Acquire), 0);
|
||||
tokio::time::timeout(CONTROL_HANDSHAKE_TIMEOUT + Duration::from_secs(2), async {
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
assert!(result.unwrap().unwrap_err().contains("timed out"));
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.available_permits(), MAX_PENDING_HANDSHAKES);
|
||||
assert_eq!(active.load(Ordering::Acquire), 0);
|
||||
drop(clients);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn grpc_blackhole_expires_connection_and_releases_mediation_lease() {
|
||||
let runtime = availability_test_runtime();
|
||||
let server_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let server_address = server_listener.local_addr().unwrap();
|
||||
let server_runtime = runtime.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = server_listener.accept().await.unwrap();
|
||||
serve_grpc(Box::new(stream), server_runtime).await
|
||||
});
|
||||
let proxy_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy_address = proxy_listener.local_addr().unwrap();
|
||||
let (blackhole, stop_forwarding) = tokio::sync::oneshot::channel::<()>();
|
||||
let proxy = tokio::spawn(async move {
|
||||
let (mut downstream, _) = proxy_listener.accept().await.unwrap();
|
||||
let mut upstream = tokio::net::TcpStream::connect(server_address)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::select! {
|
||||
_ = stop_forwarding => {},
|
||||
_ = tokio::io::copy_bidirectional(&mut upstream, &mut downstream) => panic!("proxy closed before blackhole"),
|
||||
}
|
||||
// Keep both sockets open without forwarding PING or ACK: this
|
||||
// models a silently dropped Kubernetes TCP path, not FIN/RST.
|
||||
std::future::pending::<()>().await;
|
||||
drop((upstream, downstream));
|
||||
});
|
||||
let channel =
|
||||
tonic::transport::Endpoint::from_shared(format!("http://{proxy_address}"))
|
||||
.unwrap()
|
||||
.connect()
|
||||
.await
|
||||
.unwrap();
|
||||
let (sender, receiver) = tokio::sync::mpsc::channel(4);
|
||||
let request = RequestEnvelope::new(
|
||||
"availability".to_string(),
|
||||
"a".repeat(32),
|
||||
Request::OpenMediation,
|
||||
)
|
||||
.unwrap();
|
||||
sender
|
||||
.send(BoundaryChunk {
|
||||
data: encode_frame(&request).unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut response = IsolationBoundaryClient::new(channel)
|
||||
.mediate(ReceiverStream::new(receiver))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.message().await.unwrap().is_some());
|
||||
tokio::time::sleep(CONTROL_KEEPALIVE_INTERVAL + Duration::from_secs(1)).await;
|
||||
assert!(
|
||||
!server.is_finished(),
|
||||
"healthy idle session must survive keepalive"
|
||||
);
|
||||
assert!(runtime.mediation_active.try_lock().is_err());
|
||||
blackhole.send(()).unwrap();
|
||||
tokio::time::timeout(
|
||||
CONTROL_KEEPALIVE_INTERVAL + CONTROL_KEEPALIVE_TIMEOUT + Duration::from_secs(3),
|
||||
server,
|
||||
)
|
||||
.await
|
||||
.expect("blackholed HTTP/2 connection must expire")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while runtime.mediation_active.try_lock().is_err() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("connection teardown must stop all bridges and release lease");
|
||||
let (mut replacement, task) = request_test_mediation(runtime, &"a".repeat(32)).await;
|
||||
let ready: ResponseEnvelope =
|
||||
openshell_isolation_interface::boundary_protocol::read_frame_async(
|
||||
&mut replacement,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(ready.response, Response::MediationReady));
|
||||
drop(replacement);
|
||||
task.await.unwrap().unwrap();
|
||||
drop(sender);
|
||||
proxy.abort();
|
||||
}
|
||||
|
||||
async fn request_test_mediation(
|
||||
runtime: Arc<BoundaryRuntime>,
|
||||
token: &str,
|
||||
) -> (
|
||||
tokio::io::DuplexStream,
|
||||
tokio::task::JoinHandle<Result<(), String>>,
|
||||
) {
|
||||
let (mut client, server) = tokio::io::duplex(4096);
|
||||
let task = tokio::spawn(serve_persistent_mediation(server, runtime));
|
||||
let envelope = RequestEnvelope::new(
|
||||
"availability".to_string(),
|
||||
token.to_string(),
|
||||
Request::OpenMediation,
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.write_all(&encode_frame(&envelope).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
(client, task)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mediation_replacement_waits_for_lease_and_rejects_bad_authentication() {
|
||||
let runtime = availability_test_runtime();
|
||||
let (mut first, first_task) =
|
||||
request_test_mediation(runtime.clone(), &"a".repeat(32)).await;
|
||||
let ready: ResponseEnvelope =
|
||||
openshell_isolation_interface::boundary_protocol::read_frame_async(&mut first)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(ready.response, Response::MediationReady));
|
||||
let (mut denied, denied_task) =
|
||||
request_test_mediation(runtime.clone(), &"b".repeat(32)).await;
|
||||
let response: ResponseEnvelope =
|
||||
openshell_isolation_interface::boundary_protocol::read_frame_async(&mut denied)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
response.response,
|
||||
Response::Error {
|
||||
kind: BoundaryErrorKind::Denied,
|
||||
..
|
||||
}
|
||||
));
|
||||
denied_task.await.unwrap().unwrap();
|
||||
let (mut replacement, replacement_task) =
|
||||
request_test_mediation(runtime.clone(), &"a".repeat(32)).await;
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), replacement.read_u8())
|
||||
.await
|
||||
.is_err(),
|
||||
"a live lease cannot be preempted"
|
||||
);
|
||||
drop(first);
|
||||
first_task.await.unwrap().unwrap();
|
||||
let ready: ResponseEnvelope = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
openshell_isolation_interface::boundary_protocol::read_frame_async(
|
||||
&mut replacement,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(ready.response, Response::MediationReady));
|
||||
assert!(runtime.mediation_active.try_lock().is_err());
|
||||
drop(replacement);
|
||||
replacement_task.await.unwrap().unwrap();
|
||||
assert!(runtime.mediation_active.try_lock().is_ok());
|
||||
}
|
||||
|
||||
fn test_workload_identity() -> ResolvedWorkloadIdentity {
|
||||
let mut supplementary_gids = nix::unistd::getgroups()
|
||||
.unwrap()
|
||||
|
||||
@@ -187,6 +187,7 @@ fn register_dns_socket(
|
||||
|
||||
#[derive(Clone)]
|
||||
struct NotificationQueues {
|
||||
protected_control_port: Option<u16>,
|
||||
accept_registrar: crate::accept_interrupt::AcceptRegistrar,
|
||||
identity_resolver: ProcfsIdentityResolver,
|
||||
pending: mpsc::Sender<PendingTcpOpen>,
|
||||
@@ -207,8 +208,11 @@ pub struct NetworkBroker {
|
||||
}
|
||||
|
||||
impl NetworkBroker {
|
||||
pub(crate) fn start(listener: NotificationListener) -> io::Result<Self> {
|
||||
Self::start_with_dns_address(listener, DNS_RELAY_ADDRESS)
|
||||
pub(crate) fn start(
|
||||
listener: NotificationListener,
|
||||
protected_control_port: Option<u16>,
|
||||
) -> io::Result<Self> {
|
||||
Self::start_with_dns_address(listener, DNS_RELAY_ADDRESS, protected_control_port)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "perf-harness"))]
|
||||
@@ -216,19 +220,27 @@ impl NetworkBroker {
|
||||
Self::start_with_dns_address(
|
||||
listener,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn start_with_dns_address(
|
||||
listener: NotificationListener,
|
||||
dns_address: SocketAddr,
|
||||
protected_control_port: Option<u16>,
|
||||
) -> io::Result<Self> {
|
||||
Self::start_with_decision_timeout(listener, dns_address, NETWORK_DECISION_TIMEOUT)
|
||||
Self::start_with_decision_timeout(
|
||||
listener,
|
||||
dns_address,
|
||||
protected_control_port,
|
||||
NETWORK_DECISION_TIMEOUT,
|
||||
)
|
||||
}
|
||||
|
||||
fn start_with_decision_timeout(
|
||||
listener: NotificationListener,
|
||||
dns_address: SocketAddr,
|
||||
protected_control_port: Option<u16>,
|
||||
decision_timeout: Duration,
|
||||
) -> io::Result<Self> {
|
||||
let listener = Arc::new(listener);
|
||||
@@ -244,6 +256,7 @@ impl NetworkBroker {
|
||||
let dns_relay = start_dns_relay(dns_address, pending_dns_tx)?;
|
||||
let dns_address = dns_relay.address;
|
||||
let queues = NotificationQueues {
|
||||
protected_control_port,
|
||||
accept_registrar: accept_monitor.registrar(),
|
||||
identity_resolver: ProcfsIdentityResolver::for_pid_namespace(),
|
||||
pending: pending_tx,
|
||||
@@ -616,6 +629,19 @@ fn create_socket(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_protected_control_destination(
|
||||
destination: SocketAddr,
|
||||
protected_port: Option<u16>,
|
||||
) -> io::Result<()> {
|
||||
// Reserve the listener's port across loopback aliases, IPv4-mapped IPv6,
|
||||
// and wildcard Pod listeners. A workload must never reach its control
|
||||
// endpoint, including through a supervisor-authorized external relay.
|
||||
if protected_port == Some(destination.port()) {
|
||||
return Err(io::Error::from_raw_os_error(libc::EACCES));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn connect_socket(
|
||||
registry: Arc<Mutex<SocketRegistry>>,
|
||||
listener: Arc<NotificationListener>,
|
||||
@@ -628,6 +654,7 @@ fn connect_socket(
|
||||
active_opens,
|
||||
identity_resolver,
|
||||
decision_timeout,
|
||||
protected_control_port,
|
||||
..
|
||||
} = queues;
|
||||
let notification_started = Instant::now();
|
||||
@@ -661,6 +688,7 @@ fn connect_socket(
|
||||
}
|
||||
let destination =
|
||||
read_socket_addr(notification.tid, notification.args[1], notification.args[2])?;
|
||||
reject_protected_control_destination(destination, protected_control_port)?;
|
||||
let (kind, socket_identity, nonblocking) = {
|
||||
let registry = lock(®istry);
|
||||
let entry = registry.resolve(notification.tid, fd)?;
|
||||
@@ -1771,6 +1799,7 @@ mod tests {
|
||||
let broker = NetworkBroker::start_with_decision_timeout(
|
||||
listener,
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
None,
|
||||
Duration::from_millis(50),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1875,6 +1904,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_control_port_rejects_loopback_aliases_and_pod_addresses() {
|
||||
for address in [
|
||||
"127.0.0.1:7443",
|
||||
"127.0.0.2:7443",
|
||||
"[::1]:7443",
|
||||
"[::ffff:127.0.0.1]:7443",
|
||||
"10.42.0.8:7443",
|
||||
] {
|
||||
assert_eq!(
|
||||
reject_protected_control_destination(address.parse().unwrap(), Some(7443))
|
||||
.unwrap_err()
|
||||
.raw_os_error(),
|
||||
Some(libc::EACCES)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
reject_protected_control_destination("127.0.0.1:8080".parse().unwrap(), Some(7443))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workload_cannot_fill_control_listener_with_loopback_connections() {
|
||||
let control = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
control.set_nonblocking(true).unwrap();
|
||||
let address = control.local_addr().unwrap();
|
||||
let (launcher, listener) =
|
||||
openshell_isolation_interface::linux::workload_launcher::start().unwrap();
|
||||
let _broker = NetworkBroker::start_with_dns_address(
|
||||
listener,
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
Some(address.port()),
|
||||
)
|
||||
.unwrap();
|
||||
launcher
|
||||
.execute(move || -> io::Result<()> {
|
||||
for _ in 0..160 {
|
||||
let error =
|
||||
TcpStream::connect(address).expect_err("control port must be unreachable");
|
||||
assert_eq!(error.raw_os_error(), Some(libc::EACCES));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
control.accept().unwrap_err().kind(),
|
||||
io::ErrorKind::WouldBlock
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_external_open_slots_are_bounded_and_reusable() {
|
||||
let active = Arc::new(AtomicUsize::new(OPEN_QUEUE_CAPACITY - 1));
|
||||
|
||||
@@ -434,17 +434,6 @@ pub fn enforce(prepared: PreparedRuleset) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Legacy single-phase apply. Kept for non-Linux platforms and tests.
|
||||
/// On Linux, callers should use [`prepare`] + [`enforce`] for correct
|
||||
/// privilege ordering.
|
||||
#[allow(dead_code)] // Retained for backward compat; live callers use prepare+enforce.
|
||||
pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
|
||||
if let Some(prepared) = prepare(policy, workdir)? {
|
||||
enforce(prepared)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tailor a rule's access mask to the inode referenced by its already-open FD.
|
||||
///
|
||||
/// Landlock directory-only rights such as `ReadDir` are invalid for regular
|
||||
|
||||
@@ -102,15 +102,6 @@ pub fn apply_supervisor_prelude() -> Result<()> {
|
||||
seccomp::apply_supervisor_prelude()
|
||||
}
|
||||
|
||||
/// Legacy single-phase apply. Kept for backward compatibility.
|
||||
/// New callers should use [`prepare`] + [`enforce`] for correct privilege ordering.
|
||||
#[allow(dead_code)] // Retained for backward compat; live callers use prepare+enforce.
|
||||
pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
|
||||
landlock::apply(policy, workdir)?;
|
||||
seccomp::apply(policy)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Probe Landlock availability and emit OCSF logs from the parent process.
|
||||
///
|
||||
/// This must be called **before** `pre_exec` / `fork()` so that the OCSF events
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! Platform sandboxing implementation.
|
||||
|
||||
use miette::Result;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use openshell_core::policy::SandboxPolicy;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -16,15 +17,9 @@ pub mod linux;
|
||||
/// Returns an error if the sandbox cannot be applied.
|
||||
// On Linux the spawn path uses `prepare`+`enforce` directly; this single-phase
|
||||
// apply is only invoked from the non-Linux spawn_impl.
|
||||
#[cfg_attr(target_os = "linux", allow(dead_code))]
|
||||
#[cfg_attr(not(target_os = "linux"), allow(clippy::unnecessary_wraps))]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::apply(policy, workdir)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = (policy, workdir);
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
const LOCAL_NO_PROXY: &str = "127.0.0.1,localhost,::1";
|
||||
|
||||
pub fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 9] {
|
||||
[
|
||||
("ALL_PROXY", proxy_url.to_owned()),
|
||||
("HTTP_PROXY", proxy_url.to_owned()),
|
||||
("HTTPS_PROXY", proxy_url.to_owned()),
|
||||
("NO_PROXY", LOCAL_NO_PROXY.to_owned()),
|
||||
("http_proxy", proxy_url.to_owned()),
|
||||
("https_proxy", proxy_url.to_owned()),
|
||||
("no_proxy", LOCAL_NO_PROXY.to_owned()),
|
||||
("grpc_proxy", proxy_url.to_owned()),
|
||||
// Node.js only honors HTTP(S)_PROXY for built-in fetch/http clients when
|
||||
// proxy support is explicitly enabled at process startup.
|
||||
("NODE_USE_ENV_PROXY", "1".to_owned()),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn tls_env_vars(
|
||||
ca_cert_path: &Path,
|
||||
combined_bundle_path: &Path,
|
||||
) -> [(&'static str, String); 6] {
|
||||
let ca_cert_path = ca_cert_path.display().to_string();
|
||||
let combined_bundle_path = combined_bundle_path.display().to_string();
|
||||
[
|
||||
("NODE_EXTRA_CA_CERTS", ca_cert_path.clone()),
|
||||
("DENO_CERT", ca_cert_path),
|
||||
("SSL_CERT_FILE", combined_bundle_path.clone()),
|
||||
("REQUESTS_CA_BUNDLE", combined_bundle_path.clone()),
|
||||
("CURL_CA_BUNDLE", combined_bundle_path.clone()),
|
||||
// Ubuntu Noble's git links against libcurl-gnutls, which ignores SSL_CERT_FILE.
|
||||
// git reads GIT_SSL_CAINFO (or http.sslCAInfo) to locate the CA bundle.
|
||||
("GIT_SSL_CAINFO", combined_bundle_path),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
#[test]
|
||||
fn apply_proxy_env_includes_node_proxy_opt_in_and_local_bypass() {
|
||||
let mut cmd = Command::new("/usr/bin/env");
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
for (key, value) in proxy_env_vars("http://10.200.0.1:3128") {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
let output = cmd.output().expect("spawn env");
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
|
||||
assert!(stdout.contains("HTTP_PROXY=http://10.200.0.1:3128"));
|
||||
assert!(stdout.contains("NO_PROXY=127.0.0.1,localhost,::1"));
|
||||
assert!(stdout.contains("NODE_USE_ENV_PROXY=1"));
|
||||
assert!(stdout.contains("no_proxy=127.0.0.1,localhost,::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_tls_env_sets_node_and_bundle_paths() {
|
||||
let mut cmd = Command::new("/usr/bin/env");
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
let ca_cert_path = Path::new("/etc/openshell-tls/openshell-ca.pem");
|
||||
let combined_bundle_path = Path::new("/etc/openshell-tls/ca-bundle.pem");
|
||||
for (key, value) in tls_env_vars(ca_cert_path, combined_bundle_path) {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
let output = cmd.output().expect("spawn env");
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
|
||||
assert!(stdout.contains("NODE_EXTRA_CA_CERTS=/etc/openshell-tls/openshell-ca.pem"));
|
||||
assert!(stdout.contains("DENO_CERT=/etc/openshell-tls/openshell-ca.pem"));
|
||||
assert!(stdout.contains("SSL_CERT_FILE=/etc/openshell-tls/ca-bundle.pem"));
|
||||
assert!(stdout.contains("REQUESTS_CA_BUNDLE=/etc/openshell-tls/ca-bundle.pem"));
|
||||
assert!(stdout.contains("CURL_CA_BUNDLE=/etc/openshell-tls/ca-bundle.pem"));
|
||||
assert!(stdout.contains("GIT_SSL_CAINFO=/etc/openshell-tls/ca-bundle.pem"));
|
||||
}
|
||||
}
|
||||
@@ -1,833 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Driver identity normalization and OCI `USER` resolution.
|
||||
|
||||
use crate::process::ResolvedProcessIdentity;
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
use openshell_core::policy::SandboxPolicy;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
|
||||
const PASSWD_PATH: &str = "/etc/passwd";
|
||||
const GROUP_PATH: &str = "/etc/group";
|
||||
const MAX_ACCOUNT_FILE_SIZE: u64 = 1024 * 1024;
|
||||
const MAX_ACCOUNT_LINE_SIZE: usize = 8 * 1024;
|
||||
const MAX_ACCOUNT_FIELD_SIZE: usize = 1024;
|
||||
|
||||
/// Identity input selected by the active compute driver.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DriverIdentity {
|
||||
/// Platform-selected identity used by Kubernetes and `OpenShift`.
|
||||
Resolved { uid: u32, gid: u32 },
|
||||
/// Raw OCI `Config.User` selected by Docker and Podman.
|
||||
OciUser { declaration: String },
|
||||
/// Drivers with no authoritative identity metadata.
|
||||
None,
|
||||
}
|
||||
|
||||
impl DriverIdentity {
|
||||
/// Normalize the protected driver environment into one identity variant.
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let oci_user = optional_utf8_env(openshell_core::sandbox_env::OCI_IMAGE_USER)?;
|
||||
let uid = optional_nonempty_utf8_env(openshell_core::sandbox_env::SANDBOX_UID)?;
|
||||
let gid = optional_nonempty_utf8_env(openshell_core::sandbox_env::SANDBOX_GID)?;
|
||||
Self::from_values(oci_user, uid, gid)
|
||||
}
|
||||
|
||||
fn from_values(
|
||||
oci_user: Option<String>,
|
||||
uid: Option<String>,
|
||||
gid: Option<String>,
|
||||
) -> Result<Self> {
|
||||
// Resolved-identity drivers explicitly clear the OCI declaration so
|
||||
// an image-baked or user-supplied value cannot select the OCI path.
|
||||
// Preserve an empty declaration when no resolved pair is present:
|
||||
// Docker and Podman use that state to reject images without USER.
|
||||
let oci_user = if oci_user.as_deref() == Some("") && (uid.is_some() || gid.is_some()) {
|
||||
None
|
||||
} else {
|
||||
oci_user
|
||||
};
|
||||
|
||||
match (oci_user, uid, gid) {
|
||||
(Some(declaration), None, None) => Ok(Self::OciUser { declaration }),
|
||||
(None, Some(uid), Some(gid)) => {
|
||||
let uid = uid.parse::<u32>().ok().filter(|uid| {
|
||||
(openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID)
|
||||
.contains(uid)
|
||||
});
|
||||
let gid = gid.parse::<u32>().ok().filter(|gid| {
|
||||
(openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID)
|
||||
.contains(gid)
|
||||
});
|
||||
let (Some(uid), Some(gid)) = (uid, gid) else {
|
||||
return Err(miette::miette!(
|
||||
"driver UID/GID must be numeric identities in range [{}, {}]",
|
||||
openshell_policy::MIN_SANDBOX_UID,
|
||||
openshell_policy::MAX_SANDBOX_UID
|
||||
));
|
||||
};
|
||||
Ok(Self::Resolved { uid, gid })
|
||||
}
|
||||
(None, None, None) => Ok(Self::None),
|
||||
(Some(_), _, _) => Err(miette::miette!(
|
||||
"{} conflicts with non-empty {}/{} driver identity",
|
||||
openshell_core::sandbox_env::OCI_IMAGE_USER,
|
||||
openshell_core::sandbox_env::SANDBOX_UID,
|
||||
openshell_core::sandbox_env::SANDBOX_GID
|
||||
)),
|
||||
(None, _, _) => Err(miette::miette!(
|
||||
"{} and {} must be supplied together",
|
||||
openshell_core::sandbox_env::SANDBOX_UID,
|
||||
openshell_core::sandbox_env::SANDBOX_GID
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a driver identity before any workload child becomes reachable.
|
||||
pub fn resolve_process_identity(
|
||||
policy: &mut SandboxPolicy,
|
||||
driver_identity: &DriverIdentity,
|
||||
) -> Result<ResolvedProcessIdentity> {
|
||||
match driver_identity {
|
||||
DriverIdentity::Resolved { uid, gid } => {
|
||||
policy.process.run_as_user = Some(uid.to_string());
|
||||
policy.process.run_as_group = Some(gid.to_string());
|
||||
// Kubernetes/OpenShift already supply numeric policy values and
|
||||
// retain their existing privilege-drop path.
|
||||
Ok(ResolvedProcessIdentity::default())
|
||||
}
|
||||
DriverIdentity::OciUser { declaration } => resolve_oci_process_identity_at(
|
||||
policy,
|
||||
declaration,
|
||||
Path::new(PASSWD_PATH),
|
||||
Path::new(GROUP_PATH),
|
||||
),
|
||||
DriverIdentity::None => {
|
||||
// VM/offline drivers retain the pre-OCI per-field fallback. A
|
||||
// partial policy must never leave the omitted component at the
|
||||
// root supervisor identity.
|
||||
if policy
|
||||
.process
|
||||
.run_as_user
|
||||
.as_deref()
|
||||
.is_none_or(str::is_empty)
|
||||
{
|
||||
policy.process.run_as_user = Some("sandbox".into());
|
||||
}
|
||||
if policy
|
||||
.process
|
||||
.run_as_group
|
||||
.as_deref()
|
||||
.is_none_or(str::is_empty)
|
||||
{
|
||||
policy.process.run_as_group = Some("sandbox".into());
|
||||
}
|
||||
Ok(ResolvedProcessIdentity::default())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::similar_names)]
|
||||
fn resolve_oci_process_identity_at(
|
||||
policy: &mut SandboxPolicy,
|
||||
declaration: &str,
|
||||
passwd_path: &Path,
|
||||
group_path: &Path,
|
||||
) -> Result<ResolvedProcessIdentity> {
|
||||
let explicit_user = policy
|
||||
.process
|
||||
.run_as_user
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let explicit_group = policy
|
||||
.process
|
||||
.run_as_group
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
|
||||
if explicit_user && explicit_group {
|
||||
return Ok(ResolvedProcessIdentity::default());
|
||||
}
|
||||
|
||||
let (oci_user, oci_group) = split_oci_declaration(declaration);
|
||||
let needs_primary_gid = !explicit_group && oci_group.is_none();
|
||||
let resolved_user = if !explicit_user || needs_primary_gid {
|
||||
Some(resolve_required_oci_user(
|
||||
oci_user,
|
||||
passwd_path,
|
||||
declaration,
|
||||
needs_primary_gid,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let oci_uid = if explicit_user {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
resolved_user
|
||||
.as_ref()
|
||||
.expect("omitted OCI user must have been resolved")
|
||||
.0,
|
||||
)
|
||||
};
|
||||
|
||||
if !explicit_user {
|
||||
policy.process.run_as_user = Some(oci_user.to_string());
|
||||
}
|
||||
|
||||
let oci_gid = if explicit_group {
|
||||
None
|
||||
} else {
|
||||
let (group_value, gid) = match oci_group {
|
||||
Some(group) if !group.is_empty() => {
|
||||
let gid = validate_oci_group(group, group_path, declaration)?;
|
||||
(group.to_string(), gid)
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(miette::miette!(
|
||||
"OCI USER '{declaration}' has an empty group component"
|
||||
));
|
||||
}
|
||||
None => {
|
||||
let gid = resolved_user
|
||||
.and_then(|(_, primary_gid)| primary_gid)
|
||||
.ok_or_else(|| {
|
||||
miette::miette!(
|
||||
"OCI USER '{declaration}' uses a numeric UID without an explicit group, \
|
||||
but /etc/passwd has no matching primary GID"
|
||||
)
|
||||
})?;
|
||||
(gid.to_string(), gid)
|
||||
}
|
||||
};
|
||||
policy.process.run_as_group = Some(group_value);
|
||||
Some(gid)
|
||||
};
|
||||
|
||||
Ok(ResolvedProcessIdentity::new(oci_uid, oci_gid))
|
||||
}
|
||||
|
||||
fn split_oci_declaration(declaration: &str) -> (&str, Option<&str>) {
|
||||
declaration
|
||||
.split_once(':')
|
||||
.map_or((declaration, None), |(user, group)| (user, Some(group)))
|
||||
}
|
||||
|
||||
fn resolve_required_oci_user(
|
||||
user: &str,
|
||||
passwd_path: &Path,
|
||||
declaration: &str,
|
||||
require_primary_gid: bool,
|
||||
) -> Result<(u32, Option<u32>)> {
|
||||
if user.is_empty() {
|
||||
return Err(miette::miette!(
|
||||
"OCI USER is required because run_as_user is omitted"
|
||||
));
|
||||
}
|
||||
validate_component(user, "OCI user")?;
|
||||
if user == "root" {
|
||||
return Err(miette::miette!("OCI USER '{declaration}' selects root"));
|
||||
}
|
||||
if let Ok(uid) = user.parse::<u32>() {
|
||||
if uid == 0 {
|
||||
return Err(miette::miette!("OCI USER '{declaration}' selects UID 0"));
|
||||
}
|
||||
let primary_gid = if require_primary_gid {
|
||||
find_passwd_by_uid(passwd_path, uid)?.map(|entry| entry.gid)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if primary_gid == Some(0) {
|
||||
return Err(miette::miette!(
|
||||
"OCI USER '{declaration}' resolves to prohibited primary GID 0"
|
||||
));
|
||||
}
|
||||
return Ok((uid, primary_gid));
|
||||
}
|
||||
let entry = find_passwd_by_name(passwd_path, user)?
|
||||
.ok_or_else(|| miette::miette!("OCI USER name '{user}' was not found in /etc/passwd"))?;
|
||||
if entry.uid == 0 {
|
||||
return Err(miette::miette!(
|
||||
"OCI USER '{declaration}' resolves to prohibited UID 0"
|
||||
));
|
||||
}
|
||||
if require_primary_gid && entry.gid == 0 {
|
||||
return Err(miette::miette!(
|
||||
"OCI USER '{declaration}' resolves to prohibited primary GID 0"
|
||||
));
|
||||
}
|
||||
Ok((entry.uid, require_primary_gid.then_some(entry.gid)))
|
||||
}
|
||||
|
||||
fn validate_oci_group(value: &str, group_path: &Path, declaration: &str) -> Result<u32> {
|
||||
validate_component(value, "OCI group")?;
|
||||
if value == "root" {
|
||||
return Err(miette::miette!(
|
||||
"OCI USER '{declaration}' selects root group"
|
||||
));
|
||||
}
|
||||
let gid = if let Ok(gid) = value.parse::<u32>() {
|
||||
gid
|
||||
} else {
|
||||
find_group_by_name(group_path, value)?
|
||||
.ok_or_else(|| miette::miette!("OCI group '{value}' was not found in /etc/group"))?
|
||||
.gid
|
||||
};
|
||||
if gid == 0 {
|
||||
return Err(miette::miette!(
|
||||
"OCI USER '{declaration}' resolves to prohibited GID 0"
|
||||
));
|
||||
}
|
||||
Ok(gid)
|
||||
}
|
||||
|
||||
fn validate_component(value: &str, kind: &str) -> Result<()> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_ACCOUNT_FIELD_SIZE
|
||||
|| value.trim() != value
|
||||
|| value.chars().any(|ch| ch.is_control() || ch == ':')
|
||||
{
|
||||
return Err(miette::miette!("{kind} component '{value}' is malformed"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PasswdEntry {
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct GroupEntry {
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
fn find_passwd_by_name(path: &Path, name: &str) -> Result<Option<PasswdEntry>> {
|
||||
find_unique(path, |fields| {
|
||||
(fields.first().copied() == Some(name)).then(|| parse_passwd(fields))
|
||||
})
|
||||
}
|
||||
|
||||
fn find_passwd_by_uid(path: &Path, uid: u32) -> Result<Option<PasswdEntry>> {
|
||||
find_unique(path, |fields| {
|
||||
fields
|
||||
.get(2)
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.filter(|candidate| *candidate == uid)
|
||||
.map(|_| parse_passwd(fields))
|
||||
})
|
||||
}
|
||||
|
||||
fn find_group_by_name(path: &Path, name: &str) -> Result<Option<GroupEntry>> {
|
||||
find_unique(path, |fields| {
|
||||
(fields.first().copied() == Some(name)).then(|| parse_group(fields))
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve supplementary groups declared for an OCI named user without
|
||||
/// consulting NSS. Numeric OCI users have no trustworthy group-membership
|
||||
/// name and therefore receive no supplementary groups.
|
||||
pub fn resolve_oci_supplementary_gids(declaration: &str, primary_gid: u32) -> Result<Vec<u32>> {
|
||||
resolve_oci_supplementary_gids_at(declaration, primary_gid, Path::new(GROUP_PATH))
|
||||
}
|
||||
|
||||
fn resolve_oci_supplementary_gids_at(
|
||||
declaration: &str,
|
||||
primary_gid: u32,
|
||||
group_path: &Path,
|
||||
) -> Result<Vec<u32>> {
|
||||
let (user, _) = split_oci_declaration(declaration);
|
||||
validate_component(user, "OCI user")?;
|
||||
if user.parse::<u32>().is_ok() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = read_account_file(group_path)?;
|
||||
let mut gids = vec![primary_gid];
|
||||
for line in content.lines() {
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if line.len() > MAX_ACCOUNT_LINE_SIZE {
|
||||
return Err(miette::miette!(
|
||||
"account file '{}' contains an oversized line",
|
||||
group_path.display()
|
||||
));
|
||||
}
|
||||
let fields = line.split(':').collect::<Vec<_>>();
|
||||
if fields.len() != 4
|
||||
|| fields
|
||||
.iter()
|
||||
.any(|field| field.len() > MAX_ACCOUNT_FIELD_SIZE)
|
||||
{
|
||||
return Err(miette::miette!(
|
||||
"group membership entry in '{}' is malformed",
|
||||
group_path.display()
|
||||
));
|
||||
}
|
||||
if !fields[3].split(',').any(|member| member == user) {
|
||||
continue;
|
||||
}
|
||||
let gid = fields[2].parse::<u32>().map_err(|_| {
|
||||
miette::miette!(
|
||||
"group membership GID in '{}' is malformed",
|
||||
group_path.display()
|
||||
)
|
||||
})?;
|
||||
if gid == 0 {
|
||||
return Err(miette::miette!(
|
||||
"OCI user '{user}' is a member of prohibited GID 0"
|
||||
));
|
||||
}
|
||||
gids.push(gid);
|
||||
}
|
||||
gids.sort_unstable();
|
||||
gids.dedup();
|
||||
Ok(gids)
|
||||
}
|
||||
|
||||
fn find_unique<T>(
|
||||
path: &Path,
|
||||
mut select: impl FnMut(&[&str]) -> Option<Result<T>>,
|
||||
) -> Result<Option<T>> {
|
||||
let content = read_account_file(path)?;
|
||||
let mut found = None;
|
||||
for line in content.lines() {
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if line.len() > MAX_ACCOUNT_LINE_SIZE {
|
||||
return Err(miette::miette!(
|
||||
"account file '{}' contains an oversized line",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let fields = line.split(':').collect::<Vec<_>>();
|
||||
if fields
|
||||
.iter()
|
||||
.any(|field| field.len() > MAX_ACCOUNT_FIELD_SIZE)
|
||||
{
|
||||
return Err(miette::miette!(
|
||||
"account file '{}' contains an oversized field",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let Some(candidate) = select(&fields) else {
|
||||
continue;
|
||||
};
|
||||
let candidate = candidate?;
|
||||
if found.replace(candidate).is_some() {
|
||||
return Err(miette::miette!(
|
||||
"account identity is ambiguous in '{}'",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn parse_passwd(fields: &[&str]) -> Result<PasswdEntry> {
|
||||
if fields.len() != 7 {
|
||||
return Err(miette::miette!("matching /etc/passwd entry is malformed"));
|
||||
}
|
||||
Ok(PasswdEntry {
|
||||
uid: fields[2]
|
||||
.parse()
|
||||
.map_err(|_| miette::miette!("matching /etc/passwd UID is malformed"))?,
|
||||
gid: fields[3]
|
||||
.parse()
|
||||
.map_err(|_| miette::miette!("matching /etc/passwd GID is malformed"))?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_group(fields: &[&str]) -> Result<GroupEntry> {
|
||||
if fields.len() != 4 {
|
||||
return Err(miette::miette!("matching /etc/group entry is malformed"));
|
||||
}
|
||||
Ok(GroupEntry {
|
||||
gid: fields[2]
|
||||
.parse()
|
||||
.map_err(|_| miette::miette!("matching /etc/group GID is malformed"))?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_account_file(path: &Path) -> Result<String> {
|
||||
let mut options = OpenOptions::new();
|
||||
options
|
||||
.read(true)
|
||||
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
|
||||
let mut file = options
|
||||
.open(path)
|
||||
.into_diagnostic()
|
||||
.map_err(|error| miette::miette!("failed to open '{}': {error}", path.display()))?;
|
||||
validate_account_file(&file, path)?;
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
file.by_ref()
|
||||
.take(MAX_ACCOUNT_FILE_SIZE + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.into_diagnostic()?;
|
||||
if bytes.len() as u64 > MAX_ACCOUNT_FILE_SIZE {
|
||||
return Err(miette::miette!(
|
||||
"account file '{}' exceeds {MAX_ACCOUNT_FILE_SIZE} bytes",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|_| miette::miette!("account file '{}' is not valid UTF-8", path.display()))
|
||||
}
|
||||
|
||||
fn validate_account_file(file: &File, path: &Path) -> Result<()> {
|
||||
let metadata = file.metadata().into_diagnostic()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(miette::miette!(
|
||||
"account path '{}' is not a regular file",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if metadata.len() > MAX_ACCOUNT_FILE_SIZE {
|
||||
return Err(miette::miette!(
|
||||
"account file '{}' exceeds {MAX_ACCOUNT_FILE_SIZE} bytes",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn optional_utf8_env(name: &str) -> Result<Option<String>> {
|
||||
std::env::var_os(name)
|
||||
.map(|value| {
|
||||
value
|
||||
.into_string()
|
||||
.map_err(|_| miette::miette!("{name} is not valid UTF-8"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn optional_nonempty_utf8_env(name: &str) -> Result<Option<String>> {
|
||||
Ok(optional_utf8_env(name)?.filter(|value| !value.is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openshell_core::policy::SandboxPolicy;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn account_files(
|
||||
passwd: &str,
|
||||
group: &str,
|
||||
) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
|
||||
let dir = tempdir().unwrap();
|
||||
let passwd_path = dir.path().join("passwd");
|
||||
let group_path = dir.path().join("group");
|
||||
fs::write(&passwd_path, passwd).unwrap();
|
||||
fs::write(&group_path, group).unwrap();
|
||||
(dir, passwd_path, group_path)
|
||||
}
|
||||
|
||||
fn policy(user: Option<&str>, group: Option<&str>) -> SandboxPolicy {
|
||||
let mut policy = SandboxPolicy {
|
||||
version: 1,
|
||||
filesystem: openshell_core::policy::FilesystemPolicy::default(),
|
||||
network: openshell_core::policy::NetworkPolicy::default(),
|
||||
landlock: openshell_core::policy::LandlockPolicy::default(),
|
||||
process: openshell_core::policy::ProcessPolicy::default(),
|
||||
};
|
||||
policy.process.run_as_user = user.map(str::to_string);
|
||||
policy.process.run_as_group = group.map(str::to_string);
|
||||
policy
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_field_policy_precedence_resolves_complete_pair() {
|
||||
let (_dir, passwd, group) = account_files(
|
||||
"app:x:1234:1235::/home/app:/bin/sh\nsandbox:x:2000:2001::/sandbox:/bin/sh\n",
|
||||
"staff:x:1235:\nsandbox:x:2001:\n",
|
||||
);
|
||||
let cases = [
|
||||
(
|
||||
Some("2000"),
|
||||
Some("2001"),
|
||||
"root",
|
||||
"2000",
|
||||
"2001",
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
Some("2000"),
|
||||
None,
|
||||
"app:staff",
|
||||
"2000",
|
||||
"staff",
|
||||
None,
|
||||
Some(1235),
|
||||
),
|
||||
(
|
||||
None,
|
||||
Some("2001"),
|
||||
"app:root",
|
||||
"app",
|
||||
"2001",
|
||||
Some(1234),
|
||||
None,
|
||||
),
|
||||
(
|
||||
None,
|
||||
None,
|
||||
"app:staff",
|
||||
"app",
|
||||
"staff",
|
||||
Some(1234),
|
||||
Some(1235),
|
||||
),
|
||||
(None, None, "app", "app", "1235", Some(1234), Some(1235)),
|
||||
];
|
||||
for (
|
||||
user,
|
||||
group_name,
|
||||
declaration,
|
||||
expected_user,
|
||||
expected_group,
|
||||
resolved_uid,
|
||||
resolved_gid,
|
||||
) in cases
|
||||
{
|
||||
let mut policy = policy(user, group_name);
|
||||
let resolved =
|
||||
resolve_oci_process_identity_at(&mut policy, declaration, &passwd, &group).unwrap();
|
||||
assert_eq!(policy.process.run_as_user.as_deref(), Some(expected_user));
|
||||
assert_eq!(policy.process.run_as_group.as_deref(), Some(expected_group));
|
||||
assert_eq!(resolved.uid(), resolved_uid);
|
||||
assert_eq!(resolved.gid(), resolved_gid);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_pair_does_not_require_account_entries() {
|
||||
let dir = tempdir().unwrap();
|
||||
let passwd = dir.path().join("missing-passwd");
|
||||
let group = dir.path().join("missing-group");
|
||||
let mut policy = policy(None, None);
|
||||
let resolved =
|
||||
resolve_oci_process_identity_at(&mut policy, "1234:1235", &passwd, &group).unwrap();
|
||||
assert_eq!(policy.process.run_as_user.as_deref(), Some("1234"));
|
||||
assert_eq!(policy.process.run_as_group.as_deref(), Some("1235"));
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedProcessIdentity::new(Some(1234), Some(1235))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_identity_is_preserved_without_inspecting_oci_or_accounts() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut policy = policy(Some("sandbox"), Some("sandbox"));
|
||||
|
||||
let resolved = resolve_oci_process_identity_at(
|
||||
&mut policy,
|
||||
"root:root",
|
||||
&dir.path().join("missing-passwd"),
|
||||
&dir.path().join("missing-group"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(policy.process.run_as_user.as_deref(), Some("sandbox"));
|
||||
assert_eq!(policy.process.run_as_group.as_deref(), Some("sandbox"));
|
||||
assert_eq!(resolved, ResolvedProcessIdentity::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driver_identity_inputs_are_mutually_exclusive_and_complete() {
|
||||
assert_eq!(
|
||||
DriverIdentity::from_values(Some("app".into()), None, None).unwrap(),
|
||||
DriverIdentity::OciUser {
|
||||
declaration: "app".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
DriverIdentity::from_values(None, Some("1234".into()), Some("1235".into())).unwrap(),
|
||||
DriverIdentity::Resolved {
|
||||
uid: 1234,
|
||||
gid: 1235
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
DriverIdentity::from_values(None, Some("500".into()), Some("30".into())).unwrap(),
|
||||
DriverIdentity::Resolved { uid: 500, gid: 30 }
|
||||
);
|
||||
assert_eq!(
|
||||
DriverIdentity::from_values(
|
||||
Some(String::new()),
|
||||
Some("1234".into()),
|
||||
Some("1235".into())
|
||||
)
|
||||
.unwrap(),
|
||||
DriverIdentity::Resolved {
|
||||
uid: 1234,
|
||||
gid: 1235
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
DriverIdentity::from_values(Some(String::new()), None, None).unwrap(),
|
||||
DriverIdentity::OciUser {
|
||||
declaration: String::new()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
DriverIdentity::from_values(None, None, None).unwrap(),
|
||||
DriverIdentity::None
|
||||
);
|
||||
assert!(
|
||||
DriverIdentity::from_values(
|
||||
Some("app".into()),
|
||||
Some("1234".into()),
|
||||
Some("1235".into())
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(DriverIdentity::from_values(None, Some("1234".into()), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_driver_identity_completes_partial_policy_with_sandbox() {
|
||||
let cases = [
|
||||
(None, Some("staff"), "sandbox", "staff"),
|
||||
(Some("app"), None, "app", "sandbox"),
|
||||
(None, None, "sandbox", "sandbox"),
|
||||
(Some("app"), Some("staff"), "app", "staff"),
|
||||
];
|
||||
|
||||
for (user, group, expected_user, expected_group) in cases {
|
||||
let mut policy = policy(user, group);
|
||||
let resolved = resolve_process_identity(&mut policy, &DriverIdentity::None).unwrap();
|
||||
|
||||
assert_eq!(policy.process.run_as_user.as_deref(), Some(expected_user));
|
||||
assert_eq!(policy.process.run_as_group.as_deref(), Some(expected_group));
|
||||
assert_eq!(resolved, ResolvedProcessIdentity::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_uid_uses_passwd_primary_gid() {
|
||||
let (_dir, passwd, group) = account_files("app:x:1234:4321::/home/app:/bin/sh\n", "");
|
||||
let mut policy = policy(None, None);
|
||||
let resolved =
|
||||
resolve_oci_process_identity_at(&mut policy, "1234", &passwd, &group).unwrap();
|
||||
assert_eq!(policy.process.run_as_group.as_deref(), Some("4321"));
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedProcessIdentity::new(Some(1234), Some(4321))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_oci_user_resolves_bounded_supplementary_groups() {
|
||||
let (_dir, _passwd, group) = account_files(
|
||||
"",
|
||||
"primary:x:1235:\nvideo:x:44:app,other\naudio:x:63:other\nrender:x:107:app\n",
|
||||
);
|
||||
|
||||
let gids = resolve_oci_supplementary_gids_at("app:primary", 1235, &group).unwrap();
|
||||
assert_eq!(gids, vec![44, 107, 1235]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_oci_user_has_no_named_supplementary_groups() {
|
||||
let dir = tempdir().unwrap();
|
||||
let missing_group = dir.path().join("missing-group");
|
||||
|
||||
let gids = resolve_oci_supplementary_gids_at("1234:1235", 1235, &missing_group).unwrap();
|
||||
assert!(gids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oci_supplementary_membership_rejects_root_group() {
|
||||
let (_dir, _passwd, group) = account_files("", "root:x:0:app\n");
|
||||
|
||||
let error =
|
||||
resolve_oci_supplementary_gids_at("app", 1235, &group).expect_err("GID 0 must fail");
|
||||
assert!(error.to_string().contains("prohibited GID 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_unknown_ambiguous_and_root_identities_fail() {
|
||||
let (_dir, passwd, group) = account_files(
|
||||
"app:x:1234:1235::/home/app:/bin/sh\napp:x:2234:2235::/home/app2:/bin/sh\n",
|
||||
"staff:x:1235:\nstaff:x:2235:\n",
|
||||
);
|
||||
for declaration in ["", "unknown", "app", "9999", "0:1235", "1234:0"] {
|
||||
let mut policy = policy(None, None);
|
||||
assert!(
|
||||
resolve_oci_process_identity_at(&mut policy, declaration, &passwd, &group).is_err(),
|
||||
"{declaration:?} unexpectedly resolved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_component_is_validated_independently() {
|
||||
let (_dir, passwd, group) =
|
||||
account_files("app:x:1234:1235::/home/app:/bin/sh\n", "staff:x:1235:\n");
|
||||
|
||||
let mut explicit_user = policy(Some("1234"), None);
|
||||
let resolved =
|
||||
resolve_oci_process_identity_at(&mut explicit_user, "root:staff", &passwd, &group)
|
||||
.unwrap();
|
||||
assert_eq!(explicit_user.process.run_as_user.as_deref(), Some("1234"));
|
||||
assert_eq!(explicit_user.process.run_as_group.as_deref(), Some("staff"));
|
||||
assert_eq!(resolved, ResolvedProcessIdentity::new(None, Some(1235)));
|
||||
|
||||
let mut explicit_group = policy(None, Some("1235"));
|
||||
let resolved =
|
||||
resolve_oci_process_identity_at(&mut explicit_group, "app:root", &passwd, &group)
|
||||
.unwrap();
|
||||
assert_eq!(explicit_group.process.run_as_user.as_deref(), Some("app"));
|
||||
assert_eq!(explicit_group.process.run_as_group.as_deref(), Some("1235"));
|
||||
assert_eq!(resolved, ResolvedProcessIdentity::new(Some(1234), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_oci_components_mapping_to_root_are_rejected() {
|
||||
let (_dir, passwd, group) = account_files(
|
||||
"root_alias:x:0:1235::/root:/bin/sh\napp:x:1234:1235::/home/app:/bin/sh\n",
|
||||
"root_alias:x:0:\nstaff:x:1235:\n",
|
||||
);
|
||||
|
||||
let mut root_user = policy(None, None);
|
||||
assert!(
|
||||
resolve_oci_process_identity_at(&mut root_user, "root_alias:staff", &passwd, &group)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let mut root_group = policy(None, None);
|
||||
assert!(
|
||||
resolve_oci_process_identity_at(&mut root_group, "app:root_alias", &passwd, &group)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn account_file_symlinks_are_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let (_dir, passwd, group) =
|
||||
account_files("app:x:1234:1235::/home/app:/bin/sh\n", "staff:x:1235:\n");
|
||||
let link = passwd.with_file_name("passwd-link");
|
||||
symlink(&passwd, &link).unwrap();
|
||||
|
||||
let mut policy = policy(None, None);
|
||||
assert!(resolve_oci_process_identity_at(&mut policy, "app:staff", &link, &group).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,769 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Landlock filesystem sandboxing.
|
||||
|
||||
use landlock::{
|
||||
ABI, Access, AccessFs, BitFlags, CompatLevel, Compatible, PathBeneath, PathFd, PathFdError,
|
||||
Ruleset, RulesetAttr, RulesetCreatedAttr,
|
||||
};
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
use openshell_core::policy::{LandlockCompatibility, SandboxPolicy};
|
||||
use std::os::fd::AsFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::debug;
|
||||
|
||||
/// Result of probing the kernel for Landlock support.
|
||||
#[derive(Debug)]
|
||||
pub enum LandlockAvailability {
|
||||
/// Landlock is available with the given ABI version.
|
||||
Available { abi: i32 },
|
||||
/// Kernel does not implement Landlock (ENOSYS).
|
||||
NotImplemented,
|
||||
/// Landlock is compiled in but not enabled at boot (EOPNOTSUPP).
|
||||
NotEnabled,
|
||||
/// Landlock syscall is blocked, likely by a container seccomp profile (EPERM).
|
||||
Blocked,
|
||||
/// Unexpected error from the probe syscall.
|
||||
Unknown(i32),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LandlockAvailability {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Available { abi } => write!(f, "available (ABI v{abi})"),
|
||||
Self::NotImplemented => {
|
||||
write!(f, "not implemented (kernel lacks CONFIG_SECURITY_LANDLOCK)")
|
||||
}
|
||||
Self::NotEnabled => write!(
|
||||
f,
|
||||
"not enabled (Landlock built into kernel but not in active LSM list)"
|
||||
),
|
||||
Self::Blocked => write!(
|
||||
f,
|
||||
"blocked (container seccomp profile denies Landlock syscalls)"
|
||||
),
|
||||
Self::Unknown(errno) => write!(f, "unexpected probe error (errno {errno})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the kernel for Landlock support by issuing the `landlock_create_ruleset`
|
||||
/// syscall with the version-check flag.
|
||||
///
|
||||
/// This is safe to call from the parent process and does not create any file
|
||||
/// descriptors or modify process state.
|
||||
pub fn probe_availability() -> LandlockAvailability {
|
||||
// landlock_create_ruleset syscall number (same on x86_64 and aarch64).
|
||||
const SYS_LANDLOCK_CREATE_RULESET: libc::c_long = 444;
|
||||
// Flag: return the highest supported ABI version instead of creating a ruleset.
|
||||
const LANDLOCK_CREATE_RULESET_VERSION: libc::c_uint = 1 << 0;
|
||||
|
||||
// SAFETY: landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)
|
||||
// is a read-only probe that returns the ABI version or an error code.
|
||||
// It does not allocate file descriptors or modify process state.
|
||||
#[allow(unsafe_code)]
|
||||
let ret = unsafe {
|
||||
libc::syscall(
|
||||
SYS_LANDLOCK_CREATE_RULESET,
|
||||
std::ptr::null::<libc::c_void>(),
|
||||
0_usize,
|
||||
LANDLOCK_CREATE_RULESET_VERSION,
|
||||
)
|
||||
};
|
||||
|
||||
if ret >= 0 {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
LandlockAvailability::Available { abi: ret as i32 }
|
||||
} else {
|
||||
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
|
||||
match errno {
|
||||
libc::ENOSYS => LandlockAvailability::NotImplemented,
|
||||
libc::EOPNOTSUPP => LandlockAvailability::NotEnabled,
|
||||
libc::EPERM => LandlockAvailability::Blocked,
|
||||
other => LandlockAvailability::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A prepared Landlock ruleset ready to be enforced via `restrict_self()`.
|
||||
///
|
||||
/// Created by [`prepare`] while running as root (so `PathFd::new()` can open
|
||||
/// any path regardless of DAC permissions). Enforced by [`enforce`] after
|
||||
/// `drop_privileges()` — `restrict_self()` does not require elevated privileges.
|
||||
pub struct PreparedRuleset {
|
||||
ruleset: landlock::RulesetCreated,
|
||||
compatibility: LandlockCompatibility,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PathOpenMode {
|
||||
Privileged,
|
||||
CurrentUser,
|
||||
}
|
||||
|
||||
/// Phase 1: Open `PathFds` and build the Landlock ruleset **as root**.
|
||||
///
|
||||
/// This must run before `drop_privileges()` so that `PathFd::new()` can open
|
||||
/// paths that are only accessible to root (e.g. mode 700 directories).
|
||||
///
|
||||
/// Returns `None` if there are no filesystem paths to restrict (no-op).
|
||||
/// Returns `Some(PreparedRuleset)` on success, or an error.
|
||||
pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<Option<PreparedRuleset>> {
|
||||
prepare_with_path_open_mode(policy, workdir, PathOpenMode::Privileged)
|
||||
}
|
||||
|
||||
/// Phase 1 for already-unprivileged workloads.
|
||||
///
|
||||
/// Kubernetes sidecar mode starts the process supervisor as the sandbox UID, so
|
||||
/// Landlock path FDs are opened as the same UID that will run the workload.
|
||||
/// Paths this UID cannot open are already unavailable to the workload; omit
|
||||
/// them from the allowlist and let the resulting ruleset deny everything else.
|
||||
pub fn prepare_current_user(
|
||||
policy: &SandboxPolicy,
|
||||
workdir: Option<&str>,
|
||||
) -> Result<Option<PreparedRuleset>> {
|
||||
prepare_with_path_open_mode(policy, workdir, PathOpenMode::CurrentUser)
|
||||
}
|
||||
|
||||
fn prepare_with_path_open_mode(
|
||||
policy: &SandboxPolicy,
|
||||
workdir: Option<&str>,
|
||||
path_open_mode: PathOpenMode,
|
||||
) -> Result<Option<PreparedRuleset>> {
|
||||
let read_only = policy.filesystem.read_only.clone();
|
||||
let mut read_write = policy.filesystem.read_write.clone();
|
||||
|
||||
if policy.filesystem.include_workdir
|
||||
&& let Some(dir) = workdir
|
||||
{
|
||||
let workdir_path = PathBuf::from(dir);
|
||||
if !read_write.contains(&workdir_path) {
|
||||
read_write.push(workdir_path);
|
||||
}
|
||||
}
|
||||
|
||||
if read_only.is_empty() && read_write.is_empty() {
|
||||
if matches!(
|
||||
policy.landlock.compatibility,
|
||||
LandlockCompatibility::HardRequirement
|
||||
) {
|
||||
miette::bail!(
|
||||
"landlock.compatibility is hard_requirement but no filesystem paths are configured"
|
||||
);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let compatibility = &policy.landlock.compatibility;
|
||||
|
||||
// Probe first: kernels without Landlock (e.g. gVisor's sentry returns
|
||||
// ENOSYS) would otherwise log misleading "Applying"+"Built" events.
|
||||
let availability = probe_availability();
|
||||
if !matches!(availability, LandlockAvailability::Available { .. }) {
|
||||
match compatibility {
|
||||
LandlockCompatibility::BestEffort => {
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.activity(openshell_ocsf::ActivityId::Open)
|
||||
.severity(openshell_ocsf::SeverityId::High)
|
||||
.confidence(openshell_ocsf::ConfidenceId::High)
|
||||
.is_alert(true)
|
||||
.finding_info(
|
||||
openshell_ocsf::FindingInfo::new(
|
||||
"landlock-unavailable",
|
||||
"Landlock Filesystem Sandbox Unavailable",
|
||||
)
|
||||
.with_desc(&format!(
|
||||
"Running WITHOUT filesystem restrictions: Landlock is {availability}. \
|
||||
Set landlock.compatibility to 'hard_requirement' to make this fatal."
|
||||
)),
|
||||
)
|
||||
.message(format!(
|
||||
"Landlock filesystem sandbox unavailable: {availability}"
|
||||
))
|
||||
.build()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
LandlockCompatibility::HardRequirement => {
|
||||
return Err(miette::miette!(
|
||||
"Landlock unavailable in hard_requirement mode: {availability}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_paths = read_only.len() + read_write.len();
|
||||
let abi = ABI::V2;
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.severity(openshell_ocsf::SeverityId::Informational)
|
||||
.status(openshell_ocsf::StatusId::Success)
|
||||
.state(openshell_ocsf::StateId::Enabled, "applying")
|
||||
.message(format!(
|
||||
"Applying Landlock filesystem sandbox [abi:{abi:?} compat:{:?} ro:{} rw:{}]",
|
||||
policy.landlock.compatibility,
|
||||
read_only.len(),
|
||||
read_write.len(),
|
||||
))
|
||||
.build()
|
||||
);
|
||||
|
||||
let result: Result<PreparedRuleset> = (|| {
|
||||
let access_all = AccessFs::from_all(abi);
|
||||
let access_read = AccessFs::from_read(abi);
|
||||
|
||||
let mut ruleset = Ruleset::default();
|
||||
ruleset = ruleset
|
||||
.set_compatibility(compat_level(compatibility))
|
||||
.handle_access(access_all)
|
||||
.into_diagnostic()?;
|
||||
|
||||
let mut ruleset = ruleset.create().into_diagnostic()?;
|
||||
let mut rules_applied: usize = 0;
|
||||
|
||||
for path in &read_only {
|
||||
if let Some(path_fd) = try_open_path(path, compatibility, path_open_mode)? {
|
||||
let allowed_access = access_for_path_fd(&path_fd, access_read, abi)?;
|
||||
debug!(path = %path.display(), "Landlock allow read-only");
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(path_fd, allowed_access))
|
||||
.into_diagnostic()?;
|
||||
rules_applied += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for path in &read_write {
|
||||
if let Some(path_fd) = try_open_path(path, compatibility, path_open_mode)? {
|
||||
let allowed_access = access_for_path_fd(&path_fd, access_all, abi)?;
|
||||
debug!(path = %path.display(), "Landlock allow read-write");
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(path_fd, allowed_access))
|
||||
.into_diagnostic()?;
|
||||
rules_applied += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if rules_applied == 0 {
|
||||
return Err(miette::miette!(
|
||||
"Landlock ruleset has zero valid paths — all {} path(s) failed to open. \
|
||||
Refusing to apply an empty ruleset that would block all filesystem access.",
|
||||
total_paths,
|
||||
));
|
||||
}
|
||||
|
||||
let skipped = total_paths - rules_applied;
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.severity(openshell_ocsf::SeverityId::Informational)
|
||||
.status(openshell_ocsf::StatusId::Success)
|
||||
.state(openshell_ocsf::StateId::Enabled, "built")
|
||||
.message(format!(
|
||||
"Landlock ruleset built [rules_applied:{rules_applied} skipped:{skipped}]"
|
||||
))
|
||||
.build()
|
||||
);
|
||||
|
||||
Ok(PreparedRuleset {
|
||||
ruleset,
|
||||
compatibility: compatibility.clone(),
|
||||
})
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(prepared) => Ok(Some(prepared)),
|
||||
Err(err) => {
|
||||
if matches!(compatibility, LandlockCompatibility::BestEffort) {
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.activity(openshell_ocsf::ActivityId::Open)
|
||||
.severity(openshell_ocsf::SeverityId::High)
|
||||
.confidence(openshell_ocsf::ConfidenceId::High)
|
||||
.is_alert(true)
|
||||
.finding_info(
|
||||
openshell_ocsf::FindingInfo::new(
|
||||
"landlock-unavailable",
|
||||
"Landlock Filesystem Sandbox Unavailable",
|
||||
)
|
||||
.with_desc(&format!(
|
||||
"Running WITHOUT filesystem restrictions: {err}. \
|
||||
Set landlock.compatibility to 'hard_requirement' to make this fatal."
|
||||
)),
|
||||
)
|
||||
.message(format!("Landlock filesystem sandbox unavailable: {err}"))
|
||||
.build()
|
||||
);
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 2: Enforce a prepared Landlock ruleset by calling `restrict_self()`.
|
||||
///
|
||||
/// This runs **after** `drop_privileges()`. The `restrict_self()` syscall does
|
||||
/// not require root — it only restricts the calling thread (and its future
|
||||
/// children), which is always permitted.
|
||||
///
|
||||
/// Respects the same `best_effort` / `hard_requirement` compatibility as
|
||||
/// [`prepare`]: if `restrict_self()` fails and the policy is `best_effort`,
|
||||
/// the error is logged and the sandbox continues without Landlock.
|
||||
pub fn enforce(prepared: PreparedRuleset) -> Result<()> {
|
||||
let result = prepared.ruleset.restrict_self().into_diagnostic();
|
||||
if let Err(err) = result {
|
||||
if matches!(prepared.compatibility, LandlockCompatibility::BestEffort) {
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.activity(openshell_ocsf::ActivityId::Open)
|
||||
.severity(openshell_ocsf::SeverityId::High)
|
||||
.confidence(openshell_ocsf::ConfidenceId::High)
|
||||
.is_alert(true)
|
||||
.finding_info(
|
||||
openshell_ocsf::FindingInfo::new(
|
||||
"landlock-enforce-failed",
|
||||
"Landlock restrict_self Failed",
|
||||
)
|
||||
.with_desc(&format!(
|
||||
"Ruleset was prepared but restrict_self() failed: {err}. \
|
||||
Running WITHOUT filesystem restrictions. \
|
||||
Set landlock.compatibility to 'hard_requirement' to make this fatal."
|
||||
)),
|
||||
)
|
||||
.message(format!(
|
||||
"Landlock restrict_self failed (best_effort): {err}"
|
||||
))
|
||||
.build()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Legacy single-phase apply. Kept for non-Linux platforms and tests.
|
||||
/// On Linux, callers should use [`prepare`] + [`enforce`] for correct
|
||||
/// privilege ordering.
|
||||
#[allow(dead_code)] // Retained for backward compat; live callers use prepare+enforce.
|
||||
pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
|
||||
if let Some(prepared) = prepare(policy, workdir)? {
|
||||
enforce(prepared)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tailor a rule's access mask to the inode referenced by its already-open FD.
|
||||
///
|
||||
/// Landlock directory-only rights such as `ReadDir` are invalid for regular
|
||||
/// files and device nodes in hard-requirement mode. Classifying through the
|
||||
/// same `PathFd` used by the rule avoids a pathname TOCTOU race.
|
||||
fn access_for_path_fd(
|
||||
path_fd: &PathFd,
|
||||
requested_access: BitFlags<AccessFs>,
|
||||
abi: ABI,
|
||||
) -> Result<BitFlags<AccessFs>> {
|
||||
let stat = rustix::fs::fstat(path_fd.as_fd()).into_diagnostic()?;
|
||||
Ok(match rustix::fs::FileType::from_raw_mode(stat.st_mode) {
|
||||
rustix::fs::FileType::Directory => requested_access,
|
||||
_ => requested_access & AccessFs::from_file(abi),
|
||||
})
|
||||
}
|
||||
|
||||
/// Attempt to open a path for Landlock rule creation.
|
||||
///
|
||||
/// In `BestEffort` mode, inaccessible paths (missing, permission denied, symlink
|
||||
/// loops, etc.) are skipped with a warning and `Ok(None)` is returned so the
|
||||
/// caller can continue building the ruleset from the remaining valid paths.
|
||||
///
|
||||
/// In `HardRequirement` mode, any failure is fatal — the caller propagates the
|
||||
/// error, which ultimately aborts sandbox startup.
|
||||
fn try_open_path(
|
||||
path: &Path,
|
||||
compatibility: &LandlockCompatibility,
|
||||
path_open_mode: PathOpenMode,
|
||||
) -> Result<Option<PathFd>> {
|
||||
match PathFd::new(path) {
|
||||
Ok(fd) => Ok(Some(fd)),
|
||||
Err(err) => {
|
||||
let reason = classify_path_fd_error(&err);
|
||||
let is_not_found = matches!(
|
||||
&err,
|
||||
PathFdError::OpenCall { source, .. }
|
||||
if source.kind() == std::io::ErrorKind::NotFound
|
||||
);
|
||||
if matches!(path_open_mode, PathOpenMode::CurrentUser) {
|
||||
if is_not_found {
|
||||
debug!(
|
||||
path = %path.display(),
|
||||
reason,
|
||||
"Skipping non-existent Landlock path for current user"
|
||||
);
|
||||
} else {
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.severity(openshell_ocsf::SeverityId::Informational)
|
||||
.status(openshell_ocsf::StatusId::Success)
|
||||
.state(openshell_ocsf::StateId::Other, "already-denied")
|
||||
.message(format!(
|
||||
"Skipping inaccessible Landlock path for current user [path:{} error:{err}]",
|
||||
path.display()
|
||||
))
|
||||
.build()
|
||||
);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
match compatibility {
|
||||
LandlockCompatibility::BestEffort => {
|
||||
// NotFound is expected for stale baseline paths (e.g.
|
||||
// /app baked into the server-stored policy but absent
|
||||
// in this container image). Log at debug! to avoid
|
||||
// polluting SSH exec stdout — the pre_exec hook
|
||||
// inherits the tracing subscriber whose writer targets
|
||||
// fd 1 (the pipe/PTY).
|
||||
//
|
||||
// Other errors (permission denied, symlink loops, etc.)
|
||||
// are genuinely unexpected and logged at warn!.
|
||||
if is_not_found {
|
||||
debug!(
|
||||
path = %path.display(),
|
||||
reason,
|
||||
"Skipping non-existent Landlock path (best-effort mode)"
|
||||
);
|
||||
} else {
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.severity(openshell_ocsf::SeverityId::Medium)
|
||||
.status(openshell_ocsf::StatusId::Failure)
|
||||
.state(openshell_ocsf::StateId::Other, "degraded")
|
||||
.message(format!(
|
||||
"Skipping inaccessible Landlock path (best-effort) [path:{} error:{err}]",
|
||||
path.display()
|
||||
))
|
||||
.build()
|
||||
);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
LandlockCompatibility::HardRequirement => Err(miette::miette!(
|
||||
"Landlock path unavailable in hard_requirement mode: {} ({}): {}",
|
||||
path.display(),
|
||||
reason,
|
||||
err,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a [`PathFdError`] into a human-readable reason.
|
||||
///
|
||||
/// `PathFd::new()` wraps `open(path, O_PATH | O_CLOEXEC)` which can fail for
|
||||
/// several reasons beyond simple non-existence. The `PathFdError::OpenCall`
|
||||
/// variant wraps the underlying `std::io::Error`.
|
||||
fn classify_path_fd_error(err: &PathFdError) -> &'static str {
|
||||
match err {
|
||||
PathFdError::OpenCall { source, .. } => classify_io_error(source),
|
||||
// PathFdError is #[non_exhaustive], handle future variants gracefully.
|
||||
_ => "unexpected error",
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a `std::io::Error` into a human-readable reason string.
|
||||
fn classify_io_error(err: &std::io::Error) -> &'static str {
|
||||
match err.kind() {
|
||||
std::io::ErrorKind::NotFound => "path does not exist",
|
||||
std::io::ErrorKind::PermissionDenied => "permission denied",
|
||||
_ => match err.raw_os_error() {
|
||||
Some(40) => "too many symlink levels", // ELOOP
|
||||
Some(36) => "path name too long", // ENAMETOOLONG
|
||||
Some(20) => "path component is not a directory", // ENOTDIR
|
||||
_ => "unexpected error",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn compat_level(level: &LandlockCompatibility) -> CompatLevel {
|
||||
match level {
|
||||
LandlockCompatibility::BestEffort => CompatLevel::BestEffort,
|
||||
LandlockCompatibility::HardRequirement => CompatLevel::HardRequirement,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openshell_core::policy::{FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy};
|
||||
|
||||
fn hard_requirement_policy(read_only: Vec<PathBuf>, read_write: Vec<PathBuf>) -> SandboxPolicy {
|
||||
SandboxPolicy {
|
||||
version: 1,
|
||||
filesystem: FilesystemPolicy {
|
||||
read_only,
|
||||
read_write,
|
||||
include_workdir: false,
|
||||
},
|
||||
network: NetworkPolicy::default(),
|
||||
landlock: LandlockPolicy {
|
||||
compatibility: LandlockCompatibility::HardRequirement,
|
||||
},
|
||||
process: ProcessPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_hard_requirement_accepts_device_paths() {
|
||||
if !matches!(probe_availability(), LandlockAvailability::Available { .. }) {
|
||||
return;
|
||||
}
|
||||
|
||||
let policy = hard_requirement_policy(
|
||||
vec![PathBuf::from("/tmp"), PathBuf::from("/dev/urandom")],
|
||||
vec![PathBuf::from("/dev/null")],
|
||||
);
|
||||
|
||||
let result = prepare(&policy, None);
|
||||
if let Err(err) = result {
|
||||
panic!("hard_requirement should accept mixed directory and device paths: {err}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn prepare_hard_requirement_no_paths_aborts() {
|
||||
// Zero configured paths under hard_requirement must fail startup rather
|
||||
// than silently running without filesystem restrictions.
|
||||
let policy = hard_requirement_policy(vec![], vec![]);
|
||||
let Err(err) = prepare(&policy, None) else {
|
||||
panic!("should abort with no paths");
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("hard_requirement") && msg.contains("no filesystem paths"),
|
||||
"error should explain the empty hard_requirement policy: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_best_effort_no_paths_is_noop() {
|
||||
let policy = SandboxPolicy {
|
||||
version: 1,
|
||||
filesystem: FilesystemPolicy {
|
||||
read_only: vec![],
|
||||
read_write: vec![],
|
||||
include_workdir: false,
|
||||
},
|
||||
network: NetworkPolicy::default(),
|
||||
landlock: LandlockPolicy {
|
||||
compatibility: LandlockCompatibility::BestEffort,
|
||||
},
|
||||
process: ProcessPolicy::default(),
|
||||
};
|
||||
let prepared = prepare(&policy, None).expect("best_effort no-op should succeed");
|
||||
assert!(prepared.is_none(), "no paths should produce no ruleset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_include_workdir_counts_as_configured_path() {
|
||||
// With no explicit paths but include_workdir set, the workdir must be
|
||||
// treated as a configured path — so the zero-path abort must NOT fire.
|
||||
let policy = SandboxPolicy {
|
||||
version: 1,
|
||||
filesystem: FilesystemPolicy {
|
||||
read_only: vec![],
|
||||
read_write: vec![],
|
||||
include_workdir: true,
|
||||
},
|
||||
network: NetworkPolicy::default(),
|
||||
landlock: LandlockPolicy {
|
||||
compatibility: LandlockCompatibility::HardRequirement,
|
||||
},
|
||||
process: ProcessPolicy::default(),
|
||||
};
|
||||
// Any error (e.g. Landlock unavailable on this host) is acceptable, but
|
||||
// it must not be the "no filesystem paths" abort.
|
||||
if let Err(err) = prepare(&policy, Some("/tmp")) {
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
!msg.contains("no filesystem paths"),
|
||||
"workdir should count as a configured path: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn tailored_access(path: &Path, requested_access: BitFlags<AccessFs>) -> BitFlags<AccessFs> {
|
||||
let path_fd = PathFd::new(path).unwrap();
|
||||
access_for_path_fd(&path_fd, requested_access, ABI::V2).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_for_path_fd_preserves_directory_access() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let requested_access = AccessFs::from_all(ABI::V2);
|
||||
|
||||
assert_eq!(
|
||||
tailored_access(dir.path(), requested_access),
|
||||
requested_access
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_for_path_fd_limits_regular_file_access() {
|
||||
let file = tempfile::NamedTempFile::new().unwrap();
|
||||
let requested_access = AccessFs::from_all(ABI::V2);
|
||||
|
||||
assert_eq!(
|
||||
tailored_access(file.path(), requested_access),
|
||||
requested_access & AccessFs::from_file(ABI::V2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_for_path_fd_limits_character_device_access() {
|
||||
let requested_read = AccessFs::from_read(ABI::V2);
|
||||
let requested_write = AccessFs::from_all(ABI::V2);
|
||||
|
||||
assert_eq!(
|
||||
tailored_access(Path::new("/dev/urandom"), requested_read),
|
||||
requested_read & AccessFs::from_file(ABI::V2)
|
||||
);
|
||||
assert_eq!(
|
||||
tailored_access(Path::new("/dev/null"), requested_write),
|
||||
requested_write & AccessFs::from_file(ABI::V2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_for_path_fd_classifies_symlink_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target = dir.path().join("target");
|
||||
let link = dir.path().join("link");
|
||||
std::fs::File::create(&target).unwrap();
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let requested_access = AccessFs::from_all(ABI::V2);
|
||||
assert_eq!(
|
||||
tailored_access(&link, requested_access),
|
||||
requested_access & AccessFs::from_file(ABI::V2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_open_path_best_effort_returns_none_for_missing_path() {
|
||||
let result = try_open_path(
|
||||
&PathBuf::from("/nonexistent/openshell/test/path"),
|
||||
&LandlockCompatibility::BestEffort,
|
||||
PathOpenMode::Privileged,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_open_path_hard_requirement_errors_for_missing_path() {
|
||||
let result = try_open_path(
|
||||
&PathBuf::from("/nonexistent/openshell/test/path"),
|
||||
&LandlockCompatibility::HardRequirement,
|
||||
PathOpenMode::Privileged,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("hard_requirement"),
|
||||
"error should mention hard_requirement mode: {err_msg}"
|
||||
);
|
||||
assert!(
|
||||
err_msg.contains("does not exist"),
|
||||
"error should include the classified reason: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_open_path_succeeds_for_existing_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = try_open_path(
|
||||
dir.path(),
|
||||
&LandlockCompatibility::BestEffort,
|
||||
PathOpenMode::Privileged,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_open_path_current_user_skips_missing_path_in_hard_requirement() {
|
||||
let result = try_open_path(
|
||||
&PathBuf::from("/nonexistent/openshell/test/path"),
|
||||
&LandlockCompatibility::HardRequirement,
|
||||
PathOpenMode::CurrentUser,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_not_found() {
|
||||
let err = std::io::Error::from_raw_os_error(libc::ENOENT);
|
||||
assert_eq!(classify_io_error(&err), "path does not exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_permission_denied() {
|
||||
let err = std::io::Error::from_raw_os_error(libc::EACCES);
|
||||
assert_eq!(classify_io_error(&err), "permission denied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_symlink_loop() {
|
||||
let err = std::io::Error::from_raw_os_error(libc::ELOOP);
|
||||
assert_eq!(classify_io_error(&err), "too many symlink levels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_name_too_long() {
|
||||
let err = std::io::Error::from_raw_os_error(libc::ENAMETOOLONG);
|
||||
assert_eq!(classify_io_error(&err), "path name too long");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_not_a_directory() {
|
||||
let err = std::io::Error::from_raw_os_error(libc::ENOTDIR);
|
||||
assert_eq!(classify_io_error(&err), "path component is not a directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_error() {
|
||||
let err = std::io::Error::from_raw_os_error(libc::EIO);
|
||||
assert_eq!(classify_io_error(&err), "unexpected error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_path_fd_error_extracts_io_error() {
|
||||
// Use PathFd::new on a non-existent path to get a real PathFdError
|
||||
// (the OpenCall variant is #[non_exhaustive] and can't be constructed directly).
|
||||
let err = PathFd::new("/nonexistent/openshell/classify/test").unwrap_err();
|
||||
assert_eq!(classify_path_fd_error(&err), "path does not exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_availability_returns_a_result() {
|
||||
// The probe should not panic regardless of whether Landlock is available.
|
||||
// On Linux hosts with Landlock, this returns Available; on Docker Desktop
|
||||
// linuxkit or older kernels, it returns NotImplemented/NotEnabled/Blocked.
|
||||
let result = probe_availability();
|
||||
let display = format!("{result}");
|
||||
assert!(
|
||||
!display.is_empty(),
|
||||
"probe_availability Display should produce output"
|
||||
);
|
||||
// Verify the Debug impl works too.
|
||||
let debug = format!("{result:?}");
|
||||
assert!(
|
||||
!debug.is_empty(),
|
||||
"probe_availability Debug should produce output"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Linux sandbox implementation using Landlock and seccomp.
|
||||
|
||||
mod landlock;
|
||||
mod seccomp;
|
||||
|
||||
use miette::Result;
|
||||
use openshell_core::policy::SandboxPolicy;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Once;
|
||||
|
||||
/// Opaque handle to a prepared-but-not-yet-enforced sandbox.
|
||||
/// Holds the Landlock ruleset with `PathFds` opened before child exec.
|
||||
pub struct PreparedSandbox {
|
||||
landlock: Option<landlock::PreparedRuleset>,
|
||||
policy: SandboxPolicy,
|
||||
}
|
||||
|
||||
/// Phase 1: Prepare sandbox restrictions **as root** (before `drop_privileges`).
|
||||
///
|
||||
/// Opens Landlock `PathFds` while the process still has root privileges,
|
||||
/// ensuring paths like mode-700 directories are accessible.
|
||||
pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<PreparedSandbox> {
|
||||
let landlock = landlock::prepare(policy, workdir)?;
|
||||
Ok(PreparedSandbox {
|
||||
landlock,
|
||||
policy: policy.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 1 for already-unprivileged workloads.
|
||||
///
|
||||
/// Opens Landlock `PathFds` as the current UID. This is used by Kubernetes
|
||||
/// sidecar mode, where the agent container already runs as the sandbox user.
|
||||
pub fn prepare_current_user(
|
||||
policy: &SandboxPolicy,
|
||||
workdir: Option<&str>,
|
||||
) -> Result<PreparedSandbox> {
|
||||
let landlock = landlock::prepare_current_user(policy, workdir)?;
|
||||
Ok(PreparedSandbox {
|
||||
landlock,
|
||||
policy: policy.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 2: Enforce prepared sandbox restrictions (after `drop_privileges`).
|
||||
///
|
||||
/// Calls `restrict_self()` for Landlock and applies seccomp filters.
|
||||
/// Neither operation requires root privileges.
|
||||
pub fn enforce(prepared: PreparedSandbox) -> Result<()> {
|
||||
if let Some(ruleset) = prepared.landlock {
|
||||
landlock::enforce(ruleset)?;
|
||||
}
|
||||
seccomp::apply(&prepared.policy)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply the supervisor seccomp prelude after privileged bootstrap completes.
|
||||
pub fn apply_supervisor_prelude() -> Result<()> {
|
||||
seccomp::apply_supervisor_prelude()
|
||||
}
|
||||
|
||||
/// Legacy single-phase apply. Kept for backward compatibility.
|
||||
/// New callers should use [`prepare`] + [`enforce`] for correct privilege ordering.
|
||||
#[allow(dead_code)] // Retained for backward compat; live callers use prepare+enforce.
|
||||
pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
|
||||
landlock::apply(policy, workdir)?;
|
||||
seccomp::apply(policy)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Probe Landlock availability and emit OCSF logs from the parent process.
|
||||
///
|
||||
/// This must be called **before** `pre_exec` / `fork()` so that the OCSF events
|
||||
/// are emitted through the parent's tracing subscriber (the child process after
|
||||
/// fork does not have a working tracing pipeline).
|
||||
pub fn log_sandbox_readiness(policy: &SandboxPolicy, workdir: Option<&str>) {
|
||||
static PROBED: Once = Once::new();
|
||||
let mut already_probed = true;
|
||||
PROBED.call_once(|| already_probed = false);
|
||||
if already_probed {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut read_write = policy.filesystem.read_write.clone();
|
||||
let read_only = &policy.filesystem.read_only;
|
||||
|
||||
if policy.filesystem.include_workdir
|
||||
&& let Some(dir) = workdir
|
||||
{
|
||||
let workdir_path = PathBuf::from(dir);
|
||||
if !read_write.contains(&workdir_path) {
|
||||
read_write.push(workdir_path);
|
||||
}
|
||||
}
|
||||
|
||||
let total_paths = read_only.len() + read_write.len();
|
||||
|
||||
if total_paths == 0 {
|
||||
if matches!(
|
||||
policy.landlock.compatibility,
|
||||
openshell_core::policy::LandlockCompatibility::HardRequirement
|
||||
) {
|
||||
// hard_requirement with no paths is fatal (see `landlock::prepare`).
|
||||
// Emit a failure state so operators don't see a misleading "skipped"
|
||||
// success event immediately before startup aborts.
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.severity(openshell_ocsf::SeverityId::High)
|
||||
.status(openshell_ocsf::StatusId::Failure)
|
||||
.state(openshell_ocsf::StateId::Other, "invalid")
|
||||
.message(
|
||||
"Landlock hard_requirement but no filesystem paths configured; \
|
||||
sandbox startup will abort"
|
||||
.to_string(),
|
||||
)
|
||||
.build()
|
||||
);
|
||||
// Dual-emit a security finding for the unsafe policy (per OCSF
|
||||
// guidance: pair the domain event with a DetectionFinding).
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.activity(openshell_ocsf::ActivityId::Open)
|
||||
.severity(openshell_ocsf::SeverityId::High)
|
||||
.confidence(openshell_ocsf::ConfidenceId::High)
|
||||
.is_alert(true)
|
||||
.finding_info(
|
||||
openshell_ocsf::FindingInfo::new(
|
||||
"landlock-hard-requirement-no-paths",
|
||||
"Landlock Hard Requirement Without Paths",
|
||||
)
|
||||
.with_desc(
|
||||
"landlock.compatibility is hard_requirement but no filesystem \
|
||||
paths are configured; sandbox startup will abort.",
|
||||
),
|
||||
)
|
||||
.message(
|
||||
"Landlock hard_requirement with no filesystem paths configured".to_string(),
|
||||
)
|
||||
.build()
|
||||
);
|
||||
} else {
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.severity(openshell_ocsf::SeverityId::Informational)
|
||||
.status(openshell_ocsf::StatusId::Success)
|
||||
.state(openshell_ocsf::StateId::Other, "skipped")
|
||||
.message(
|
||||
"Landlock filesystem sandbox skipped: no paths configured".to_string(),
|
||||
)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let availability = landlock::probe_availability();
|
||||
if let landlock::LandlockAvailability::Available { abi } = &availability {
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.severity(openshell_ocsf::SeverityId::Informational)
|
||||
.status(openshell_ocsf::StatusId::Success)
|
||||
.state(openshell_ocsf::StateId::Enabled, "probed")
|
||||
.message(format!(
|
||||
"Landlock filesystem sandbox available \
|
||||
[abi:v{abi} compat:{:?} ro:{} rw:{}]",
|
||||
policy.landlock.compatibility,
|
||||
read_only.len(),
|
||||
read_write.len(),
|
||||
))
|
||||
.build()
|
||||
);
|
||||
} else {
|
||||
// Landlock is NOT available — this is the critical log that was
|
||||
// previously invisible because it only fired inside pre_exec.
|
||||
let is_best_effort = matches!(
|
||||
policy.landlock.compatibility,
|
||||
openshell_core::policy::LandlockCompatibility::BestEffort
|
||||
);
|
||||
let (desc, msg) = if is_best_effort {
|
||||
(
|
||||
format!(
|
||||
"Sandbox will run WITHOUT filesystem restrictions: {availability}. \
|
||||
Policy requests {total_paths} path rule(s) \
|
||||
(ro:{} rw:{}) but Landlock cannot enforce them. \
|
||||
Set landlock.compatibility to 'hard_requirement' to make this fatal.",
|
||||
read_only.len(),
|
||||
read_write.len(),
|
||||
),
|
||||
format!(
|
||||
"Landlock filesystem sandbox unavailable (best_effort, degraded): {availability}"
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"Landlock is unavailable: {availability}. \
|
||||
Policy requires {total_paths} path rule(s) \
|
||||
(ro:{} rw:{}) with hard_requirement — sandbox startup will fail.",
|
||||
read_only.len(),
|
||||
read_write.len(),
|
||||
),
|
||||
format!(
|
||||
"Landlock filesystem sandbox unavailable (hard_requirement, will fail): {availability}"
|
||||
),
|
||||
)
|
||||
};
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.activity(openshell_ocsf::ActivityId::Open)
|
||||
.severity(openshell_ocsf::SeverityId::High)
|
||||
.confidence(openshell_ocsf::ConfidenceId::High)
|
||||
.is_alert(true)
|
||||
.finding_info(
|
||||
openshell_ocsf::FindingInfo::new(
|
||||
"landlock-unavailable",
|
||||
"Landlock Filesystem Sandbox Unavailable",
|
||||
)
|
||||
.with_desc(&desc),
|
||||
)
|
||||
.message(msg)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,841 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Seccomp syscall filtering.
|
||||
//!
|
||||
//! The filter uses a default-allow policy with targeted blocks:
|
||||
//!
|
||||
//! 1. **Socket domain blocks** -- prevent raw/kernel sockets that bypass the proxy
|
||||
//! 2. **Unconditional syscall blocks** -- block syscalls that enable sandbox escape
|
||||
//! (fileless exec, ptrace, BPF, cross-process memory access, `io_uring`, mount)
|
||||
//! 3. **Conditional syscall blocks** -- block dangerous flag combinations on otherwise
|
||||
//! needed syscalls (`execveat+AT_EMPTY_PATH`, `unshare+CLONE_NEWUSER`,
|
||||
//! `seccomp+SET_MODE_FILTER`)
|
||||
//!
|
||||
//! ## `AF_NETLINK` policy
|
||||
//!
|
||||
//! `AF_NETLINK` sockets are allowed **only** for the `NETLINK_ROUTE` protocol
|
||||
//! (protocol value 0). All other netlink protocols are blocked with `EPERM`.
|
||||
//!
|
||||
//! `NETLINK_ROUTE` is required by `getifaddrs(3)` on Linux (used by Node.js,
|
||||
//! Python, Go, and many HTTP/gRPC client libraries during startup). Without it
|
||||
//! those runtimes fail to enumerate network interfaces even when they have no
|
||||
//! intent to modify them.
|
||||
//!
|
||||
//! The risk is contained by existing sandbox layers:
|
||||
//! - **Privilege drop**: `CAP_NET_ADMIN` is not granted, so all write operations
|
||||
//! (add/delete routes, addresses, interfaces) fail with `EPERM` regardless.
|
||||
//! - **Network namespace**: the sandboxed process sees only `lo` and one veth;
|
||||
//! no host interfaces are visible.
|
||||
//! - **nftables bypass rules**: all non-proxy traffic is rejected at the
|
||||
//! netfilter level regardless of what the sandbox learns about its interfaces.
|
||||
//!
|
||||
//! Every other netlink protocol (`NETLINK_SOCK_DIAG`, `NETLINK_NETFILTER`,
|
||||
//! `NETLINK_AUDIT`, `NETLINK_XFRM`, `NETLINK_GENERIC`, etc.) remains blocked.
|
||||
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
use openshell_core::policy::{NetworkMode, SandboxPolicy};
|
||||
use seccompiler::{
|
||||
SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, SeccompRule,
|
||||
apply_filter, apply_filter_all_threads,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::TryInto;
|
||||
use tracing::debug;
|
||||
|
||||
/// Value of `SECCOMP_SET_MODE_FILTER` (linux/seccomp.h).
|
||||
const SECCOMP_SET_MODE_FILTER: u64 = 1;
|
||||
|
||||
// libc 0.2.185 omits `SYS_kexec_file_load` from the musl/aarch64 bindings even
|
||||
// though the kernel exposes syscall 294. Fall back to the literal so the
|
||||
// supervisor's seccomp filter still blocks fileless kernel-image loads when
|
||||
// built statically against musl on aarch64.
|
||||
#[cfg(all(target_arch = "aarch64", target_env = "musl"))]
|
||||
#[allow(non_upper_case_globals)]
|
||||
const SYS_kexec_file_load: libc::c_long = 294;
|
||||
#[cfg(not(all(target_arch = "aarch64", target_env = "musl")))]
|
||||
use libc::SYS_kexec_file_load;
|
||||
|
||||
/// Apply the supervisor seccomp filter across the running process.
|
||||
///
|
||||
/// This runs after privileged startup helpers complete and synchronizes the
|
||||
/// filter across all supervisor threads via TSYNC. It intentionally blocks
|
||||
/// only the privileged escape primitives that the long-lived supervisor no
|
||||
/// longer needs once bootstrap is complete.
|
||||
pub fn apply_supervisor_prelude() -> Result<()> {
|
||||
let filter = build_supervisor_prelude_filter()?;
|
||||
set_no_new_privs()?;
|
||||
apply_filter_all_threads(&filter).into_diagnostic()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply(policy: &SandboxPolicy) -> Result<()> {
|
||||
let allow_inet = matches!(policy.network.mode, NetworkMode::Proxy | NetworkMode::Allow);
|
||||
let main_filter = build_filter(allow_inet)?;
|
||||
let clone3_filter = build_clone3_filter()?;
|
||||
|
||||
set_no_new_privs()?;
|
||||
apply_runtime_filters(&main_filter, &clone3_filter)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_filter(allow_inet: bool) -> Result<seccompiler::BpfProgram> {
|
||||
let rules = build_filter_rules(allow_inet)?;
|
||||
compile_filter(rules, SeccompAction::Errno(libc::EPERM as u32))
|
||||
}
|
||||
|
||||
fn build_supervisor_prelude_filter() -> Result<seccompiler::BpfProgram> {
|
||||
compile_filter(
|
||||
build_supervisor_prelude_rules(),
|
||||
SeccompAction::Errno(libc::EPERM as u32),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_supervisor_prelude_rules() -> BTreeMap<i64, Vec<SeccompRule>> {
|
||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
||||
|
||||
for syscall in [
|
||||
libc::SYS_mount,
|
||||
libc::SYS_fsopen,
|
||||
libc::SYS_fsconfig,
|
||||
libc::SYS_fsmount,
|
||||
libc::SYS_fspick,
|
||||
libc::SYS_move_mount,
|
||||
libc::SYS_open_tree,
|
||||
libc::SYS_pivot_root,
|
||||
libc::SYS_umount2,
|
||||
libc::SYS_bpf,
|
||||
libc::SYS_perf_event_open,
|
||||
libc::SYS_userfaultfd,
|
||||
libc::SYS_init_module,
|
||||
libc::SYS_finit_module,
|
||||
libc::SYS_delete_module,
|
||||
libc::SYS_kexec_load,
|
||||
SYS_kexec_file_load,
|
||||
] {
|
||||
rules.entry(syscall).or_default();
|
||||
}
|
||||
|
||||
rules
|
||||
}
|
||||
|
||||
fn set_no_new_privs() -> Result<()> {
|
||||
// libc/syscall FFI requires unsafe
|
||||
#[allow(unsafe_code)]
|
||||
let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
|
||||
if rc != 0 {
|
||||
return Err(miette::miette!(
|
||||
"Failed to set no_new_privs: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compile_filter(
|
||||
rules: BTreeMap<i64, Vec<SeccompRule>>,
|
||||
blocked_action: SeccompAction,
|
||||
) -> Result<seccompiler::BpfProgram> {
|
||||
let arch = std::env::consts::ARCH
|
||||
.try_into()
|
||||
.map_err(|_| miette::miette!("Unsupported architecture for seccomp"))?;
|
||||
|
||||
let filter =
|
||||
SeccompFilter::new(rules, SeccompAction::Allow, blocked_action, arch).into_diagnostic()?;
|
||||
|
||||
filter.try_into().into_diagnostic()
|
||||
}
|
||||
|
||||
/// Build a minimal BPF filter that blocks clone3 with ENOSYS.
|
||||
///
|
||||
/// This is a separate filter from the main one because seccomp BPF cannot
|
||||
/// dereference the `struct clone_args *` pointer that clone3 takes as arg 0,
|
||||
/// so we cannot selectively block `CLONE_NEWUSER`. We block clone3
|
||||
/// unconditionally with ENOSYS so glibc falls back to the older clone
|
||||
/// syscall (where flags are a direct register argument and CAN be filtered).
|
||||
///
|
||||
/// glibc's clone3 wrapper checks for ENOSYS specifically — EPERM would be
|
||||
/// treated as a hard failure and propagated to the caller instead of
|
||||
/// triggering the clone fallback.
|
||||
fn build_clone3_filter() -> Result<seccompiler::BpfProgram> {
|
||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
||||
rules.entry(libc::SYS_clone3).or_default();
|
||||
compile_filter(rules, SeccompAction::Errno(libc::ENOSYS as u32))
|
||||
}
|
||||
|
||||
/// Install the sandbox seccomp filters in the required order.
|
||||
///
|
||||
/// Order matters:
|
||||
/// 1. Install the dedicated clone3 filter first so it can still call
|
||||
/// `seccomp(SECCOMP_SET_MODE_FILTER)`.
|
||||
/// 2. Install the main filter second. It blocks further seccomp filter
|
||||
/// installation with `EPERM`, preserving the original hardening intent.
|
||||
fn apply_runtime_filters(
|
||||
main_filter: seccompiler::BpfProgramRef<'_>,
|
||||
clone3_filter: seccompiler::BpfProgramRef<'_>,
|
||||
) -> Result<()> {
|
||||
apply_filter(clone3_filter).into_diagnostic()?;
|
||||
apply_filter(main_filter).into_diagnostic()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_filter_rules(allow_inet: bool) -> Result<BTreeMap<i64, Vec<SeccompRule>>> {
|
||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
||||
|
||||
// --- Socket domain blocks ---
|
||||
let mut blocked_domains = vec![
|
||||
libc::AF_PACKET,
|
||||
libc::AF_BLUETOOTH,
|
||||
libc::AF_VSOCK,
|
||||
// AF_NETLINK is handled separately below: NETLINK_ROUTE (protocol 0)
|
||||
// is allowed for getifaddrs(3); all other netlink protocols are blocked.
|
||||
];
|
||||
if !allow_inet {
|
||||
blocked_domains.push(libc::AF_INET);
|
||||
blocked_domains.push(libc::AF_INET6);
|
||||
}
|
||||
|
||||
for domain in blocked_domains {
|
||||
debug!(domain, "Blocking socket domain via seccomp");
|
||||
add_socket_domain_rule(&mut rules, domain)?;
|
||||
}
|
||||
|
||||
// Allow AF_NETLINK only for NETLINK_ROUTE (protocol 0).
|
||||
//
|
||||
// NETLINK_ROUTE is needed by getifaddrs(3) which is called by Node.js,
|
||||
// Python, Go, and many HTTP/gRPC client libraries during startup to
|
||||
// enumerate local network interfaces. Blocking it causes runtime errors
|
||||
// such as "getifaddrs returned an error" in tools like Claude Code.
|
||||
//
|
||||
// The rule blocks socket(AF_NETLINK, *, protocol) for any protocol != 0.
|
||||
// Write operations via NETLINK_ROUTE still require CAP_NET_ADMIN, which
|
||||
// the sandbox does not grant, so interface/route modification is not possible.
|
||||
add_netlink_non_route_rule(&mut rules)?;
|
||||
|
||||
// --- Unconditional syscall blocks ---
|
||||
// These syscalls are blocked entirely (empty rule vec = unconditional EPERM).
|
||||
|
||||
// Fileless binary execution via memfd bypasses Landlock filesystem restrictions.
|
||||
rules.entry(libc::SYS_memfd_create).or_default();
|
||||
// Cross-process memory inspection and code injection.
|
||||
rules.entry(libc::SYS_ptrace).or_default();
|
||||
// Kernel BPF program loading.
|
||||
rules.entry(libc::SYS_bpf).or_default();
|
||||
// Cross-process memory read.
|
||||
rules.entry(libc::SYS_process_vm_readv).or_default();
|
||||
// Cross-process memory write (symmetric with process_vm_readv).
|
||||
rules.entry(libc::SYS_process_vm_writev).or_default();
|
||||
// Process handle acquisition, fd theft, and signalling via pidfd.
|
||||
rules.entry(libc::SYS_pidfd_open).or_default();
|
||||
rules.entry(libc::SYS_pidfd_getfd).or_default();
|
||||
rules.entry(libc::SYS_pidfd_send_signal).or_default();
|
||||
// Async I/O subsystem with extensive CVE history.
|
||||
rules.entry(libc::SYS_io_uring_setup).or_default();
|
||||
// Filesystem mount could subvert Landlock or overlay writable paths.
|
||||
rules.entry(libc::SYS_mount).or_default();
|
||||
// New mount API syscalls (Linux 5.2+) bypass the SYS_mount block entirely.
|
||||
rules.entry(libc::SYS_fsopen).or_default();
|
||||
rules.entry(libc::SYS_fsconfig).or_default();
|
||||
rules.entry(libc::SYS_fsmount).or_default();
|
||||
rules.entry(libc::SYS_fspick).or_default();
|
||||
rules.entry(libc::SYS_move_mount).or_default();
|
||||
rules.entry(libc::SYS_open_tree).or_default();
|
||||
// Namespace manipulation — setns enters existing namespaces, pivot_root/umount2
|
||||
// change the filesystem root. The supervisor calls setns before seccomp is applied,
|
||||
// so blocking it here is safe.
|
||||
rules.entry(libc::SYS_setns).or_default();
|
||||
rules.entry(libc::SYS_umount2).or_default();
|
||||
rules.entry(libc::SYS_pivot_root).or_default();
|
||||
// Kernel exploit primitives: userfaultfd enables race-condition exploitation (multiple
|
||||
// CVEs), perf_event_open enables Spectre-class side channels. Both blocked by Docker's
|
||||
// default seccomp profile.
|
||||
rules.entry(libc::SYS_userfaultfd).or_default();
|
||||
rules.entry(libc::SYS_perf_event_open).or_default();
|
||||
|
||||
// --- Conditional syscall blocks ---
|
||||
|
||||
// execveat with AT_EMPTY_PATH enables fileless execution from an anonymous fd.
|
||||
add_masked_arg_rule(
|
||||
&mut rules,
|
||||
libc::SYS_execveat,
|
||||
4, // flags argument
|
||||
libc::AT_EMPTY_PATH as u64,
|
||||
)?;
|
||||
|
||||
// unshare with CLONE_NEWUSER allows creating user namespaces to escalate privileges.
|
||||
add_masked_arg_rule(
|
||||
&mut rules,
|
||||
libc::SYS_unshare,
|
||||
0, // flags argument
|
||||
libc::CLONE_NEWUSER as u64,
|
||||
)?;
|
||||
|
||||
// clone with CLONE_NEWUSER achieves the same as unshare via a different syscall.
|
||||
add_masked_arg_rule(
|
||||
&mut rules,
|
||||
libc::SYS_clone,
|
||||
0, // flags argument
|
||||
libc::CLONE_NEWUSER as u64,
|
||||
)?;
|
||||
// clone3 is handled by a separate filter — see build_clone3_filter().
|
||||
|
||||
// seccomp(SECCOMP_SET_MODE_FILTER) would let sandboxed code replace the active filter.
|
||||
let condition = SeccompCondition::new(
|
||||
0, // operation argument
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Eq,
|
||||
SECCOMP_SET_MODE_FILTER,
|
||||
)
|
||||
.into_diagnostic()?;
|
||||
let rule = SeccompRule::new(vec![condition]).into_diagnostic()?;
|
||||
rules.entry(libc::SYS_seccomp).or_default().push(rule);
|
||||
|
||||
Ok(rules)
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
fn add_socket_domain_rule(rules: &mut BTreeMap<i64, Vec<SeccompRule>>, domain: i32) -> Result<()> {
|
||||
let condition =
|
||||
SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Eq, domain as u64)
|
||||
.into_diagnostic()?;
|
||||
|
||||
let rule = SeccompRule::new(vec![condition]).into_diagnostic()?;
|
||||
rules.entry(libc::SYS_socket).or_default().push(rule);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Block `socket(AF_NETLINK, *, protocol)` for every protocol except
|
||||
/// `NETLINK_ROUTE` (protocol 0).
|
||||
///
|
||||
/// Two AND'd conditions are required:
|
||||
/// - arg0 == `AF_NETLINK` (domain)
|
||||
/// - arg2 != 0 (protocol is not `NETLINK_ROUTE`)
|
||||
///
|
||||
/// A seccomp rule fires (and returns EPERM) only when **all** conditions
|
||||
/// match, so this rule is triggered for any `socket(AF_NETLINK, *, non-zero)`
|
||||
/// call while leaving `socket(AF_NETLINK, *, 0)` (`NETLINK_ROUTE`) through.
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
fn add_netlink_non_route_rule(rules: &mut BTreeMap<i64, Vec<SeccompRule>>) -> Result<()> {
|
||||
let domain_condition = SeccompCondition::new(
|
||||
0, // domain argument
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Eq,
|
||||
libc::AF_NETLINK as u64,
|
||||
)
|
||||
.into_diagnostic()?;
|
||||
|
||||
let protocol_condition = SeccompCondition::new(
|
||||
2, // protocol argument
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Ne,
|
||||
0, // NETLINK_ROUTE = 0
|
||||
)
|
||||
.into_diagnostic()?;
|
||||
|
||||
let rule = SeccompRule::new(vec![domain_condition, protocol_condition]).into_diagnostic()?;
|
||||
rules.entry(libc::SYS_socket).or_default().push(rule);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Block a syscall when a specific bit pattern is set in an argument.
|
||||
///
|
||||
/// Uses `MaskedEq` to check `(arg & flag_bit) == flag_bit`, which triggers
|
||||
/// EPERM when the flag is present regardless of other bits in the argument.
|
||||
fn add_masked_arg_rule(
|
||||
rules: &mut BTreeMap<i64, Vec<SeccompRule>>,
|
||||
syscall: i64,
|
||||
arg_index: u8,
|
||||
flag_bit: u64,
|
||||
) -> Result<()> {
|
||||
let condition = SeccompCondition::new(
|
||||
arg_index,
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::MaskedEq(flag_bit),
|
||||
flag_bit,
|
||||
)
|
||||
.into_diagnostic()?;
|
||||
let rule = SeccompRule::new(vec![condition]).into_diagnostic()?;
|
||||
rules.entry(syscall).or_default().push(rule);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// libc/syscall FFI requires unsafe; these tests fork children and exercise
|
||||
// blocked syscalls, so unsafe blocks/calls are pervasive.
|
||||
#[allow(
|
||||
unsafe_code,
|
||||
unsafe_op_in_unsafe_fn,
|
||||
unused_unsafe,
|
||||
clippy::borrow_as_ptr,
|
||||
trivial_numeric_casts
|
||||
)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// These tests cover both filter construction (rule map shape and BPF
|
||||
// compilation) and selected runtime behavior on Linux via forked children.
|
||||
|
||||
#[test]
|
||||
fn build_filter_proxy_mode_compiles() {
|
||||
let filter = build_filter(true);
|
||||
assert!(filter.is_ok(), "build_filter(true) should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_filter_block_mode_compiles() {
|
||||
let filter = build_filter(false);
|
||||
assert!(filter.is_ok(), "build_filter(false) should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_supervisor_prelude_filter_compiles() {
|
||||
let filter = build_supervisor_prelude_filter();
|
||||
assert!(
|
||||
filter.is_ok(),
|
||||
"build_supervisor_prelude_filter() should succeed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_masked_arg_rule_creates_entry() {
|
||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
||||
let result = add_masked_arg_rule(&mut rules, libc::SYS_execveat, 4, 0x1000);
|
||||
assert!(result.is_ok());
|
||||
assert!(
|
||||
rules.contains_key(&libc::SYS_execveat),
|
||||
"should have an entry for SYS_execveat"
|
||||
);
|
||||
assert_eq!(
|
||||
rules[&libc::SYS_execveat].len(),
|
||||
1,
|
||||
"should have exactly one rule"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconditional_blocks_present_in_filter() {
|
||||
// Build a real filter and verify all unconditional blocks are present.
|
||||
let filter_rules = build_filter_rules(true).unwrap();
|
||||
|
||||
// Unconditional blocks have an empty Vec (no conditions = always match).
|
||||
let expected = [
|
||||
libc::SYS_memfd_create,
|
||||
libc::SYS_ptrace,
|
||||
libc::SYS_bpf,
|
||||
libc::SYS_process_vm_readv,
|
||||
libc::SYS_process_vm_writev,
|
||||
libc::SYS_pidfd_open,
|
||||
libc::SYS_pidfd_getfd,
|
||||
libc::SYS_pidfd_send_signal,
|
||||
libc::SYS_io_uring_setup,
|
||||
libc::SYS_mount,
|
||||
libc::SYS_fsopen,
|
||||
libc::SYS_fsconfig,
|
||||
libc::SYS_fsmount,
|
||||
libc::SYS_fspick,
|
||||
libc::SYS_move_mount,
|
||||
libc::SYS_open_tree,
|
||||
libc::SYS_setns,
|
||||
libc::SYS_umount2,
|
||||
libc::SYS_pivot_root,
|
||||
libc::SYS_userfaultfd,
|
||||
libc::SYS_perf_event_open,
|
||||
];
|
||||
|
||||
for syscall in expected {
|
||||
assert!(
|
||||
filter_rules.contains_key(&syscall),
|
||||
"syscall {syscall} should be in the rules map"
|
||||
);
|
||||
assert!(
|
||||
filter_rules[&syscall].is_empty(),
|
||||
"syscall {syscall} should have empty rules (unconditional block)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_blocks_have_rules() {
|
||||
// Build a real filter and verify the conditional syscalls have rule entries
|
||||
// (non-empty Vec means conditional match).
|
||||
let filter_rules = build_filter_rules(true).unwrap();
|
||||
|
||||
for syscall in [
|
||||
libc::SYS_execveat,
|
||||
libc::SYS_unshare,
|
||||
libc::SYS_clone,
|
||||
libc::SYS_seccomp,
|
||||
] {
|
||||
assert!(
|
||||
filter_rules.contains_key(&syscall),
|
||||
"syscall {syscall} should be in the rules map"
|
||||
);
|
||||
assert!(
|
||||
!filter_rules[&syscall].is_empty(),
|
||||
"syscall {syscall} should have conditional rules"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn netlink_socket_rules_are_conditional_not_unconditional() {
|
||||
// SYS_socket must appear in the rules map (for domain blocks and the
|
||||
// AF_NETLINK+non-ROUTE filter), but it must NOT be an unconditional block
|
||||
// (empty Vec). An empty Vec would block ALL socket() calls, including
|
||||
// socket(AF_NETLINK, *, NETLINK_ROUTE=0) which getifaddrs(3) needs.
|
||||
let filter_rules = build_filter_rules(true).unwrap();
|
||||
|
||||
assert!(
|
||||
filter_rules.contains_key(&libc::SYS_socket),
|
||||
"SYS_socket should be in the rules map (domain blocks present)"
|
||||
);
|
||||
|
||||
// The Vec<SeccompRule> for SYS_socket must be non-empty (rules are
|
||||
// conditional), which is the opposite of an unconditional block.
|
||||
assert!(
|
||||
!filter_rules[&libc::SYS_socket].is_empty(),
|
||||
"SYS_socket should have conditional rules, not an unconditional block"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervisor_prelude_blocks_expected_syscalls() {
|
||||
let filter_rules = build_supervisor_prelude_rules();
|
||||
|
||||
for syscall in [
|
||||
libc::SYS_mount,
|
||||
libc::SYS_fsopen,
|
||||
libc::SYS_fsconfig,
|
||||
libc::SYS_fsmount,
|
||||
libc::SYS_fspick,
|
||||
libc::SYS_move_mount,
|
||||
libc::SYS_open_tree,
|
||||
libc::SYS_pivot_root,
|
||||
libc::SYS_umount2,
|
||||
libc::SYS_bpf,
|
||||
libc::SYS_perf_event_open,
|
||||
libc::SYS_userfaultfd,
|
||||
libc::SYS_init_module,
|
||||
libc::SYS_finit_module,
|
||||
libc::SYS_delete_module,
|
||||
libc::SYS_kexec_load,
|
||||
SYS_kexec_file_load,
|
||||
] {
|
||||
assert!(
|
||||
filter_rules.contains_key(&syscall),
|
||||
"syscall {syscall} should be in the supervisor prelude rules"
|
||||
);
|
||||
assert!(
|
||||
filter_rules[&syscall].is_empty(),
|
||||
"syscall {syscall} should be unconditionally blocked in the supervisor prelude"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervisor_prelude_keeps_required_setup_syscalls_available() {
|
||||
let filter_rules = build_supervisor_prelude_rules();
|
||||
|
||||
for syscall in [
|
||||
libc::SYS_setns,
|
||||
libc::SYS_clone,
|
||||
libc::SYS_unshare,
|
||||
libc::SYS_ptrace,
|
||||
] {
|
||||
assert!(
|
||||
!filter_rules.contains_key(&syscall),
|
||||
"syscall {syscall} should remain available during supervisor startup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone3_filter_compiles_and_blocks_clone3() {
|
||||
let bpf = build_clone3_filter();
|
||||
assert!(bpf.is_ok(), "clone3 ENOSYS filter should compile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone3_not_in_main_filter() {
|
||||
// clone3 must NOT be in the main filter; it has its own ENOSYS filter.
|
||||
let filter_rules = build_filter_rules(true).unwrap();
|
||||
assert!(
|
||||
!filter_rules.contains_key(&libc::SYS_clone3),
|
||||
"clone3 should not be in the main filter — it uses a separate ENOSYS filter"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Behavioral tests ---
|
||||
//
|
||||
// These apply seccomp filters in a forked child and verify that blocked
|
||||
// syscalls actually return the expected errno. They only compile and run
|
||||
// on Linux (seccomp is a Linux kernel feature).
|
||||
|
||||
/// Fork a child, apply the given filter, invoke `syscall_nr`, and return
|
||||
/// the errno observed by the child. The child exits 0 if the syscall
|
||||
/// returned the expected errno, 1 otherwise.
|
||||
unsafe fn assert_blocked_in_child(
|
||||
filter: &seccompiler::BpfProgram,
|
||||
syscall_nr: i64,
|
||||
expected_errno: i32,
|
||||
) {
|
||||
let pid = libc::fork();
|
||||
assert!(pid >= 0, "fork failed");
|
||||
if pid == 0 {
|
||||
// Child: apply filter and try the syscall.
|
||||
libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
|
||||
apply_filter(filter).expect("apply_filter");
|
||||
let ret = libc::syscall(syscall_nr, 0 as libc::c_ulong, 0 as libc::c_ulong);
|
||||
let errno = *libc::__errno_location();
|
||||
if ret == -1 && errno == expected_errno {
|
||||
libc::_exit(0);
|
||||
} else {
|
||||
// Write diagnostic before exiting so test failures are debuggable.
|
||||
let msg = format!(
|
||||
"syscall {syscall_nr}: expected errno={expected_errno}, got ret={ret} errno={errno}\n"
|
||||
);
|
||||
libc::write(2, msg.as_ptr().cast(), msg.len());
|
||||
libc::_exit(1);
|
||||
}
|
||||
}
|
||||
// Parent: wait for child.
|
||||
let mut status: libc::c_int = 0;
|
||||
libc::waitpid(pid, &mut status, 0);
|
||||
assert!(
|
||||
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
|
||||
"child failed: syscall {syscall_nr} was not blocked with errno {expected_errno}"
|
||||
);
|
||||
}
|
||||
|
||||
unsafe fn install_runtime_filters_in_child(
|
||||
main_filter: &seccompiler::BpfProgram,
|
||||
clone3_filter: &seccompiler::BpfProgram,
|
||||
) {
|
||||
libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
|
||||
if let Err(err) = apply_runtime_filters(main_filter, clone3_filter) {
|
||||
let msg = format!("failed to install runtime seccomp filters: {err}\n");
|
||||
libc::write(2, msg.as_ptr().cast(), msg.len());
|
||||
libc::_exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_memfd_create_blocked() {
|
||||
let filter = build_filter(true).unwrap();
|
||||
unsafe { assert_blocked_in_child(&filter, libc::SYS_memfd_create, libc::EPERM) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_ptrace_blocked() {
|
||||
let filter = build_filter(true).unwrap();
|
||||
unsafe { assert_blocked_in_child(&filter, libc::SYS_ptrace, libc::EPERM) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_process_vm_writev_blocked() {
|
||||
let filter = build_filter(true).unwrap();
|
||||
unsafe { assert_blocked_in_child(&filter, libc::SYS_process_vm_writev, libc::EPERM) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_userfaultfd_blocked() {
|
||||
let filter = build_filter(true).unwrap();
|
||||
unsafe { assert_blocked_in_child(&filter, libc::SYS_userfaultfd, libc::EPERM) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_perf_event_open_blocked() {
|
||||
let filter = build_filter(true).unwrap();
|
||||
unsafe { assert_blocked_in_child(&filter, libc::SYS_perf_event_open, libc::EPERM) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_setns_blocked() {
|
||||
let filter = build_filter(true).unwrap();
|
||||
unsafe { assert_blocked_in_child(&filter, libc::SYS_setns, libc::EPERM) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_supervisor_prelude_mount_blocked() {
|
||||
let pid = unsafe { libc::fork() };
|
||||
assert!(pid >= 0, "fork failed");
|
||||
if pid == 0 {
|
||||
unsafe {
|
||||
if let Err(err) = apply_supervisor_prelude() {
|
||||
let msg = format!("failed to install supervisor prelude: {err}\n");
|
||||
libc::write(2, msg.as_ptr().cast(), msg.len());
|
||||
libc::_exit(1);
|
||||
}
|
||||
let ret = libc::syscall(
|
||||
libc::SYS_mount,
|
||||
std::ptr::null::<libc::c_char>(),
|
||||
std::ptr::null::<libc::c_char>(),
|
||||
std::ptr::null::<libc::c_char>(),
|
||||
0 as libc::c_ulong,
|
||||
std::ptr::null::<libc::c_void>(),
|
||||
);
|
||||
let errno = *libc::__errno_location();
|
||||
if ret == -1 && errno == libc::EPERM {
|
||||
libc::_exit(0);
|
||||
} else {
|
||||
let msg = format!(
|
||||
"mount: expected EPERM after supervisor prelude, got ret={ret} errno={errno}\n"
|
||||
);
|
||||
libc::write(2, msg.as_ptr().cast(), msg.len());
|
||||
libc::_exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut status: libc::c_int = 0;
|
||||
unsafe { libc::waitpid(pid, &mut status, 0) };
|
||||
assert!(
|
||||
unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 },
|
||||
"mount should be blocked by the supervisor prelude filter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_clone3_returns_enosys() {
|
||||
// clone3 uses a separate filter that returns ENOSYS (not EPERM) so
|
||||
// glibc falls back to clone.
|
||||
let main_filter = build_filter(true).unwrap();
|
||||
let clone3_filter = build_clone3_filter().unwrap();
|
||||
// Apply in the same order as apply(): clone3 filter first, main filter second.
|
||||
let pid = unsafe { libc::fork() };
|
||||
assert!(pid >= 0, "fork failed");
|
||||
if pid == 0 {
|
||||
unsafe {
|
||||
install_runtime_filters_in_child(&main_filter, &clone3_filter);
|
||||
let ret = libc::syscall(libc::SYS_clone3, 0 as libc::c_ulong, 0 as libc::c_ulong);
|
||||
let errno = *libc::__errno_location();
|
||||
if ret == -1 && errno == libc::ENOSYS {
|
||||
libc::_exit(0);
|
||||
} else {
|
||||
let msg = format!("clone3: expected ENOSYS, got ret={ret} errno={errno}\n");
|
||||
libc::write(2, msg.as_ptr().cast(), msg.len());
|
||||
libc::_exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut status: libc::c_int = 0;
|
||||
unsafe { libc::waitpid(pid, &mut status, 0) };
|
||||
assert!(
|
||||
unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 },
|
||||
"clone3 should be blocked with ENOSYS, not EPERM"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_third_filter_install_blocked_after_startup() {
|
||||
let main_filter = build_filter(true).unwrap();
|
||||
let clone3_filter = build_clone3_filter().unwrap();
|
||||
let third_filter = build_clone3_filter().unwrap();
|
||||
|
||||
let pid = unsafe { libc::fork() };
|
||||
assert!(pid >= 0, "fork failed");
|
||||
if pid == 0 {
|
||||
unsafe {
|
||||
install_runtime_filters_in_child(&main_filter, &clone3_filter);
|
||||
match apply_filter(&third_filter) {
|
||||
Err(seccompiler::Error::Seccomp(e))
|
||||
if e.raw_os_error() == Some(libc::EPERM) =>
|
||||
{
|
||||
libc::_exit(0);
|
||||
}
|
||||
Err(err) => {
|
||||
let msg =
|
||||
format!("third filter install failed with unexpected error: {err}\n");
|
||||
libc::write(2, msg.as_ptr().cast(), msg.len());
|
||||
libc::_exit(1);
|
||||
}
|
||||
Ok(()) => {
|
||||
let msg = "third filter unexpectedly installed\n";
|
||||
libc::write(2, msg.as_ptr().cast(), msg.len());
|
||||
libc::_exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut status: libc::c_int = 0;
|
||||
unsafe { libc::waitpid(pid, &mut status, 0) };
|
||||
assert!(
|
||||
unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 },
|
||||
"additional seccomp filter installation should be blocked after startup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_netlink_route_allowed() {
|
||||
// socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE=0) must succeed (not blocked).
|
||||
// This is the call getifaddrs(3) makes on Linux to enumerate interfaces.
|
||||
let filter = build_filter(true).unwrap();
|
||||
let pid = unsafe { libc::fork() };
|
||||
assert!(pid >= 0, "fork failed");
|
||||
if pid == 0 {
|
||||
unsafe {
|
||||
libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
|
||||
apply_filter(&filter).expect("apply_filter");
|
||||
// NETLINK_ROUTE = 0
|
||||
let fd = libc::socket(libc::AF_NETLINK, libc::SOCK_RAW, 0);
|
||||
if fd >= 0 {
|
||||
libc::close(fd);
|
||||
libc::_exit(0);
|
||||
} else {
|
||||
let errno = *libc::__errno_location();
|
||||
let msg = format!(
|
||||
"socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE): expected success, got errno={errno}\n"
|
||||
);
|
||||
libc::write(2, msg.as_ptr().cast(), msg.len());
|
||||
libc::_exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut status: libc::c_int = 0;
|
||||
unsafe { libc::waitpid(pid, &mut status, 0) };
|
||||
assert!(
|
||||
unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 },
|
||||
"socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE) should be allowed for getifaddrs(3)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_netlink_non_route_blocked() {
|
||||
// socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG=4) must be blocked.
|
||||
// NETLINK_SOCK_DIAG is representative of non-ROUTE netlink protocols
|
||||
// that have no legitimate use inside the sandbox.
|
||||
let filter = build_filter(true).unwrap();
|
||||
let pid = unsafe { libc::fork() };
|
||||
assert!(pid >= 0, "fork failed");
|
||||
if pid == 0 {
|
||||
unsafe {
|
||||
libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
|
||||
apply_filter(&filter).expect("apply_filter");
|
||||
// NETLINK_SOCK_DIAG = 4
|
||||
let fd = libc::socket(libc::AF_NETLINK, libc::SOCK_RAW, 4);
|
||||
let errno = *libc::__errno_location();
|
||||
if fd == -1 && errno == libc::EPERM {
|
||||
libc::_exit(0);
|
||||
} else {
|
||||
if fd >= 0 {
|
||||
libc::close(fd);
|
||||
}
|
||||
let msg = format!(
|
||||
"socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG): expected EPERM, got fd={fd} errno={errno}\n"
|
||||
);
|
||||
libc::write(2, msg.as_ptr().cast(), msg.len());
|
||||
libc::_exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut status: libc::c_int = 0;
|
||||
unsafe { libc::waitpid(pid, &mut status, 0) };
|
||||
assert!(
|
||||
unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 },
|
||||
"socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG) should be blocked with EPERM"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Platform sandboxing implementation.
|
||||
|
||||
use miette::Result;
|
||||
use openshell_core::policy::SandboxPolicy;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
|
||||
/// Apply sandboxing rules for the current platform.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the sandbox cannot be applied.
|
||||
// On Linux the spawn path uses `prepare`+`enforce` directly; this single-phase
|
||||
// apply is only invoked from the non-Linux spawn_impl.
|
||||
#[cfg_attr(target_os = "linux", allow(dead_code))]
|
||||
#[cfg_attr(not(target_os = "linux"), allow(clippy::unnecessary_wraps))]
|
||||
pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::apply(policy, workdir)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = (policy, workdir);
|
||||
openshell_ocsf::ocsf_emit!(
|
||||
openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx())
|
||||
.activity(openshell_ocsf::ActivityId::Open)
|
||||
.severity(openshell_ocsf::SeverityId::Medium)
|
||||
.finding_info(openshell_ocsf::FindingInfo::new(
|
||||
"platform-sandbox-unavailable",
|
||||
"Platform Sandboxing Not Implemented",
|
||||
).with_desc("Sandbox policy provided but platform sandboxing is not yet implemented on this OS"))
|
||||
.message("Platform sandboxing not yet implemented")
|
||||
.build()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply seccomp hardening for the long-lived supervisor process itself.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(clippy::unnecessary_wraps))]
|
||||
pub fn apply_supervisor_startup_hardening() -> Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::apply_supervisor_prelude()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user