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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ positive int = deny with errno, `"audit"`/`-2` = allow + flag.
### Rust API

```rust
use sandlock_core::{Policy, Sandbox, Pipeline, Stage, confine_current_process};
use sandlock_core::{ConfinePolicy, Policy, Sandbox, Pipeline, Stage, confine};

// Basic run
let policy = Policy::builder()
Expand All @@ -352,11 +352,11 @@ let policy = Policy::builder()
let result = Sandbox::run(&policy, Some("agent-box"), &["python3", "agent.py"]).await?;

// Confine the current process (Landlock filesystem only, irreversible)
let policy = Policy::builder()
let policy = ConfinePolicy::builder()
.fs_read("/usr").fs_read("/lib")
.fs_write("/tmp")
.build()?;
confine_current_process(&policy)?;
.build();
confine(&policy)?;

// Pipeline
let result = (
Expand Down Expand Up @@ -393,6 +393,7 @@ fs_readable = ["/usr", "/lib", "/lib64", "/bin", "/etc"]
clean_env = true
max_memory = "512M"
max_processes = 50
block_syscalls = []

[env]
CC = "gcc"
Expand Down Expand Up @@ -648,8 +649,7 @@ Policy(
fs_denied=["/proc/kcore"], # Explicitly denied

# Syscall filtering (seccomp)
deny_syscalls=None, # None = default blocklist
allow_syscalls=None, # Allowlist mode (stricter)
block_syscalls=[], # Extra syscalls to block in addition to Sandlock defaults

# Network — see "Network Model" above. Each entry is `host:port[,port,...]`,
# `:port`, `*:port`, `host:*`, or `:*` / `*:*`. Empty list = deny all
Expand All @@ -660,7 +660,7 @@ Policy(

# HTTP ACL (transparent proxy)
http_allow=["POST api.openai.com/v1/*"], # Allow rules (METHOD host/path)
http_deny=["* */admin/*"], # Deny rules (checked first)
http_deny=["* */admin/*"], # Block rules (checked first)
http_ports=[80], # Ports to intercept (default: [80])
https_ca="ca.pem", # CA cert for HTTPS MITM (adds port 443)
https_key="ca-key.pem", # CA key for HTTPS MITM
Expand Down
11 changes: 9 additions & 2 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ async fn main() -> Result<()> {
if let Some(cpu) = base.max_cpu { b = b.max_cpu(cpu); }
if let Some(seed) = base.random_seed { b = b.random_seed(seed); }
if let Some(n) = base.num_cpus { b = b.num_cpus(n); }
b = b.block_syscalls(base.block_syscalls.clone());
b = b.allow_udp(base.allow_udp);
b = b.allow_icmp(base.allow_icmp);
b = b.allow_sysv_ipc(base.allow_sysv_ipc);
Expand Down Expand Up @@ -685,11 +686,17 @@ fn no_supervisor_exec(policy: &Policy, cmd: &[&str]) -> Result<()> {
use std::ffi::CString;

// 1. Apply Landlock confinement (sets NO_NEW_PRIVS + Landlock rules)
sandlock_core::confine_current_process(policy)
if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
return Err(anyhow!(
"prctl(PR_SET_NO_NEW_PRIVS) failed: {}",
std::io::Error::last_os_error()
));
}
sandlock_core::landlock::confine(policy)
.map_err(|e| anyhow!("Landlock confinement failed: {}", e))?;

// 2. Install deny-only seccomp filter (blocks dangerous syscalls without supervisor)
let deny_nrs = sandlock_core::context::no_supervisor_deny_syscall_numbers(policy);
let deny_nrs = sandlock_core::context::no_supervisor_blocklist_syscall_numbers(policy);
let filter = sandlock_core::seccomp::bpf::assemble_filter(&[], &deny_nrs, &[])
.map_err(|e| anyhow!("seccomp assemble failed: {}", e))?;
sandlock_core::seccomp::bpf::install_deny_filter(&filter)
Expand Down
16 changes: 8 additions & 8 deletions crates/sandlock-core/examples/openat_audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ use std::env;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use sandlock_core::seccomp::dispatch::{ExtraHandler, HandlerFn};
use sandlock_core::seccomp::notif::NotifAction;
use sandlock_core::{Policy, Sandbox};
use sandlock_core::{HandlerCtx, Policy, Sandbox};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
Expand All @@ -51,21 +50,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = Arc::clone(&counter);

let audit: HandlerFn = Box::new(move |notif, _ctx, _fd| {
let audit = move |cx: &HandlerCtx| {
let counter = Arc::clone(&counter_clone);
Box::pin(async move {
let pid = cx.notif.pid;
async move {
let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
eprintln!("[audit #{n}] pid={} openat", notif.pid);
eprintln!("[audit #{n}] pid={pid} openat");
// Continue = let the default table and the kernel handle it.
NotifAction::Continue
})
});
}
};

let result = Sandbox::run_with_extra_handlers(
&policy,
Some("openat-audit"),
&cmd_ref,
vec![ExtraHandler::new(libc::SYS_openat, audit)],
[(libc::SYS_openat, audit)],
)
.await?;

Expand Down
115 changes: 47 additions & 68 deletions crates/sandlock-core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::seccomp::bpf::{self, stmt, jump};
use crate::sys::structs::{
AF_INET, AF_INET6,
BPF_ABS, BPF_ALU, BPF_AND, BPF_JEQ, BPF_JSET, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W,
CLONE_NS_FLAGS, DEFAULT_DENY_SYSCALLS, EPERM, SYSV_IPC_DENY_SYSCALLS,
CLONE_NS_FLAGS, DEFAULT_BLOCKLIST_SYSCALLS, EPERM, SYSV_IPC_BLOCKLIST_SYSCALLS,
SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO,
SIOCETHTOOL, SIOCGIFADDR, SIOCGIFBRDADDR, SIOCGIFCONF, SIOCGIFDSTADDR,
SIOCGIFFLAGS, SIOCGIFHWADDR, SIOCGIFINDEX, SIOCGIFNAME, SIOCGIFNETMASK,
Expand Down Expand Up @@ -125,7 +125,7 @@ pub(crate) fn read_u32_fd(fd: RawFd) -> io::Result<u32> {

/// Map a syscall name to its `libc::SYS_*` number.
///
/// Covers all names in `DEFAULT_DENY_SYSCALLS` plus extras needed for
/// Covers all names in `DEFAULT_BLOCKLIST_SYSCALLS` plus extras needed for
/// notif and arg-filter lists.
pub fn syscall_name_to_nr(name: &str) -> Option<u32> {
let nr: i64 = match name {
Expand Down Expand Up @@ -272,7 +272,7 @@ pub fn notif_syscalls(policy: &Policy, sandbox_name: Option<&str>) -> Vec<u32> {
// layout puts notif JEQs before deny JEQs, so a syscall on
// both lists would notify (RET_USER_NOTIF) and silently
// bypass the kernel-level deny. When --allow-sysv-ipc is
// unset, shmget belongs only on the deny list.
// unset, shmget belongs only on the blocklist.
if policy.allow_sysv_ipc {
nrs.push(libc::SYS_shmget as u32);
}
Expand Down Expand Up @@ -442,60 +442,52 @@ pub fn notif_syscalls(policy: &Policy, sandbox_name: Option<&str>) -> Vec<u32> {
nrs
}

/// Resolve `NO_SUPERVISOR_DENY_SYSCALLS` names to numbers, plus
/// Resolve `NO_SUPERVISOR_BLOCKLIST_SYSCALLS` names to numbers, plus
/// SysV IPC syscalls when `policy.allow_sysv_ipc` is false.
pub fn no_supervisor_deny_syscall_numbers(policy: &Policy) -> Vec<u32> {
use crate::sys::structs::NO_SUPERVISOR_DENY_SYSCALLS;
let mut nrs: Vec<u32> = NO_SUPERVISOR_DENY_SYSCALLS
pub fn no_supervisor_blocklist_syscall_numbers(policy: &Policy) -> Vec<u32> {
use crate::sys::structs::NO_SUPERVISOR_BLOCKLIST_SYSCALLS;
let mut nrs: Vec<u32> = NO_SUPERVISOR_BLOCKLIST_SYSCALLS
.iter()
.copied()
.chain(policy.block_syscalls.iter().map(String::as_str))
.filter_map(|n| syscall_name_to_nr(n))
.collect();
if !policy.allow_sysv_ipc {
for name in SYSV_IPC_DENY_SYSCALLS {
for name in SYSV_IPC_BLOCKLIST_SYSCALLS {
if let Some(nr) = syscall_name_to_nr(name) {
if !nrs.contains(&nr) {
nrs.push(nr);
}
}
}
}
nrs.sort_unstable();
nrs.dedup();
nrs
}

/// Resolve `deny_syscalls` names to numbers.
///
/// If both `deny_syscalls` and `allow_syscalls` are `None`, returns the
/// numbers for `DEFAULT_DENY_SYSCALLS`.
/// Resolve the default syscall blocklist plus policy extras to numbers.
///
/// SysV IPC syscalls are appended to the resolved deny list when
/// `policy.allow_sysv_ipc` is false — both for the default branch and
/// the user-supplied `deny_syscalls` branch. They are not appended in
/// allowlist mode (`allow_syscalls = Some(_)`); a user enumerating the
/// exact set of permitted syscalls is already in control.
pub fn deny_syscall_numbers(policy: &Policy) -> Vec<u32> {
let mut nrs: Vec<u32> = if let Some(ref names) = policy.deny_syscalls {
names
.iter()
.filter_map(|n| syscall_name_to_nr(n))
.collect()
} else if policy.allow_syscalls.is_none() {
DEFAULT_DENY_SYSCALLS
.iter()
.filter_map(|n| syscall_name_to_nr(n))
.collect()
} else {
// allow_syscalls is set — no deny list
return Vec::new();
};
/// SysV IPC syscalls are appended to the resolved blocklist when
/// `policy.allow_sysv_ipc` is false.
pub fn blocklist_syscall_numbers(policy: &Policy) -> Vec<u32> {
let mut nrs: Vec<u32> = DEFAULT_BLOCKLIST_SYSCALLS
.iter()
.copied()
.chain(policy.block_syscalls.iter().map(String::as_str))
.filter_map(|n| syscall_name_to_nr(n))
.collect();
if !policy.allow_sysv_ipc {
for name in SYSV_IPC_DENY_SYSCALLS {
for name in SYSV_IPC_BLOCKLIST_SYSCALLS {
if let Some(nr) = syscall_name_to_nr(name) {
if !nrs.contains(&nr) {
nrs.push(nr);
}
}
}
}
nrs.sort_unstable();
nrs.dedup();
nrs
}

Expand Down Expand Up @@ -980,7 +972,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
}

// 9. Assemble and install seccomp filter (IRREVERSIBLE)
let deny = deny_syscall_numbers(policy);
let deny = blocklist_syscall_numbers(policy);
let args = arg_filters(policy);
let mut keep_fd: i32 = -1;

Expand Down Expand Up @@ -1188,7 +1180,7 @@ mod tests {
#[test]
fn test_notif_syscalls_memory() {
// shmget only appears in notif when SysV IPC is allowed —
// otherwise it is on the kernel deny list and notifying would
// otherwise it is on the kernel blocklist and notifying would
// bypass the deny (notif JEQs precede deny JEQs in the BPF
// layout).
let policy = Policy::builder()
Expand Down Expand Up @@ -1269,9 +1261,9 @@ mod tests {
}

#[test]
fn test_deny_syscall_numbers_default() {
fn test_blocklist_syscall_numbers_default() {
let policy = Policy::builder().build().unwrap();
let nrs = deny_syscall_numbers(&policy);
let nrs = blocklist_syscall_numbers(&policy);
// Should contain mount, ptrace, etc.
assert!(nrs.contains(&(libc::SYS_mount as u32)));
assert!(nrs.contains(&(libc::SYS_ptrace as u32)));
Expand All @@ -1286,77 +1278,64 @@ mod tests {
}

#[test]
fn test_deny_syscall_numbers_custom() {
fn test_blocklist_syscall_numbers_custom() {
let policy = Policy::builder()
.deny_syscalls(vec!["mount".into(), "ptrace".into()])
.block_syscalls(vec!["mount".into(), "ptrace".into()])
.build()
.unwrap();
let nrs = deny_syscall_numbers(&policy);
// User-supplied deny list still gets SysV IPC appended
let nrs = blocklist_syscall_numbers(&policy);
// User-supplied blocklist still gets SysV IPC appended
// (allow_sysv_ipc defaults to false).
assert!(nrs.contains(&(libc::SYS_mount as u32)));
assert!(nrs.contains(&(libc::SYS_ptrace as u32)));
assert!(nrs.contains(&(libc::SYS_shmget as u32)));
}

#[test]
fn test_deny_syscall_numbers_custom_with_sysv_ipc_allowed() {
fn test_blocklist_syscall_numbers_custom_with_sysv_ipc_allowed() {
let policy = Policy::builder()
.deny_syscalls(vec!["mount".into(), "ptrace".into()])
.block_syscalls(vec!["mount".into(), "ptrace".into()])
.allow_sysv_ipc(true)
.build()
.unwrap();
let nrs = deny_syscall_numbers(&policy);
// Exactly the user-supplied two — no SysV IPC append.
assert_eq!(nrs.len(), 2);
let nrs = blocklist_syscall_numbers(&policy);
// Default blocklist plus user extras — no SysV IPC append.
assert!(nrs.contains(&(libc::SYS_mount as u32)));
assert!(nrs.contains(&(libc::SYS_ptrace as u32)));
assert!(nrs.contains(&(libc::SYS_bpf as u32)));
assert!(!nrs.contains(&(libc::SYS_shmget as u32)));
}

#[test]
fn test_deny_syscall_numbers_empty_when_allow_set() {
let policy = Policy::builder()
.allow_syscalls(vec!["read".into(), "write".into()])
.build()
.unwrap();
let nrs = deny_syscall_numbers(&policy);
// Allowlist mode: user enumerated exactly what is permitted —
// we do not append SysV IPC denials (the absence of those
// syscalls in allow_syscalls already denies them).
assert!(nrs.is_empty());
}

#[test]
fn test_deny_syscall_numbers_default_with_sysv_ipc_allowed() {
fn test_blocklist_syscall_numbers_default_with_sysv_ipc_allowed() {
let policy = Policy::builder()
.allow_sysv_ipc(true)
.build()
.unwrap();
let nrs = deny_syscall_numbers(&policy);
// Default deny list still present, but SysV IPC is permitted.
let nrs = blocklist_syscall_numbers(&policy);
// Default blocklist still present, but SysV IPC is permitted.
assert!(nrs.contains(&(libc::SYS_mount as u32)));
assert!(!nrs.contains(&(libc::SYS_shmget as u32)));
assert!(!nrs.contains(&(libc::SYS_msgget as u32)));
assert!(!nrs.contains(&(libc::SYS_semget as u32)));
}

#[test]
fn test_no_supervisor_deny_includes_sysv_ipc_by_default() {
fn test_no_supervisor_blocklist_includes_sysv_ipc_by_default() {
let policy = Policy::builder().build().unwrap();
let nrs = no_supervisor_deny_syscall_numbers(&policy);
let nrs = no_supervisor_blocklist_syscall_numbers(&policy);
assert!(nrs.contains(&(libc::SYS_shmget as u32)));
assert!(nrs.contains(&(libc::SYS_msgget as u32)));
assert!(nrs.contains(&(libc::SYS_semget as u32)));
}

#[test]
fn test_no_supervisor_deny_excludes_sysv_ipc_when_allowed() {
fn test_no_supervisor_blocklist_excludes_sysv_ipc_when_allowed() {
let policy = Policy::builder()
.allow_sysv_ipc(true)
.build()
.unwrap();
let nrs = no_supervisor_deny_syscall_numbers(&policy);
let nrs = no_supervisor_blocklist_syscall_numbers(&policy);
assert!(!nrs.contains(&(libc::SYS_shmget as u32)));
assert!(!nrs.contains(&(libc::SYS_msgget as u32)));
assert!(!nrs.contains(&(libc::SYS_semget as u32)));
Expand Down Expand Up @@ -1428,7 +1407,7 @@ mod tests {

#[test]
fn test_syscall_name_to_nr_covers_defaults() {
// Every name in DEFAULT_DENY_SYSCALLS should resolve unless the
// Every name in DEFAULT_BLOCKLIST_SYSCALLS should resolve unless the
// running architecture does not expose that syscall.
let expected_unresolved: &[&str] = &[
"nfsservctl",
Expand All @@ -1438,7 +1417,7 @@ mod tests {
"iopl",
];
let mut skipped = 0;
for name in DEFAULT_DENY_SYSCALLS {
for name in DEFAULT_BLOCKLIST_SYSCALLS {
match syscall_name_to_nr(name) {
Some(_) => {}
None => {
Expand Down
6 changes: 3 additions & 3 deletions crates/sandlock-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ pub enum PolicyError {
#[error("invalid policy: {0}")]
Invalid(String),

#[error("deny_syscalls and allow_syscalls are mutually exclusive")]
MutuallyExclusiveSyscalls,

#[error("fs_isolation requires workdir to be set")]
FsIsolationRequiresWorkdir,

#[error("max_cpu must be 1-100, got {0}")]
InvalidCpuPercent(u8),

#[error("confine() only accepts Landlock filesystem policy; unsupported fields: {0}")]
UnsupportedForConfine(String),
}

#[derive(Debug, Error)]
Expand Down
Loading
Loading