server/sandbox/src/crun.rs
//! **The direct OCI-runtime engine: `crun`, with Holger writing the bundle itself**
//! (lane HOLGER-MEDIA, 2026-10-23).
//!
//! The appliance is OS-less: korp-start `fexecve`s the static holger-server as
//! PID 1, and there is no shell, no setuid helper, no `/etc/subuid`, no image
//! store. Rootless podman would bring a userland the medium does not have
//! (podman ≈ 45 MB of Go plus conmon, newuidmap/newgidmap, containers.conf,
//! storage.conf, policy.json, an overlay store and a second user). crun is ONE
//! static program of 1.9 MB (`crun-<ver>-linux-amd64-disable-systemd`), and the
//! OCI bundle is a directory with a `config.json`, which this module writes.
//! Every limit the podman engine sets holds, in the runtime's own vocabulary:
//!
//! | What | How |
//! |---|---|
//! | No network | a new, empty network namespace (no interface, not even lo up) |
//! | The artifact, the job, the extra inputs | bind mounts, `ro,nosuid,nodev,noexec` |
//! | A scratch space with a cap | tmpfs `/scratch`, `size=<n>m,mode=1777,noexec,nosuid,nodev` |
//! | CPU, memory, pids | cgroup v2: `cpu.max`, `memory.max` (`memory.swap.max 0`), `pids.max` |
//! | A wall-clock timeout | the runtime's child is killed at the deadline, the container killed and deleted |
//! | A read-only rootfs | `root.readonly`, plus the masked and read-only `/proc` paths podman sets |
//! | No capabilities | every capability set empty (bounding set too) |
//! | No privilege gain | `noNewPrivileges` |
//! | Seccomp | the containers default profile (`seccomp/containers-default.json`), the rules a container with no capability gets |
//! | A non-root user | `process.user` = the configured uid:gid |
//! | No host root inside | a user namespace: container uids map to `userns_base..+65536` on the host, never to host 0 |
//!
//! `require_rootless` means, here, "the container runs in a user namespace with
//! no host uid 0 in its map": a kernel without `CONFIG_USER_NS` is refused as
//! [`Refusal::NotRootless`]. Images are not loaded into a store: the worker is a
//! directory on disk (`rootfs`), laid from the medium and verified by sha256
//! before the server serves (holger-server `sandbox::media`).
//!
//! The runtime's stdout is the worker's: `crun run` in the foreground inherits
//! the container's stdio, so [`crate::drive`] reads it exactly as it reads
//! podman's.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::{drive, Drive, Engine, Limits, Output, Refusal, SandboxConfig};
/// The runtime program a default config names.
pub const DEFAULT_CRUN: &str = "crun";
/// The worker program inside its rootfs (the image's `ENTRYPOINT`).
pub const WORKER_BIN: &str = "/holger-scan-worker";
/// The worker's default argument (the image's `CMD`): the inputs directory.
pub const WORKER_ARG: &str = "/in";
/// The first host uid a container's uid 0 maps to. Container uid 65532 is host
/// `base + 65532`; host 0 is never in the map.
pub const DEFAULT_USERNS_BASE: u32 = 100_000;
/// How many ids one container's user namespace maps.
pub const USERNS_SIZE: u32 = 65_536;
/// The cgroup every sandbox container is made under (`/sys/fs/cgroup/<this>/<name>`).
pub const CGROUP_PARENT: &str = "holger-sandbox";
/// Where cgroup v2 is mounted.
pub const CGROUP_ROOT: &str = "/sys/fs/cgroup";
/// The containers default seccomp profile (containers/common, Apache-2.0;
/// `LICENSES/Apache-2.0.txt`). podman applies it to every container; this
/// engine applies the rules a container with no capability gets.
pub const SECCOMP_PROFILE_JSON: &str = include_str!("../seccomp/containers-default.json");
/// The OCI runtime-spec version the bundle declares.
const OCI_VERSION: &str = "1.0.2";
// ── the spec ────────────────────────────────────────────────────────────────
/// One mount of a container.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MountSpec {
/// A host path, bind-mounted (read-only unless `rw`; always nosuid, nodev, noexec).
Bind {
host: PathBuf,
dest: String,
rw: bool,
},
/// A tmpfs with a size cap.
Tmpfs {
dest: String,
size_mib: u64,
mode: u32,
},
}
/// What one container is, as data. [`oci_config`] renders it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Spec {
pub name: String,
pub hostname: String,
/// The root filesystem (a directory on the host), mounted read-only.
pub rootfs: PathBuf,
pub argv: Vec<String>,
pub env: Vec<(String, String)>,
pub uid: u32,
pub gid: u32,
pub limits: Limits,
pub mounts: Vec<MountSpec>,
pub userns_base: u32,
/// Unshare the IPC namespace too (needs `CONFIG_IPC_NS`; [`Kernel::ipc`]).
pub ipc: bool,
/// The program's own GENERATED seccomp profile (OCI `linux.seccomp`), instead of the
/// capability-free containers default ([`seccomp_profile`]); clamd's
/// ([`clamd_seccomp_profile`]). `None`: the default.
pub seccomp: Option<serde_json::Value>,
}
/// What [`Crun::ready`] learned about the kernel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Kernel {
pub ipc: bool,
}
impl Kernel {
/// Look at `/proc/self/ns`: which namespaces this kernel has.
pub fn probe() -> Result<Kernel, Refusal> {
let ns = |n: &str| Path::new("/proc/self/ns").join(n).exists();
if !ns("user") {
return Err(Refusal::NotRootless);
}
for n in ["pid", "mnt", "net", "uts"] {
if !ns(n) {
return Err(Refusal::NoEngine(format!(
"the kernel has no {n} namespace (CONFIG_NAMESPACES)"
)));
}
}
Ok(Kernel { ipc: ns("ipc") })
}
}
/// Which `cgroup.controllers` the sandbox needs at [`CGROUP_ROOT`].
pub const CONTROLLERS: [&str; 3] = ["cpu", "memory", "pids"];
/// Is cgroup v2 mounted at `root` with every controller the limits need?
pub fn cgroup_ready(root: &Path) -> Result<(), Refusal> {
let f = root.join("cgroup.controllers");
let have = std::fs::read_to_string(&f).map_err(|e| {
Refusal::NoEngine(format!(
"cgroup v2 is not mounted at {} ({e}); the limits need it",
root.display()
))
})?;
let have: Vec<&str> = have.split_whitespace().collect();
let missing: Vec<&str> = CONTROLLERS
.iter()
.copied()
.filter(|c| !have.contains(c))
.collect();
if !missing.is_empty() {
return Err(Refusal::NoEngine(format!(
"cgroup v2 at {} lacks the {} controller(s) (CONFIG_CGROUPS, MEMCG, CGROUP_PIDS, CFS_BANDWIDTH)",
root.display(),
missing.join(", ")
)));
}
Ok(())
}
/// **The seccomp profile** this engine applies: the containers default profile
/// reduced to what applies to a container holding no capability, in the OCI
/// runtime-spec shape (`linux.seccomp`). Rules that need a capability
/// (`includes.caps`) are dropped; rules that podman lifts only with a
/// capability (`excludes.caps`) stay, since none is held; the amd64 rules stay.
pub fn seccomp_profile() -> serde_json::Value {
static PROFILE: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
PROFILE
.get_or_init(|| {
reduce_profile(
&serde_json::from_str(SECCOMP_PROFILE_JSON).expect("the vendored profile is JSON"),
)
})
.clone()
}
/// **clamd's GENERATED seccomp profile** (lane HEIMDALL-ALLIN prof): what `heimdall profile
/// gen` (Heimdall 0.3, heimdall-probe) wrote from what clamd really called in learning mode,
/// in the shape it runs in here, serving Holger (PING, INSTREAM of real packages and the
/// EICAR test string, RELOAD, bad clients, SelfCheck); `seccomp/clamd/README.md` has the
/// corpus. Unknown call: the process is killed (the supervisor starts clamd again; the
/// required ClamAV scanner refuses every scan in between).
pub const CLAMD_SECCOMP_JSON: &str = include_str!("../seccomp/clamd/x86_64-musl.oci.json");
/// Its pin: `sha256:` of [`CLAMD_SECCOMP_JSON`] (a changed profile is a reviewed diff of both).
pub const CLAMD_SECCOMP_SHA256: &str =
"sha256:4a714c3dac4d4ac393accf15a5ba788120a58af1649a6a51583e890cc4e19f53";
/// **The calls the runtime makes after it installed the filter**, before clamd's first
/// instruction: the generated profile starts at clamd's own first call (Heimdall's probe
/// starts the program by `execveat` and records from there); crun, right after loading the
/// filter, closes its descriptors (`close_range`) and starts clamd by `execve` (measured:
/// without `close_range` crun is killed before clamd runs). These, and only these, are added.
pub const RUNTIME_AFTER_FILTER: &[&str] = &["close_range", "execve"];
/// clamd's profile as crun applies it: [`CLAMD_SECCOMP_JSON`] plus [`RUNTIME_AFTER_FILTER`].
pub fn clamd_seccomp_profile() -> serde_json::Value {
let mut v: serde_json::Value =
serde_json::from_str(CLAMD_SECCOMP_JSON).expect("the checked-in profile is JSON");
let calls = v["syscalls"]
.as_array_mut()
.expect("an OCI seccomp profile");
calls.push(serde_json::json!({
"names": RUNTIME_AFTER_FILTER,
"action": "SCMP_ACT_ALLOW",
}));
v
}
/// **Never allowed in the sandbox, whatever the default profile says**: the
/// containers default profile lists these in its unconditional allow set and
/// relies on the missing capability to refuse them at the call. The sandbox
/// holds no capability and never will, so they leave the filter too (ENOSYS,
/// the profile's default): one fewer kernel entry point for a hostile artifact
/// to reach, and a tightening over podman's default, never a loosening.
pub const NEVER_WITHOUT_CAPABILITIES: &[&str] = &[
"mount",
"umount",
"umount2",
"unshare",
"setns",
"pivot_root",
"chroot",
"mount_setattr",
"open_tree",
"move_mount",
"fsopen",
"fsconfig",
"fsmount",
"fspick",
"reboot",
"swapon",
"swapoff",
"kexec_load",
"kexec_file_load",
"init_module",
"finit_module",
"delete_module",
"query_module",
"ptrace",
"process_vm_readv",
"process_vm_writev",
"kcmp",
"process_madvise",
"pidfd_getfd",
"sethostname",
"setdomainname",
"settimeofday",
"clock_settime",
"clock_settime64",
"stime",
"adjtimex",
"clock_adjtime",
"clock_adjtime64",
"iopl",
"ioperm",
"vhangup",
"acct",
"quotactl",
"quotactl_fd",
"nfsservctl",
"bpf",
"perf_event_open",
"userfaultfd",
"lookup_dcookie",
"add_key",
"keyctl",
"request_key",
"open_by_handle_at",
"name_to_handle_at",
"syslog",
"vm86",
"vm86old",
"modify_ldt",
"uselib",
"ustat",
"sysfs",
"nfsservctl",
];
fn reduce_profile(p: &serde_json::Value) -> serde_json::Value {
const ARCH: &str = "amd64";
let list = |v: &serde_json::Value| -> Vec<String> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
};
let mut syscalls = Vec::new();
for s in p["syscalls"].as_array().into_iter().flatten() {
let inc_caps = list(&s["includes"]["caps"]);
let inc_arches = list(&s["includes"]["arches"]);
let exc_arches = list(&s["excludes"]["arches"]);
if !inc_caps.is_empty()
|| (!inc_arches.is_empty() && !inc_arches.iter().any(|a| a == ARCH))
|| exc_arches.iter().any(|a| a == ARCH)
{
continue;
}
let names: Vec<serde_json::Value> = s["names"]
.as_array()
.into_iter()
.flatten()
.filter(|n| {
!(s["action"] == "SCMP_ACT_ALLOW"
&& n.as_str()
.is_some_and(|n| NEVER_WITHOUT_CAPABILITIES.contains(&n)))
})
.cloned()
.collect();
if names.is_empty() {
continue;
}
let mut rule = serde_json::json!({ "names": names, "action": s["action"] });
if let Some(e) = s.get("errnoRet") {
rule["errnoRet"] = e.clone();
}
if let Some(a) = s
.get("args")
.filter(|a| a.as_array().is_some_and(|x| !x.is_empty()))
{
rule["args"] = a.clone();
}
syscalls.push(rule);
}
let mut arches = Vec::new();
for m in p["archMap"].as_array().into_iter().flatten() {
if m["architecture"] == "SCMP_ARCH_X86_64" {
arches.push(m["architecture"].clone());
arches.extend(
m["subArchitectures"]
.as_array()
.into_iter()
.flatten()
.cloned(),
);
}
}
let mut out = serde_json::json!({ "defaultAction": p["defaultAction"], "architectures": arches, "syscalls": syscalls });
if let Some(e) = p.get("defaultErrnoRet") {
out["defaultErrnoRet"] = e.clone();
}
out
}
/// **The bundle's `config.json`** — pure, so every hardening line is tested
/// without a runtime.
pub fn oci_config(spec: &Spec) -> serde_json::Value {
let l = &spec.limits;
let mem = (l.memory_mib << 20) as i64;
let mut mounts = vec![
serde_json::json!({ "destination": "/proc", "type": "proc", "source": "proc",
"options": ["nosuid", "noexec", "nodev"] }),
serde_json::json!({ "destination": "/dev", "type": "tmpfs", "source": "tmpfs",
"options": ["nosuid", "noexec", "strictatime", "mode=755", "size=65536k"] }),
];
for m in &spec.mounts {
mounts.push(match m {
MountSpec::Bind { host, dest, rw } => serde_json::json!({
"destination": dest, "type": "bind", "source": host,
"options": ["rbind", if *rw { "rw" } else { "ro" }, "nosuid", "nodev", "noexec"],
}),
MountSpec::Tmpfs { dest, size_mib, mode } => serde_json::json!({
"destination": dest, "type": "tmpfs", "source": "tmpfs",
"options": ["rw", format!("size={size_mib}m"), format!("mode={mode:o}"), "noexec", "nosuid", "nodev"],
}),
});
}
let mut namespaces = vec![
serde_json::json!({ "type": "pid" }),
serde_json::json!({ "type": "mount" }),
serde_json::json!({ "type": "network" }),
serde_json::json!({ "type": "uts" }),
serde_json::json!({ "type": "user" }),
];
if spec.ipc {
namespaces.push(serde_json::json!({ "type": "ipc" }));
}
let env: Vec<String> = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
let map =
serde_json::json!([{ "containerID": 0, "hostID": spec.userns_base, "size": USERNS_SIZE }]);
serde_json::json!({
"ociVersion": OCI_VERSION,
"hostname": spec.hostname,
"process": {
"terminal": false,
"user": { "uid": spec.uid, "gid": spec.gid },
"args": spec.argv,
"env": env,
"cwd": "/",
"capabilities": { "bounding": [], "effective": [], "inheritable": [], "permitted": [], "ambient": [] },
"noNewPrivileges": true,
"rlimits": [{ "type": "RLIMIT_NOFILE", "hard": 4096, "soft": 4096 }],
},
"root": { "path": spec.rootfs, "readonly": true },
"mounts": mounts,
"linux": {
"namespaces": namespaces,
"uidMappings": map,
"gidMappings": map,
"resources": {
"memory": { "limit": mem, "swap": mem },
"cpu": { "quota": (l.cpu_millis * 100) as i64, "period": 100_000 },
"pids": { "limit": l.pids as i64 },
},
"cgroupsPath": format!("/{CGROUP_PARENT}/{}", spec.name),
"seccomp": spec.seccomp.clone().unwrap_or_else(seccomp_profile),
"rootfsPropagation": "private",
"maskedPaths": ["/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list",
"/proc/timer_stats", "/proc/sched_debug", "/proc/scsi", "/sys/firmware", "/sys/dev/block"],
"readonlyPaths": ["/proc/asound", "/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger"],
},
})
}
// ── the bundle on disk, and the runtime's verbs ─────────────────────────────
/// Where one engine keeps its bundles and the runtime's own state.
#[derive(Debug, Clone)]
pub struct State {
pub dir: PathBuf,
}
impl State {
pub fn bundles(&self) -> PathBuf {
self.dir.join("bundles")
}
/// crun's `--root`: its per-container state, under ours (never `/run/crun`).
pub fn runtime_root(&self) -> PathBuf {
self.dir.join("run")
}
/// Write the bundle of `spec`; its directory.
pub fn write_bundle(&self, spec: &Spec) -> Result<PathBuf, Refusal> {
let dir = self.bundles().join(&spec.name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir)
.map_err(|e| Refusal::Io(format!("{}: {e}", dir.display())))?;
let cfg = serde_json::to_vec_pretty(&oci_config(spec))
.map_err(|e| Refusal::Io(format!("config.json: {e}")))?;
std::fs::write(dir.join("config.json"), cfg)
.map_err(|e| Refusal::Io(format!("{}/config.json: {e}", dir.display())))?;
Ok(dir)
}
pub fn remove_bundle(&self, name: &str) {
let _ = std::fs::remove_dir_all(self.bundles().join(name));
}
}
/// The runtime program and its state: the verbs this engine uses.
#[derive(Debug, Clone)]
pub struct Runtime {
pub program: String,
pub state: State,
}
impl Runtime {
fn cmd(&self) -> Command {
// A32-14, a process by design: the sandbox runs the OCI runtime (crun) it is configured with; the sandbox is the feature.
let mut c = Command::new(&self.program);
c.arg("--root")
.arg(self.state.runtime_root())
.arg("--cgroup-manager=cgroupfs");
c.stdin(Stdio::null());
c
}
fn quiet(&self, args: &[&str]) -> Result<std::process::Output, Refusal> {
claimed_output(|| {
let mut c = self.cmd();
c.args(args);
c
})
.map_err(|e| Refusal::NoEngine(format!("{} {}: {e}", self.program, args.join(" "))))
}
/// `crun --version`: is the program there?
pub fn version(&self) -> Result<String, Refusal> {
let o = claimed_output(|| {
// A32-14, a process by design: the sandbox runs the OCI runtime (crun) it is configured with; the sandbox is the feature.
let mut c = Command::new(&self.program);
c.arg("--version").stdin(Stdio::null());
c
})
.map_err(|e| Refusal::NoEngine(format!("{} --version: {e}", self.program)))?;
if !o.status.success() {
return Err(Refusal::NoEngine(format!(
"{} --version exited {}",
self.program, o.status
)));
}
Ok(String::from_utf8_lossy(&o.stdout)
.lines()
.next()
.unwrap_or_default()
.to_string())
}
/// The `run` arguments for `bundle` as `name`.
///
/// `--no-pivot`: MEASURED on the laid box (2026-10-24), `pivot_root: Invalid
/// argument` from both containers. The appliance's root IS the initramfs
/// (`rootfs`), which pivot_root cannot leave; the runtime chroots into the
/// rootfs instead — inside its own mount namespace, with every capability
/// gone and `chroot`/`pivot_root`/`mount` out of the seccomp filter, that is
/// the same wall. `--no-new-keyring`: the kernel has no `CONFIG_KEYS`.
pub fn run_args(name: &str, bundle: &Path) -> Vec<String> {
vec![
"run".into(),
"--no-new-keyring".into(),
"--no-pivot".into(),
"--bundle".into(),
bundle.display().to_string(),
name.into(),
]
}
/// Start `spec` in the foreground: the child's stdout and stderr are the
/// container's. The bundle is written first.
pub fn spawn(&self, spec: &Spec) -> Result<std::process::Child, Refusal> {
let bundle = self.state.write_bundle(spec)?;
self.cmd()
.args(Self::run_args(&spec.name, &bundle))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// A32-14, a process by design: the OCI runtime process (see its constructor).
.spawn()
.map_err(|e| Refusal::NoEngine(format!("{} run: {e}", self.program)))
}
/// Kill the container by name (SIGKILL), whatever state it is in.
pub fn kill(&self, name: &str) {
let _ = self.quiet(&["kill", name, "KILL"]);
}
/// Delete the container's state and its bundle.
pub fn delete(&self, name: &str) {
let _ = self.quiet(&["delete", "--force", name]);
self.state.remove_bundle(name);
}
/// Did the container's cgroup record an OOM kill? Read before `delete`.
pub fn oom_killed(&self, name: &str) -> bool {
oom_killed_in(Path::new(CGROUP_ROOT), name)
}
}
/// **A short runtime call run to its end with its child claimed** from a PID 1's reaper
/// ([`crate::claim_child`]), as `spawn` claims the scan's: on the laid box holger is PID 1, and an
/// unclaimed child's exit could be reaped first (`ECHILD`, release R2 2026-10-25). The claim
/// lands right after the spawn; an `ECHILD` that still slips in before it is tried again, twice.
fn claimed_output(make: impl Fn() -> Command) -> std::io::Result<std::process::Output> {
let mut last = None;
for _ in 0..3 {
// A32-14, a process by design: the OCI runtime process (see its constructor).
let child = make()
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let _claim = crate::claim_child(&child);
match child.wait_with_output() {
Err(e) if e.raw_os_error() == Some(ECHILD) => last = Some(e),
r => return r,
}
}
Err(last.unwrap_or_else(|| std::io::Error::from_raw_os_error(ECHILD)))
}
/// `ECHILD` on Linux: the child's status was taken by another waiter.
const ECHILD: i32 = 10;
fn oom_killed_in(cgroup_root: &Path, name: &str) -> bool {
let f = cgroup_root
.join(CGROUP_PARENT)
.join(name)
.join("memory.events");
std::fs::read_to_string(f)
.ok()
.and_then(|t| {
t.lines().find_map(|l| {
l.strip_prefix("oom_kill ")
.and_then(|n| n.trim().parse::<u64>().ok())
})
})
.is_some_and(|n| n > 0)
}
// ── the engine ──────────────────────────────────────────────────────────────
/// The engine: the local `crun` program over a rootfs directory.
#[derive(Debug, Default)]
pub struct Crun {
kernel: std::sync::OnceLock<Kernel>,
}
impl Crun {
pub fn runtime(cfg: &SandboxConfig) -> Result<Runtime, Refusal> {
let dir = cfg
.state_dir
.clone()
.or_else(|| cfg.work_dir.as_ref().map(|w| format!("{w}/crun")))
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("holger-sandbox-crun"));
std::fs::create_dir_all(&dir)
.map_err(|e| Refusal::Io(format!("{}: {e}", dir.display())))?;
Ok(Runtime {
program: cfg.crun.clone(),
state: State { dir },
})
}
fn rootfs(cfg: &SandboxConfig) -> Result<PathBuf, Refusal> {
let r = cfg.rootfs.as_deref().ok_or_else(|| {
Refusal::Config("engine `crun` needs `rootfs`: the worker's root directory".into())
})?;
let r = PathBuf::from(r);
if !r.is_absolute() {
return Err(Refusal::Config(format!(
"rootfs {} is not an absolute path",
r.display()
)));
}
Ok(r)
}
/// The worker's spec for one run: the decoded `run` arguments over the config.
pub fn spec(
cfg: &SandboxConfig,
name: &str,
mounts: &[(String, String)],
command: &[String],
ipc: bool,
) -> Result<Spec, Refusal> {
let (uid, gid) = parse_user(&cfg.user)?;
let mut argv = vec![WORKER_BIN.to_string()];
if command.is_empty() {
argv.push(WORKER_ARG.to_string());
} else {
argv.extend(command.iter().cloned());
}
let mut m = vec![MountSpec::Tmpfs {
dest: crate::SCRATCH_PATH.into(),
size_mib: cfg.limits.scratch_mib,
mode: 0o1777,
}];
m.extend(mounts.iter().map(|(h, c)| MountSpec::Bind {
host: PathBuf::from(h),
dest: c.clone(),
rw: false,
}));
Ok(Spec {
name: name.to_string(),
hostname: "holger-scan".into(),
rootfs: Self::rootfs(cfg)?,
argv,
env: vec![
("HOME".into(), crate::SCRATCH_PATH.into()),
("TMPDIR".into(), crate::SCRATCH_PATH.into()),
("PATH".into(), "/".into()),
],
uid,
gid,
limits: cfg.limits.clone(),
mounts: m,
userns_base: cfg.userns_base,
ipc,
seccomp: None,
})
}
}
impl Crun {
/// The worker's spec for one run, from the `run` arguments themselves: their
/// read-only mounts, the worker's own argv, and a job's writable output
/// directory at [`crate::OUT_PATH`] (`rw`, still nosuid, nodev, noexec).
pub fn spec_for(
cfg: &SandboxConfig,
name: &str,
args: &[String],
ipc: bool,
) -> Result<Spec, Refusal> {
let mut spec = Self::spec(
cfg,
name,
&crate::mounts_of(args),
&crate::command_of(cfg, args),
ipc,
)?;
if let Some(out) = crate::out_of(args) {
spec.mounts.push(MountSpec::Bind {
host: PathBuf::from(out),
dest: crate::OUT_PATH.into(),
rw: true,
});
}
Ok(spec)
}
}
/// `uid:gid` (or `uid`) as numbers.
pub fn parse_user(user: &str) -> Result<(u32, u32), Refusal> {
let (u, g) = user.split_once(':').unwrap_or((user, user));
let uid = u
.parse::<u32>()
.map_err(|_| Refusal::Config(format!("user {user:?} is not numeric")))?;
let gid = g
.parse::<u32>()
.map_err(|_| Refusal::Config(format!("user {user:?} is not numeric")))?;
Ok((uid, gid))
}
impl Engine for Crun {
fn ready(&self, cfg: &SandboxConfig) -> Result<(), Refusal> {
let rootfs = Self::rootfs(cfg)?;
if !rootfs.join(WORKER_BIN.trim_start_matches('/')).is_file() {
return Err(Refusal::ImageMissing(format!(
"{} (no {WORKER_BIN} in it)",
rootfs.display()
)));
}
let rt = Self::runtime(cfg)?;
rt.version()?;
let kernel = Kernel::probe()?;
if cfg.require_rootless && cfg.userns_base == 0 {
return Err(Refusal::NotRootless);
}
cgroup_ready(Path::new(CGROUP_ROOT))?;
let _ = self.kernel.set(kernel);
Ok(())
}
fn run(&self, cfg: &SandboxConfig, name: &str, args: &[String]) -> Result<Output, Refusal> {
let kernel = *self.kernel.get().unwrap_or(&Kernel::default());
let spec = Self::spec_for(cfg, name, args, kernel.ipc)?;
let rt = Self::runtime(cfg)?;
let started = Instant::now();
// A32-14, a process by design: the sandboxed program, started by the OCI runtime, claimed and reaped.
let child = rt.spawn(&spec)?;
let _claim = crate::claim_child(&child);
let deadline = started + Duration::from_secs(cfg.limits.timeout_secs);
let r = drive(child, deadline, cfg.limits.report_max_bytes);
let out = match r {
Drive::Done {
status,
stdout,
stderr,
} => {
let code = status.code();
if status.success() {
Ok(Output {
report: stdout,
elapsed: started.elapsed(),
})
} else {
let oom = rt.oom_killed(name);
if code == Some(137) || code == Some(139) || code.is_none() || oom {
Err(Refusal::Killed {
exit: code,
oom,
stderr,
})
} else {
Err(Refusal::WorkerFailed { exit: code, stderr })
}
}
}
Drive::TimedOut => {
rt.kill(name);
Err(Refusal::Timeout {
secs: cfg.limits.timeout_secs,
})
}
Drive::TooLarge => {
rt.kill(name);
Err(Refusal::ReportTooLarge {
cap: cfg.limits.report_max_bytes,
})
}
Drive::Io(e) => {
rt.kill(name);
Err(Refusal::Io(e))
}
};
rt.delete(name);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
static CLAIMED: std::sync::Mutex<Vec<i32>> = std::sync::Mutex::new(Vec::new());
fn record_claim(pid: i32) -> crate::ChildClaim {
CLAIMED.lock().unwrap_or_else(|e| e.into_inner()).push(pid);
Box::new(())
}
/// ★ **`crun --version` is claimed from a PID 1's reaper** (release R2, 2026-10-25): on the
/// laid box holger is PID 1, and an unclaimed `--version` child lost its exit to the reaper
/// (`ECHILD`) — the scan worker's first `ready()` then said `sandbox-no-engine` and kept
/// saying it (the readiness is cached), so every scan of the box was refused. MEASURED in
/// the R2 installtest of holger 919d2416 (one RED run in two). The calls that only ask
/// (`--version`, `kill`, `delete`, `state`) claim their child as `spawn` does.
#[test]
fn the_runtimes_own_short_calls_claim_their_child() {
crate::set_child_claim(record_claim);
let dir = tempfile::tempdir().unwrap();
let rt = Runtime {
program: "/bin/echo".into(),
state: State {
dir: dir.path().to_path_buf(),
},
};
let before = CLAIMED.lock().unwrap_or_else(|e| e.into_inner()).len();
assert!(rt.version().is_ok(), "{:?}", rt.version());
let after = CLAIMED.lock().unwrap_or_else(|e| e.into_inner()).len();
assert!(after > before, "the --version child was not claimed");
rt.kill("holger-scan-x");
assert!(
CLAIMED.lock().unwrap_or_else(|e| e.into_inner()).len() > after,
"the kill child was not claimed"
);
}
fn cfg() -> SandboxConfig {
SandboxConfig {
engine: crate::EngineKind::Crun,
rootfs: Some("/data/holger/sandbox/worker/rootfs".into()),
..Default::default()
}
}
fn spec() -> Spec {
Crun::spec(
&cfg(),
"holger-scan-abc",
&[
("/w/j/artifact".into(), "/in/artifact".into()),
("/etc/holger/yara".into(), "/in/d-yara".into()),
],
&[],
true,
)
.unwrap()
}
#[test]
fn the_bundle_has_every_hardening_line() {
let c = oci_config(&spec());
assert_eq!(c["root"]["readonly"], true);
assert_eq!(c["root"]["path"], "/data/holger/sandbox/worker/rootfs");
assert_eq!(c["process"]["noNewPrivileges"], true);
assert_eq!(c["process"]["user"]["uid"], 65532);
assert_eq!(c["process"]["user"]["gid"], 65532);
assert_eq!(
c["process"]["args"],
serde_json::json!(["/holger-scan-worker", "/in"])
);
for set in [
"bounding",
"effective",
"inheritable",
"permitted",
"ambient",
] {
assert_eq!(
c["process"]["capabilities"][set],
serde_json::json!([]),
"{set}"
);
}
let ns: Vec<&str> = c["linux"]["namespaces"]
.as_array()
.unwrap()
.iter()
.map(|n| n["type"].as_str().unwrap())
.collect();
for want in ["pid", "mount", "network", "uts", "user", "ipc"] {
assert!(ns.contains(&want), "namespace {want} in {ns:?}");
}
assert!(
c["linux"]["namespaces"]
.as_array()
.unwrap()
.iter()
.all(|n| n.get("path").is_none()),
"every namespace is NEW, none joined"
);
assert_eq!(c["linux"]["uidMappings"][0]["hostID"], DEFAULT_USERNS_BASE);
assert_eq!(c["linux"]["uidMappings"][0]["size"], USERNS_SIZE);
assert_eq!(c["linux"]["gidMappings"], c["linux"]["uidMappings"]);
let r = &c["linux"]["resources"];
assert_eq!(r["memory"]["limit"], 1024 << 20);
assert_eq!(
r["memory"]["swap"],
1024 << 20,
"no swap: the swap limit equals the memory limit"
);
assert_eq!(r["cpu"]["quota"], 100_000);
assert_eq!(r["cpu"]["period"], 100_000);
assert_eq!(r["pids"]["limit"], 64);
assert_eq!(c["linux"]["cgroupsPath"], "/holger-sandbox/holger-scan-abc");
assert_eq!(c["linux"]["seccomp"]["defaultAction"], "SCMP_ACT_ERRNO");
let mounts = c["mounts"].as_array().unwrap();
let scratch = mounts
.iter()
.find(|m| m["destination"] == "/scratch")
.expect("the scratch tmpfs");
for o in ["size=256m", "mode=1777", "noexec", "nosuid", "nodev"] {
assert!(
scratch["options"]
.as_array()
.unwrap()
.iter()
.any(|x| x == o),
"{o} in {scratch}"
);
}
for (dest, host) in [
("/in/artifact", "/w/j/artifact"),
("/in/d-yara", "/etc/holger/yara"),
] {
let m = mounts
.iter()
.find(|m| m["destination"] == dest)
.unwrap_or_else(|| panic!("{dest}"));
assert_eq!(m["source"], host);
let o = m["options"].as_array().unwrap();
assert!(
o.iter().any(|x| x == "ro") && !o.iter().any(|x| x == "rw"),
"{dest} is read-only: {m}"
);
assert!(
o.iter().any(|x| x == "noexec")
&& o.iter().any(|x| x == "nosuid")
&& o.iter().any(|x| x == "nodev"),
"{m}"
);
}
assert!(!c.to_string().contains("unconfined"));
assert!(c["linux"]["maskedPaths"]
.as_array()
.unwrap()
.iter()
.any(|p| p == "/proc/kcore"));
}
#[test]
fn a_jobs_output_is_its_one_writable_bind() {
let args = crate::run_args_out(
&cfg(),
"holger-scan-abc",
Path::new("/w/j/artifact"),
Path::new("/w/j/job.json"),
&[crate::Mount::new("/w/in", "i")],
Some(Path::new("/w/j/out")),
);
let c = oci_config(&Crun::spec_for(&cfg(), "holger-scan-abc", &args, true).unwrap());
let mounts = c["mounts"].as_array().unwrap();
let out = mounts
.iter()
.find(|m| m["destination"] == "/out")
.expect("the job's /out");
assert_eq!(out["source"], "/w/j/out");
let o = out["options"].as_array().unwrap();
assert!(
o.iter().any(|x| x == "rw")
&& o.iter().any(|x| x == "noexec")
&& o.iter().any(|x| x == "nosuid"),
"{out}"
);
let input = mounts
.iter()
.find(|m| m["destination"] == "/in/i")
.expect("the job's input");
assert!(
input["options"]
.as_array()
.unwrap()
.iter()
.any(|x| x == "ro"),
"{input}"
);
let binds_rw = mounts
.iter()
.filter(|m| {
m["type"] == "bind" && m["options"].as_array().unwrap().iter().any(|x| x == "rw")
})
.count();
assert_eq!(binds_rw, 1, "one writable bind: {c}");
// Without an output directory the spec is the scan's, unchanged.
let plain = crate::run_args(
&cfg(),
"n",
Path::new("/w/j/artifact"),
Path::new("/w/j/job.json"),
&[],
);
assert!(
!oci_config(&Crun::spec_for(&cfg(), "n", &plain, true).unwrap())["mounts"]
.as_array()
.unwrap()
.iter()
.any(|m| m["destination"] == "/out")
);
}
#[test]
fn a_command_replaces_the_default_argument_and_ipc_is_optional() {
let s = Crun::spec(
&cfg(),
"n",
&[],
&["probe".into(), "--sleep".into(), "3".into()],
false,
)
.unwrap();
assert_eq!(s.argv, ["/holger-scan-worker", "probe", "--sleep", "3"]);
let c = oci_config(&s);
assert!(!c["linux"]["namespaces"]
.as_array()
.unwrap()
.iter()
.any(|n| n["type"] == "ipc"));
}
#[test]
fn clamds_generated_profile_is_the_pinned_one_and_crun_adds_only_its_own_calls() {
use sha2::Digest as _;
let got: String = sha2::Sha256::digest(CLAMD_SECCOMP_JSON.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let got = format!("sha256:{got}");
assert_eq!(got, CLAMD_SECCOMP_SHA256);
let p = clamd_seccomp_profile();
assert_eq!(p["defaultAction"], "SCMP_ACT_KILL_PROCESS");
let calls = p["syscalls"].as_array().unwrap();
let added = calls.last().unwrap();
assert_eq!(added["names"], serde_json::json!(RUNTIME_AFTER_FILTER));
let names: Vec<&str> = calls[..calls.len() - 1]
.iter()
.flat_map(|c| {
c["names"]
.as_array()
.unwrap()
.iter()
.map(|n| n.as_str().unwrap())
})
.collect();
for never in [
"execve", "execveat", "ptrace", "mount", "unshare", "setns", "connect",
] {
assert!(
!names.contains(&never),
"clamd's measured profile names {never}"
);
}
// clamd's one socket is its unix listener: socket(AF_UNIX, SOCK_STREAM, 0) only.
let socket: Vec<_> = calls.iter().filter(|c| c["names"][0] == "socket").collect();
assert_eq!(socket.len(), 1);
assert_eq!(socket[0]["args"][0]["valueTwo"], 1, "AF_UNIX");
}
#[test]
fn the_seccomp_profile_is_the_capability_free_reduction_of_the_default() {
let p = seccomp_profile();
assert_eq!(p["defaultAction"], "SCMP_ACT_ERRNO");
assert_eq!(
p["defaultErrnoRet"], 38,
"ENOSYS for what is not listed, as podman"
);
let arches: Vec<&str> = p["architectures"]
.as_array()
.unwrap()
.iter()
.map(|a| a.as_str().unwrap())
.collect();
assert_eq!(
arches,
["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"]
);
let names = |action: &str| -> Vec<String> {
p["syscalls"]
.as_array()
.unwrap()
.iter()
.filter(|s| s["action"] == action)
.flat_map(|s| {
s["names"]
.as_array()
.unwrap()
.iter()
.map(|n| n.as_str().unwrap().to_string())
})
.collect()
};
let allowed = names("SCMP_ACT_ALLOW");
for want in [
"read",
"write",
"openat",
"mmap",
"clone",
"clone3",
"futex",
"exit_group",
"arch_prctl",
"prctl",
] {
assert!(allowed.contains(&want.to_string()), "{want} is allowed");
}
for never in NEVER_WITHOUT_CAPABILITIES {
assert!(
!allowed.contains(&never.to_string()),
"{never} is never allowed without a capability"
);
}
assert!(
allowed.contains(&"openat".to_string()) && allowed.len() > 300,
"the ordinary syscalls stay: {}",
allowed.len()
);
assert!(
p.to_string().contains("\"args\""),
"the argument-filtered rules (personality, socket) keep their filters"
);
for s in p["syscalls"].as_array().unwrap() {
assert!(
s.get("includes").is_none()
&& s.get("excludes").is_none()
&& s.get("comment").is_none(),
"runtime-spec keys only: {s}"
);
}
}
#[test]
fn a_config_without_a_rootfs_or_with_a_relative_one_is_refused_by_name() {
let none = SandboxConfig {
engine: crate::EngineKind::Crun,
..Default::default()
};
assert!(
matches!(Crun::default().ready(&none), Err(Refusal::Config(w)) if w.contains("rootfs"))
);
let rel = SandboxConfig {
engine: crate::EngineKind::Crun,
rootfs: Some("worker/rootfs".into()),
..Default::default()
};
assert!(matches!(
Crun::default().ready(&rel),
Err(Refusal::Config(_))
));
let dir = tempfile::tempdir().unwrap();
let absent = SandboxConfig {
engine: crate::EngineKind::Crun,
rootfs: Some(dir.path().display().to_string()),
..Default::default()
};
assert!(
matches!(Crun::default().ready(&absent), Err(Refusal::ImageMissing(w)) if w.contains("holger-scan-worker")),
"{:?}",
Crun::default().ready(&absent)
);
}
#[test]
fn a_missing_runtime_program_is_no_engine() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("holger-scan-worker"), b"\x7fELF").unwrap();
let c = SandboxConfig {
engine: crate::EngineKind::Crun,
rootfs: Some(dir.path().display().to_string()),
crun: "/nonexistent/holger-test/crun".into(),
work_dir: Some(dir.path().display().to_string()),
..Default::default()
};
assert!(
matches!(Crun::default().ready(&c), Err(Refusal::NoEngine(_))),
"{:?}",
Crun::default().ready(&c)
);
}
#[test]
fn the_cgroup_check_names_the_missing_controller() {
let dir = tempfile::tempdir().unwrap();
assert!(
matches!(cgroup_ready(dir.path()), Err(Refusal::NoEngine(w)) if w.contains("not mounted"))
);
std::fs::write(
dir.path().join("cgroup.controllers"),
"cpuset cpu io memory\n",
)
.unwrap();
assert!(
matches!(cgroup_ready(dir.path()), Err(Refusal::NoEngine(w)) if w.contains("pids"))
);
std::fs::write(
dir.path().join("cgroup.controllers"),
"cpuset cpu io memory pids\n",
)
.unwrap();
cgroup_ready(dir.path()).unwrap();
}
#[test]
fn an_oom_kill_is_read_from_the_cgroup_events() {
let dir = tempfile::tempdir().unwrap();
let cg = dir.path().join(CGROUP_PARENT).join("x");
std::fs::create_dir_all(&cg).unwrap();
std::fs::write(
cg.join("memory.events"),
"low 0\nhigh 0\nmax 3\noom 1\noom_kill 1\n",
)
.unwrap();
assert!(oom_killed_in(dir.path(), "x"));
std::fs::write(cg.join("memory.events"), "low 0\noom_kill 0\n").unwrap();
assert!(!oom_killed_in(dir.path(), "x"));
assert!(!oom_killed_in(dir.path(), "y"));
}
#[test]
fn the_bundle_is_written_under_the_state_and_removed_after() {
let dir = tempfile::tempdir().unwrap();
let st = State {
dir: dir.path().to_path_buf(),
};
let b = st.write_bundle(&spec()).unwrap();
let text = std::fs::read_to_string(b.join("config.json")).unwrap();
assert!(text.contains("\"noNewPrivileges\": true"));
st.remove_bundle("holger-scan-abc");
assert!(!b.exists());
}
#[test]
fn the_runtime_is_run_without_pivot_root_and_without_a_keyring() {
let a = Runtime::run_args(
"holger-scan-x",
Path::new("/data/holger/sandbox/state/bundles/holger-scan-x"),
);
assert_eq!(a[0], "run");
assert!(
a.contains(&"--no-pivot".to_string()),
"the appliance's root is the initramfs: pivot_root cannot leave it"
);
assert!(a.contains(&"--no-new-keyring".to_string()));
assert_eq!(a.last().map(String::as_str), Some("holger-scan-x"));
assert!(
!a.iter().any(|x| x == "--detach"),
"the runtime's stdout is the worker's: foreground"
);
}
#[test]
fn a_user_reads_as_numbers_only() {
assert_eq!(parse_user("65532:65532").unwrap(), (65532, 65532));
assert_eq!(parse_user("100:101").unwrap(), (100, 101));
assert!(parse_user("nobody").is_err());
}
}