server/sandbox/src/lib.rs
//! **Holger's scan sandbox.** Every scan that unpacks, parses or runs an engine
//! over an artifact's bytes runs here, in a container, never in the
//! holger-server process (owner rule, 2026-10-23).
//!
//! One [`Sandbox::run`] is one container over one artifact:
//!
//! | What | How |
//! |---|---|
//! | No network | `--network none` |
//! | The artifact, read-only | bind-mounted at `/in/artifact`, `ro` |
//! | The job, read-only | `/in/job.json`, `ro`; extra inputs under `/in/m/<n>`, `ro` |
//! | A scratch space with a cap | `--tmpfs /scratch:rw,size=<n>m,mode=1777,noexec,nosuid,nodev` |
//! | CPU, memory, pids | `--cpus` (from `cpu_millis`), `--memory` (and `--memory-swap` the same: no swap), `--pids-limit` |
//! | A wall-clock timeout | the container is killed at the deadline; the scan is REFUSED ([`Refusal::Timeout`]) |
//! | A read-only rootfs | `--read-only` (and `--read-only-tmpfs=false`: no hidden writable /tmp) |
//! | No capabilities | `--cap-drop all` |
//! | No privilege gain | `--security-opt no-new-privileges` |
//! | The default seccomp | never `seccomp=unconfined`; the engine's default filter |
//! | A non-root user | `--user 65532:65532` |
//! | Rootless | the engine must report rootless ([`Refusal::NotRootless`]) |
//! | Never pulls | `--pull never`; a missing image is [`Refusal::ImageMissing`] |
//! | A job's output ([`Sandbox::run_job`]) | `/out`, fresh and empty per run, the ONE writable bind; only regular files are handed over, anything else refuses the run |
//! | Pinned by digest | a local tag is resolved ONCE to its digest ([`Engine::pin`], [`pinned_ref`]) and every run names `<repository>@sha256:…`, never the tag (NCSC P1, lane HOL-NCSC) |
//!
//! The worker answers with ONE document on stdout. This crate reads at most
//! [`Limits::report_max_bytes`] of it (more is [`Refusal::ReportTooLarge`] and
//! the container is killed) and hands the bytes back; the caller validates
//! them. Nothing else the worker writes reaches the server: the stderr tail is
//! kept for the refusal's words only.
//!
//! **Fail closed.** No engine is [`Refusal::NoEngine`], by name. There is no
//! in-process fallback anywhere in this crate.
//!
//! The engine is podman, the same one the Build Thing drives, two ways: the
//! local `podman` program ([`Podman`]), or podman's API socket ([`api::PodmanApi`],
//! `url`), the seam a Holger that itself runs in a container uses. Either way the
//! worker's stdout is ATTACHED, never read back from a log: measured in draupnir,
//! podman's compat log stream handed back a prefix of a container's output as if
//! it were the whole, and a scan verdict cut short must never look complete.
pub mod api;
pub mod crun;
pub mod media;
pub mod pids;
pub mod reap;
pub mod remote;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use holger_errcode::{self as ec, ErrCode};
use serde::{Deserialize, Serialize};
/// Holger's release, the version of the products (`holger`, `holger-downstream`,
/// `holger-cli`, `holger-scan-worker`, `holger-ui`) and of `holger-server-lib`. This crate
/// keeps its own semver line (4.0.1 in Holger 3.1.1), so the worker tag cannot be its own
/// version; holger-server-lib's tests hold the two equal (lane HOL-310-RELEASE).
macro_rules! release {
() => {
"3.1.1"
};
}
pub const RELEASE: &str = release!();
/// The worker image a default config names: minted locally, never pulled, and tagged with
/// Holger's release (lane HOL-FIELD-TEST P1: one fixed tag let a stale worker answer a newer
/// server).
pub const DEFAULT_IMAGE: &str = concat!("localhost/holger-scan-worker:", release!());
/// Where the artifact is inside the container.
pub const ARTIFACT_PATH: &str = "/in/artifact";
/// Where the job is inside the container.
pub const JOB_PATH: &str = "/in/job.json";
/// The capped scratch space inside the container.
pub const SCRATCH_PATH: &str = "/scratch";
/// The directory the extra read-only inputs are mounted under (`/in/<name>`).
pub const EXTRA_DIR: &str = "/in";
/// A job's one writable directory (lane HOL-SANDBOX-TRAIT): fresh and empty per
/// run, its regular files handed to the caller after the run ([`Sandbox::run_job`]).
pub const OUT_PATH: &str = "/out";
/// The container's user: the distroless `nonroot` uid and gid.
pub const DEFAULT_USER: &str = "65532:65532";
/// How much of stderr is kept for a refusal's words.
const STDERR_TAIL: usize = 8 << 10;
// ── children of a PID 1 ─────────────────────────────────────────────────────
/// A claim on a child's exit status (`appliance_pid1::Claim` on the appliance):
/// held while the engine waits for its runtime process, so a PID 1's reaper
/// leaves the status to it. MEASURED on the appliance (lane HOLGER-MEDIA):
/// between two `try_wait` polls the reaper took the exit, the engine read
/// `ECHILD`, and a finished scan with no status is a refusal.
pub type ChildClaim = Box<dyn std::any::Any + Send>;
static CLAIM_CHILD: std::sync::OnceLock<fn(i32) -> ChildClaim> = std::sync::OnceLock::new();
/// Install the claim a PID 1 process offers; set once, by the appliance binary.
pub fn set_child_claim(f: fn(i32) -> ChildClaim) {
let _ = CLAIM_CHILD.set(f);
}
/// Claim `child` for the caller, when a PID 1 offered claims.
pub fn claim_child(child: &Child) -> Option<ChildClaim> {
CLAIM_CHILD.get().map(|f| f(child.id() as i32))
}
// ── the configuration ───────────────────────────────────────────────────────
/// The limits of one scan container. Each field has a default and can be set
/// alone: `limits: (timeout_secs: 30)`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Limits {
/// Host CPU the worker may use, in thousandths of a core (1000 = one core).
pub cpu_millis: u64,
/// Memory, MiB. Swap is off (`--memory-swap` equals it).
pub memory_mib: u64,
/// Processes and threads.
pub pids: u64,
/// The tmpfs scratch space, MiB (it counts against `memory_mib`).
pub scratch_mib: u64,
/// Wall clock, seconds, from the start of the container to its report.
pub timeout_secs: u64,
/// The report's size cap, bytes.
pub report_max_bytes: u64,
}
impl Limits {
pub const DEFAULT_CPU_MILLIS: u64 = 1000;
pub const DEFAULT_MEMORY_MIB: u64 = 1024;
pub const DEFAULT_PIDS: u64 = 64;
pub const DEFAULT_SCRATCH_MIB: u64 = 256;
pub const DEFAULT_TIMEOUT_SECS: u64 = 120;
pub const DEFAULT_REPORT_MAX_BYTES: u64 = 8 << 20;
}
impl Default for Limits {
fn default() -> Self {
Self {
cpu_millis: Self::DEFAULT_CPU_MILLIS,
memory_mib: Self::DEFAULT_MEMORY_MIB,
pids: Self::DEFAULT_PIDS,
scratch_mib: Self::DEFAULT_SCRATCH_MIB,
timeout_secs: Self::DEFAULT_TIMEOUT_SECS,
report_max_bytes: Self::DEFAULT_REPORT_MAX_BYTES,
}
}
}
/// Which engine runs the container.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EngineKind {
/// The `podman` program, or its API socket when `url` is set.
#[default]
Podman,
/// The `crun` program over a rootfs directory; Holger writes the OCI bundle.
Crun,
/// A scan-worker SIDECAR over a unix socket ([`remote`]): the Kubernetes
/// pod, which holds no container runtime. `work_dir` is the directory both
/// containers share.
Remote,
/// Heimdall's protected runner (lane HOL-HEIMDALL-02, the `heimdall:` block): not an
/// engine of this crate but the job runner holger-job-runner builds with its feature
/// `heimdall`. Where Heimdall cannot start, `youki-plain`, then crun over `rootfs`.
Heimdall,
}
/// The `sandbox:` block: which engine, which image, the limits.
///
/// ```ron
/// sandbox: Some((
/// image: "localhost/holger-scan-worker:3.1.1",
/// limits: (memory_mib: 2048, timeout_secs: 300),
/// )),
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SandboxConfig {
/// Which engine runs the container: the `podman` program or its API
/// socket ([`EngineKind::Podman`], the default), or `crun` over a rootfs
/// directory with Holger writing the bundle ([`EngineKind::Crun`], the
/// appliance's engine; [`crun`]).
pub engine: EngineKind,
/// The podman program.
pub podman: String,
/// The OCI runtime program (`engine: crun`).
pub crun: String,
/// The worker's root directory (`engine: crun`): the image laid on disk,
/// verified before the server serves. Absolute.
pub rootfs: Option<String>,
/// Where the bundles and the runtime's state are kept (`engine: crun`).
/// `None`: under `work_dir`, else the system temporary directory.
pub state_dir: Option<String>,
/// The first host uid a container's uid 0 maps to (`engine: crun`); host
/// 0 is never in the map. Default 100000.
pub userns_base: u32,
/// The sidecar's unix socket (`engine: remote`). `None`:
/// [`remote::DEFAULT_SOCKET`].
pub socket: Option<String>,
/// podman's API socket (`unix:///run/user/1000/podman/podman.sock`): the
/// engine is driven over its REST API ([`api::PodmanApi`]). `None`: the
/// local `podman` program.
pub url: Option<String>,
/// The scan worker image. A local name (`localhost/…`) or one pinned by
/// digest (`…@sha256:<64 hex>`); never a moving tag from a registry.
pub image: String,
pub limits: Limits,
/// Where each scan's job directory is made (the artifact is written there
/// and mounted read-only). It must be a path the engine sees as the server
/// does. `None`: the system temporary directory.
pub work_dir: Option<String>,
/// The container's `uid:gid`. Never `0`.
pub user: String,
/// Refuse an engine that runs as root. Default true.
pub require_rootless: bool,
/// `engine: heimdall`: Heimdall's runner and the worker image it runs ([`HeimdallSettings`]).
pub heimdall: Option<HeimdallSettings>,
}
/// **The `heimdall:` block** of the sandbox config (`engine: heimdall`, lane HOL-HEIMDALL-02).
/// holger-job-runner's `heimdall::start` reads it.
///
/// ```ron
/// sandbox: Some((
/// engine: heimdall,
/// rootfs: Some("/data/holger/sandbox/worker"), // the crun fallback, when set
/// heimdall: Some((
/// image_layout: "/data/holger/sandbox/oci",
/// rootfs_tar: Some("/data/holger/sandbox/holger-scan-worker.rootfs.tar"),
/// shim: Some("/data/holger/sandbox/heimdall-container-shim"),
/// )),
/// )),
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HeimdallSettings {
/// The local OCI image layout the worker image is in (absolute).
pub image_layout: String,
/// The worker image's manifest digest (`sha256:<64 hex>`). `None`: minted at boot
/// from `rootfs_tar` into `image_layout`.
pub image: Option<String>,
/// The worker's rootfs as one tar (the medium's verified
/// `holger-scan-worker.rootfs.tar`), when `image` is `None`.
pub rootfs_tar: Option<String>,
/// The worker's path inside the image.
pub worker: String,
/// `heimdall-container-shim` (`None`: `HEIMDALL_CONTAINER_SHIM`, else next to the server).
pub shim: Option<String>,
/// `heimdall-plain-shim`, for the `youki-plain` fallback (`None`: `HEIMDALL_PLAIN_SHIM`,
/// else next to the server; no shim there, no such fallback).
pub plain_shim: Option<String>,
/// Heimdall's strict mode: refuse to start without Landlock, cgroup v2 with
/// memory/pids/cpu and user namespaces. Default true.
pub strict: bool,
/// The cgroup v2 mount and the jobs' parent cgroup under it.
pub cgroup_root: String,
pub cgroup_parent: String,
/// Per-job bundles and the runtime's state (`None`: the sandbox's `state_dir`, else
/// `work_dir`, else the system temporary directory).
pub work_dir: Option<String>,
/// When Heimdall refuses to start: run in `youki-plain` (when its shim is there).
/// Default true. crun over `rootfs` is the next fallback.
pub fallback_plain: bool,
/// Heimdall's released verify-bundle guests (lane HOL-HEIMDALL-04): the directory the
/// medium lays them in. Set, every bundle's verify-bundle job runs the split wasm guest
/// (verifier + decoder, pinned) inside the worker; the server checks every file against
/// its pin at boot.
pub guests: Option<String>,
/// The guests' only trust root (the korp root, PEM or DER), with `guests`.
pub guests_trust_root: Option<String>,
/// How many host ids a job's root range spans when this server runs as root under
/// `require_rootless` (it starts at `userns_base`). Default 65536.
pub host_id_count: u32,
/// The scan worker's GENERATED seccomp profile (holger-job-runner `seccomp`, measured
/// with heimdall-probe over the worker's real jobs): Heimdall installs it on top of its
/// fixed profile for every job but the probe. Default true; false runs the jobs under
/// the fixed profile only.
pub generated_seccomp: bool,
}
/// The worker's path inside its image ([`media::worker_rootfs_tar`] lays it there).
pub const HEIMDALL_WORKER_BIN: &str = "/holger-scan-worker";
impl Default for HeimdallSettings {
fn default() -> Self {
Self {
image_layout: String::new(),
image: None,
rootfs_tar: None,
worker: HEIMDALL_WORKER_BIN.into(),
shim: None,
plain_shim: None,
strict: true,
cgroup_root: "/sys/fs/cgroup".into(),
cgroup_parent: "heimdall".into(),
work_dir: None,
fallback_plain: true,
guests: None,
guests_trust_root: None,
host_id_count: 65536,
generated_seccomp: true,
}
}
}
impl HeimdallSettings {
/// Refuse a block that cannot mean what it says, by name.
pub fn validate(&self) -> Result<(), String> {
let abs = |what: &str, p: &str| {
if p.starts_with('/') {
Ok(())
} else {
Err(format!("heimdall: {what} {p:?} is not an absolute path"))
}
};
abs("image_layout", &self.image_layout)?;
abs("worker", &self.worker)?;
let pinned = |d: &str| {
d.strip_prefix("sha256:").is_some_and(|h| {
h.len() == 64 && h.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))
})
};
match (&self.image, &self.rootfs_tar) {
(Some(d), _) if !pinned(d) => {
return Err(format!(
"heimdall: image {d:?} is not sha256:<64 lower-case hex>"
))
}
(None, None) => return Err(
"heimdall: name the worker image (`image`, its digest) or the rootfs tar to mint it from (`rootfs_tar`)".into(),
),
(None, Some(t)) => abs("rootfs_tar", t)?,
_ => {}
}
if self.guests.is_some() != self.guests_trust_root.is_some() {
return Err("heimdall: `guests` and `guests_trust_root` go together (the guests are verified against that root only)".into());
}
for (what, p) in [
("shim", &self.shim),
("plain_shim", &self.plain_shim),
("work_dir", &self.work_dir),
("guests", &self.guests),
("guests_trust_root", &self.guests_trust_root),
] {
if let Some(p) = p {
abs(what, p)?;
}
}
abs("cgroup_root", &self.cgroup_root)
}
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
engine: EngineKind::Podman,
podman: "podman".into(),
crun: crun::DEFAULT_CRUN.into(),
rootfs: None,
state_dir: None,
userns_base: crun::DEFAULT_USERNS_BASE,
socket: None,
url: None,
image: DEFAULT_IMAGE.into(),
limits: Limits::default(),
work_dir: None,
user: DEFAULT_USER.into(),
require_rootless: true,
heimdall: None,
}
}
}
impl SandboxConfig {
/// `engine: heimdall` with `rootfs` set: the interim crun runner over it, Heimdall's
/// last fallback (lane HOL-HEIMDALL-02). `None` for any other engine, or no `rootfs`.
pub fn crun_fallback(&self) -> Option<SandboxConfig> {
(self.engine == EngineKind::Heimdall && self.rootfs.is_some()).then(|| SandboxConfig {
engine: EngineKind::Crun,
heimdall: None,
..self.clone()
})
}
/// Refuse a config that cannot mean what it says, by name.
pub fn validate(&self) -> Result<(), Refusal> {
if self.engine == EngineKind::Heimdall {
let Some(h) = &self.heimdall else {
return Err(Refusal::Config(
"engine `heimdall` needs the `heimdall:` block: the worker image and the shim"
.into(),
));
};
h.validate().map_err(Refusal::Config)?;
if let Some(f) = self.crun_fallback() {
f.validate()?;
}
return Ok(());
}
if self.engine == EngineKind::Crun {
match self.rootfs.as_deref() {
None => {
return Err(Refusal::Config(
"engine `crun` needs `rootfs`: the worker's root directory".into(),
))
}
Some(r) if !r.starts_with('/') => {
return Err(Refusal::Config(format!(
"rootfs {r:?} is not an absolute path"
)))
}
Some(_) => {}
}
if self.require_rootless && self.userns_base == 0 {
return Err(Refusal::Config("userns_base 0 would map the container's root to the host's; require_rootless refuses it".into()));
}
} else if self.engine == EngineKind::Remote {
// The sidecar's image is the pod's to pin; the server needs the
// shared directory and the socket.
match self.work_dir.as_deref() {
None => return Err(Refusal::Config(
"engine `remote` needs `work_dir`: the directory shared with the scan sidecar"
.into(),
)),
Some(d) if !d.starts_with('/') => {
return Err(Refusal::Config(format!(
"work_dir {d:?} is not an absolute path"
)))
}
Some(_) => {}
}
if let Some(s) = self.socket.as_deref().filter(|s| !s.starts_with('/')) {
return Err(Refusal::Config(format!(
"socket {s:?} is not an absolute path"
)));
}
} else if !image_is_pinned(&self.image) {
return Err(Refusal::Config(format!(
"image {:?} is neither a local name (localhost/…) nor pinned by digest (@sha256:…); \
the scan worker is minted, never pulled",
self.image
)));
}
let uid = self.user.split(':').next().unwrap_or("");
if uid.is_empty()
|| uid == "0"
|| uid == "root"
|| !self.user.chars().all(|c| c.is_ascii_digit() || c == ':')
{
return Err(Refusal::Config(format!(
"user {:?} must be a numeric non-root uid:gid",
self.user
)));
}
let l = &self.limits;
if l.cpu_millis == 0
|| l.memory_mib < 64
|| l.pids < 8
|| l.scratch_mib == 0
|| l.timeout_secs == 0
|| l.report_max_bytes < 1024
{
return Err(Refusal::Config(format!("limits {l:?} are below the floor (cpu_millis > 0, memory_mib >= 64, pids >= 8, scratch_mib > 0, timeout_secs > 0, report_max_bytes >= 1024)")));
}
if l.scratch_mib >= l.memory_mib {
return Err(Refusal::Config(format!(
"scratch_mib {} must be below memory_mib {}: the tmpfs is charged to the container's memory",
l.scratch_mib, l.memory_mib
)));
}
Ok(())
}
}
/// A local name, or a digest pin.
pub fn image_is_pinned(image: &str) -> bool {
if image.starts_with("localhost/") && !image.contains("..") {
return true;
}
match image.rsplit_once("@sha256:") {
Some((name, hex)) => {
!name.is_empty() && hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit())
}
None => false,
}
}
/// **`image` pinned by `digest`** (`sha256:<64 hex>`, what the engine says the image is now):
/// `<repository>@<digest>`, the tag dropped. An image the config already pins by digest stands
/// as written. No digest, or one that is not a sha256, is [`Refusal::ImageMissing`]: a scan runs
/// only an image named by its digest.
pub fn pinned_ref(image: &str, digest: &str) -> Result<String, Refusal> {
let sha256 = |hex: &str| hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit());
if image
.rsplit_once("@sha256:")
.is_some_and(|(name, hex)| !name.is_empty() && sha256(hex))
{
return Ok(image.to_string());
}
let digest = digest.trim();
if !digest.strip_prefix("sha256:").is_some_and(sha256) {
return Err(Refusal::ImageMissing(format!(
"{image} (the engine names no sha256 digest for it, {digest:?}; a scan runs only an image named by its digest)"
)));
}
// The tag is a `:` in the last path segment; a `:` before the last `/` is a registry port.
let repository = match image.rsplit_once('/') {
Some((head, last)) => format!("{head}/{}", last.split_once(':').map_or(last, |(n, _)| n)),
None => image.split_once(':').map_or(image, |(n, _)| n).to_string(),
};
Ok(format!("{repository}@{}", digest.to_ascii_lowercase()))
}
// ── the refusals ────────────────────────────────────────────────────────────
/// Why a scan did not produce a report. Every one is a REFUSAL of the artifact:
/// a scan that did not finish never passes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
/// No container engine answers: holger refuses to scan.
NoEngine(String),
/// The worker ran past its wall clock and was killed.
Timeout { secs: u64 },
/// The container was killed by a limit (memory, pids) or crashed.
Killed {
exit: Option<i32>,
oom: bool,
stderr: String,
},
/// The worker wrote more than the report cap.
ReportTooLarge { cap: u64 },
/// The report does not read as the schema says (the caller's validation).
ReportInvalid(String),
/// `sandbox: off` in a release build.
OffInRelease,
/// The engine runs as root.
NotRootless,
/// The worker image is not on this host; it is never pulled.
ImageMissing(String),
/// The worker exited non-zero without a report.
WorkerFailed { exit: Option<i32>, stderr: String },
/// The sandbox config cannot mean what it says.
Config(String),
/// The job directory could not be written.
Io(String),
/// clamd in its sandbox is not answering (lane HOLGER-MEDIA).
ClamdDown(String),
/// The scan sidecar has nothing bounding a job's processes, in a release build (RA5-27).
NoPidsLimit(String),
/// The laid sandbox (a rootfs or the runtime) differs from the medium's manifest, and laying
/// it again from the medium did not make it equal (lane HOL-NCSC): the scans stay refused.
RootfsDrift(String),
/// A real build talks to ClamAV only over the sandboxed clamd's unix socket (lane HOL-NCSC):
/// no TCP clamd, no host clamd, no `clamscan` (refused at the start, by name).
ClamdNotSandboxed(String),
}
impl Refusal {
/// The frozen `holger-sandbox-<n>` code.
pub fn code(&self) -> &'static ErrCode {
match self {
Refusal::NoEngine(_) => &ec::SANDBOX_NO_ENGINE,
Refusal::Timeout { .. } => &ec::SANDBOX_TIMEOUT,
Refusal::Killed { .. } => &ec::SANDBOX_KILLED,
Refusal::ReportTooLarge { .. } => &ec::SANDBOX_REPORT_TOO_LARGE,
Refusal::ReportInvalid(_) => &ec::SANDBOX_REPORT_INVALID,
Refusal::OffInRelease => &ec::SANDBOX_OFF_IN_RELEASE,
Refusal::NotRootless => &ec::SANDBOX_NOT_ROOTLESS,
Refusal::ImageMissing(_) => &ec::SANDBOX_IMAGE_MISSING,
Refusal::WorkerFailed { .. } => &ec::SANDBOX_WORKER_FAILED,
Refusal::Config(_) => &ec::SANDBOX_CONFIG,
Refusal::Io(_) => &ec::SANDBOX_IO,
Refusal::ClamdDown(_) => &ec::SANDBOX_CLAMD_DOWN,
Refusal::NoPidsLimit(_) => &ec::SANDBOX_NO_PIDS_LIMIT,
Refusal::RootfsDrift(_) => &ec::SANDBOX_ROOTFS_DRIFT,
Refusal::ClamdNotSandboxed(_) => &ec::SANDBOX_CLAMD_NOT_SANDBOXED,
}
}
/// The short name a finding id carries (`sandbox-timeout`).
pub fn name(&self) -> &'static str {
match self {
Refusal::NoEngine(_) => "sandbox-no-engine",
Refusal::Timeout { .. } => "sandbox-timeout",
Refusal::Killed { .. } => "sandbox-killed",
Refusal::ReportTooLarge { .. } => "sandbox-report-too-large",
Refusal::ReportInvalid(_) => "sandbox-report-invalid",
Refusal::OffInRelease => "sandbox-off-in-release",
Refusal::NotRootless => "sandbox-not-rootless",
Refusal::ImageMissing(_) => "sandbox-image-missing",
Refusal::WorkerFailed { .. } => "sandbox-worker-failed",
Refusal::Config(_) => "sandbox-config",
Refusal::Io(_) => "sandbox-io",
Refusal::ClamdDown(_) => "sandbox-clamd-down",
Refusal::NoPidsLimit(_) => "sandbox-no-pids-limit",
Refusal::RootfsDrift(_) => "sandbox-rootfs-drift",
Refusal::ClamdNotSandboxed(_) => "sandbox-clamd-not-sandboxed",
}
}
fn words(&self) -> String {
match self {
Refusal::NoEngine(why) => format!("no container engine to scan in, so holger refuses to scan: {why}"),
Refusal::Timeout { secs } => format!("the scan worker ran past its {secs} s wall clock and was killed"),
Refusal::Killed { exit, oom, stderr } => format!(
"the scan worker was killed by its limits (exit {exit:?}{}){}",
if *oom { ", out of memory" } else { "" },
tail_words(stderr)
),
Refusal::ReportTooLarge { cap } => format!("the scan worker wrote more than the {cap}-byte report cap"),
Refusal::ReportInvalid(why) => format!("the scan worker's report is not valid: {why}"),
Refusal::OffInRelease => "`sandbox: off` is allowed only in a test build; this is a release build".into(),
Refusal::NotRootless => "the container engine runs as root; the scan sandbox needs rootless podman".into(),
Refusal::ImageMissing(img) => format!("the scan worker image {img} is not on this host (it is minted and carried, never pulled)"),
Refusal::WorkerFailed { exit, stderr } => {
format!("the scan worker exited {exit:?} without a report{}", tail_words(stderr))
}
Refusal::Config(why) => format!("the sandbox config is not valid: {why}"),
Refusal::Io(why) => format!("the scan job could not be prepared: {why}"),
Refusal::ClamdDown(why) => format!("clamd in its sandbox is not answering, so holger refuses to scan: {why}"),
Refusal::NoPidsLimit(why) => format!("nothing bounds a scan job's processes, so holger refuses to scan: {why}"),
Refusal::RootfsDrift(why) => format!("the laid sandbox differs from the medium, so holger refuses to scan: {why}"),
Refusal::ClamdNotSandboxed(why) => format!("a real build talks to ClamAV only over the sandboxed clamd's unix socket: {why}"),
}
}
}
fn tail_words(stderr: &str) -> String {
let t = stderr.trim();
if t.is_empty() {
String::new()
} else {
let last: String = t
.lines()
.rev()
.take(3)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join(" | ");
format!(": {}", last.chars().take(400).collect::<String>())
}
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.code().tag(self.words()))
}
}
impl std::error::Error for Refusal {}
// ── one run ─────────────────────────────────────────────────────────────────
/// One extra read-only input (`/in/<name>`): a YARA rules directory, the job's
/// own files (provenance material, a trust root).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mount {
pub host: PathBuf,
/// One path segment: `[A-Za-z0-9._-]+`.
pub name: String,
}
impl Mount {
pub fn new(host: impl Into<PathBuf>, name: impl Into<String>) -> Self {
Self {
host: host.into(),
name: name.into(),
}
}
/// The path inside the container.
pub fn container_path(&self) -> String {
format!("{EXTRA_DIR}/{}", self.name)
}
}
/// What one scan runs on.
#[derive(Debug, Clone)]
pub struct Request<'a> {
/// The artifact's bytes. Written to the job directory and mounted read-only.
pub artifact: &'a [u8],
/// The job document the worker reads at [`JOB_PATH`].
pub job: &'a [u8],
pub mounts: Vec<Mount>,
/// The artifact's sha256 hex, for the container's name.
pub sha256: &'a str,
/// The worker's arguments; empty (always, for a scan) runs the image's own
/// entrypoint. The live tests pass `probe` to look at the box from inside.
pub command: Vec<String>,
}
/// What came back from a finished worker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Output {
/// The report bytes, at most [`Limits::report_max_bytes`].
pub report: Vec<u8>,
pub elapsed: Duration,
}
/// The engine seam: one container run. [`Podman`] is the real one; a test
/// substitutes its own.
pub trait Engine: Send + Sync {
/// Is the engine there, rootless, with the image? `Ok` or the refusal.
fn ready(&self, cfg: &SandboxConfig) -> Result<(), Refusal>;
/// Run `argv` (the `run` arguments, [`run_args`]) and return the finished
/// worker's stdout, read under the cap and the deadline.
fn run(&self, cfg: &SandboxConfig, name: &str, args: &[String]) -> Result<Output, Refusal>;
/// **The image this engine runs, pinned by digest** (NCSC P1, lane HOL-NCSC): asked once,
/// after [`Engine::ready`], and every run names what it answers. podman resolves a local
/// tag to the digest it names now ([`pinned_ref`]), so a re-tag after the start cannot swap
/// what runs. An engine that runs no image by name (crun over the laid rootfs, the sidecar,
/// a test's) answers the configured name.
fn pin(&self, cfg: &SandboxConfig) -> Result<String, Refusal> {
Ok(cfg.image.clone())
}
}
/// The sandbox of one server: its config and its engine.
pub struct Sandbox {
cfg: SandboxConfig,
engine: Box<dyn Engine>,
/// The engine's first answer: the image pinned by digest, or the refusal.
ready: std::sync::OnceLock<Result<String, Refusal>>,
}
impl std::fmt::Debug for Sandbox {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Sandbox").field("cfg", &self.cfg).finish()
}
}
impl Sandbox {
/// The sandbox over podman. The config is validated now; the engine is
/// checked at the first scan (and then remembered).
/// With `url`, podman's API socket ([`api::PodmanApi`]: a Holger in a
/// container drives the host's rootless podman, no podman program beside
/// it); without, the local `podman` program.
pub fn podman(cfg: SandboxConfig) -> Result<Self, Refusal> {
Self::from_config(cfg)
}
/// The sandbox the config declares: `engine: podman` (the program, or
/// its socket with `url`) or `engine: crun` ([`crun::Crun`]).
pub fn from_config(cfg: SandboxConfig) -> Result<Self, Refusal> {
let engine: Box<dyn Engine> = match (cfg.engine, &cfg.url) {
(EngineKind::Heimdall, _) => {
return Err(Refusal::Config(
"engine `heimdall` is Heimdall's job runner (holger-job-runner, feature `heimdall`), not a container engine of the sandbox".into(),
))
}
(EngineKind::Crun, _) => Box::new(crun::Crun::default()),
(EngineKind::Remote, _) => Box::new(remote::Remote::new(&cfg)),
(EngineKind::Podman, Some(u)) => Box::new(api::PodmanApi::new(u)?),
(EngineKind::Podman, None) => Box::new(Podman),
};
Self::with_engine(cfg, engine)
}
/// Which engine this sandbox runs, for the boot log.
pub fn engine_words(&self) -> &'static str {
match (self.cfg.engine, &self.cfg.url) {
(EngineKind::Crun, _) => {
"crun over the laid worker rootfs (a user namespace, no network)"
}
(EngineKind::Remote, _) => {
"the scan-worker sidecar over its unix socket (no runtime in this pod, no network)"
}
(EngineKind::Podman, Some(_)) => "rootless podman over its API socket, no network",
(EngineKind::Podman, None) => "rootless podman, no network",
(EngineKind::Heimdall, _) => "Heimdall's protected runner",
}
}
pub fn with_engine(cfg: SandboxConfig, engine: Box<dyn Engine>) -> Result<Self, Refusal> {
cfg.validate()?;
Ok(Self {
cfg,
engine,
ready: std::sync::OnceLock::new(),
})
}
pub fn config(&self) -> &SandboxConfig {
&self.cfg
}
/// The image every run names, pinned by digest, once the engine answered.
pub fn pinned_image(&self) -> Option<String> {
self.ready.get().and_then(|r| r.as_ref().ok().cloned())
}
/// **Ready, and the image pinned**: the engine is asked once, then its digest of the
/// configured image ([`Engine::pin`]); both are remembered for the server's life, so a
/// re-tag on the host after the start changes nothing that runs. A sidecar is asked every
/// time and names no image.
fn pinned(&self) -> Result<String, Refusal> {
if self.cfg.engine == EngineKind::Remote {
self.engine.ready(&self.cfg)?;
return Ok(self.cfg.image.clone());
}
self.ready
.get_or_init(|| {
self.engine.ready(&self.cfg)?;
self.engine.pin(&self.cfg)
})
.clone()
}
/// Is the engine ready? Asked once, then remembered: a refusal stays a
/// refusal until the server restarts (fail closed, and cheap). A sidecar
/// (`engine: remote`) is asked every time: it starts beside the server, may
/// come later and may go, and a ping over a local socket is cheap.
pub fn ready(&self) -> Result<(), Refusal> {
self.pinned().map(|_| ())
}
/// Run the worker over one artifact, the image named by its digest.
pub fn run(&self, req: &Request<'_>) -> Result<Output, Refusal> {
let image = self.pinned()?;
let cfg = SandboxConfig {
image,
..self.cfg.clone()
};
check_mounts(&req.mounts)?;
let dir = self.job_dir()?;
let (artifact, job) = write_inputs(dir.path(), req)?;
let name = container_name(req.sha256);
let mut args = run_args(&cfg, &name, &artifact, &job, &req.mounts);
args.extend(req.command.iter().cloned());
self.engine.run(&cfg, &name, &args)
}
/// **Run one job with an output directory** (lane HOL-SANDBOX-TRAIT): as
/// [`Self::run`], under `limits` instead of the config's, with one fresh, empty,
/// writable directory mounted at [`OUT_PATH`]. After a finished run every
/// regular file the worker wrote there is moved into `out` (which must exist and
/// be empty); anything else there (a link, a directory) refuses the run and
/// nothing is handed over.
pub fn run_job(
&self,
req: &Request<'_>,
out: &Path,
limits: &Limits,
) -> Result<Output, Refusal> {
SandboxConfig {
limits: limits.clone(),
..self.cfg.clone()
}
.validate()?;
let mut there = std::fs::read_dir(out).map_err(|e| {
Refusal::Config(format!("the job's output directory {}: {e}", out.display()))
})?;
if there.next().is_some() {
return Err(Refusal::Config(format!(
"the job's output directory {} is not empty",
out.display()
)));
}
// The image named by its digest, as every run (lane HOL-NCSC).
let image = self.pinned()?;
let cfg = SandboxConfig {
image,
limits: limits.clone(),
..self.cfg.clone()
};
check_mounts(&req.mounts)?;
let dir = self.job_dir()?;
let (artifact, job) = write_inputs(dir.path(), req)?;
// The worker's one writable place: fresh, empty, writable by its (mapped) user.
let job_out = dir.path().join("out");
std::fs::create_dir(&job_out)
.map_err(|e| Refusal::Io(format!("{}: {e}", job_out.display())))?;
set_mode(&job_out, 0o777)?;
let name = container_name(req.sha256);
let mut args = run_args_out(&cfg, &name, &artifact, &job, &req.mounts, Some(&job_out));
args.extend(req.command.iter().cloned());
let o = self.engine.run(&cfg, &name, &args)?;
hand_over(&job_out, out)?;
Ok(o)
}
fn job_dir(&self) -> Result<tempfile::TempDir, Refusal> {
let b = match &self.cfg.work_dir {
Some(d) => {
std::fs::create_dir_all(d).map_err(|e| Refusal::Io(format!("{d}: {e}")))?;
tempfile::Builder::new()
.prefix("holger-scan-")
.tempdir_in(d)
}
None => tempfile::Builder::new().prefix("holger-scan-").tempdir(),
};
let dir = b.map_err(|e| Refusal::Io(format!("the job directory: {e}")))?;
set_mode(dir.path(), 0o755)?;
Ok(dir)
}
}
/// Every extra input is one path segment under [`EXTRA_DIR`], from an absolute host path.
fn check_mounts(mounts: &[Mount]) -> Result<(), Refusal> {
for m in mounts {
if !valid_segment(&m.name)
|| m.name == "artifact"
|| m.name == "job.json"
|| m.name == "out"
{
return Err(Refusal::Config(format!(
"mount name {:?} is not one path segment",
m.name
)));
}
if !m.host.is_absolute() {
return Err(Refusal::Config(format!(
"mount {} is not an absolute path",
m.host.display()
)));
}
}
Ok(())
}
/// The artifact and the job document, written read-only into the job directory.
fn write_inputs(dir: &Path, req: &Request<'_>) -> Result<(PathBuf, PathBuf), Refusal> {
let artifact = dir.join("artifact");
let job = dir.join("job.json");
write_ro(&artifact, req.artifact)?;
write_ro(&job, req.job)?;
Ok((artifact, job))
}
/// Move every regular file the worker wrote in `from` into `to`. Anything else
/// there refuses the run before a single file is moved.
fn hand_over(from: &Path, to: &Path) -> Result<(), Refusal> {
let mut files = Vec::new();
for e in std::fs::read_dir(from).map_err(|e| Refusal::Io(format!("{}: {e}", from.display())))? {
let e = e.map_err(|e| Refusal::Io(format!("{}: {e}", from.display())))?;
let name = e.file_name().to_string_lossy().into_owned();
let ft = e
.file_type()
.map_err(|e| Refusal::Io(format!("{name}: {e}")))?;
if !ft.is_file() || !valid_segment(&name) {
return Err(Refusal::ReportInvalid(format!(
"the job wrote {name:?} into its output directory, which is not a plain regular file; nothing is handed over"
)));
}
files.push(name);
}
for n in files {
let (a, b) = (from.join(&n), to.join(&n));
if std::fs::rename(&a, &b).is_err() {
// Another file system: copy, then remove the job's copy.
std::fs::copy(&a, &b).map_err(|e| Refusal::Io(format!("handing over {n}: {e}")))?;
let _ = std::fs::remove_file(&a);
}
}
Ok(())
}
fn valid_segment(s: &str) -> bool {
!s.is_empty()
&& s != "."
&& s != ".."
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
}
fn write_ro(path: &Path, bytes: &[u8]) -> Result<(), Refusal> {
std::fs::write(path, bytes).map_err(|e| Refusal::Io(format!("{}: {e}", path.display())))?;
// Readable by the container's non-root user (a rootless engine maps it to
// a subordinate uid), writable by nobody.
set_mode(path, 0o444)
}
fn set_mode(path: &Path, mode: u32) -> Result<(), Refusal> {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.map_err(|e| Refusal::Io(format!("{}: {e}", path.display())))
}
/// `holger-scan-<sha8>-<nonce>`: a name only this server's scans use, so a
/// cleanup removes only its own container.
pub fn container_name(sha256: &str) -> String {
let short: String = sha256
.chars()
.filter(|c| c.is_ascii_hexdigit())
.take(12)
.collect();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!(
"holger-scan-{short}-{}-{seq}-{nanos:08x}",
std::process::id()
)
}
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// **The container, as podman `run` arguments.** Pure, so every hardening flag
/// is tested without an engine. The worker's argv is the image's own.
pub fn run_args(
cfg: &SandboxConfig,
name: &str,
artifact: &Path,
job: &Path,
mounts: &[Mount],
) -> Vec<String> {
let l = &cfg.limits;
let mut a: Vec<String> = Vec::new();
let mut push = |s: String| a.push(s);
push("run".into());
push(format!("--name={name}"));
push("--pull=never".into());
push("--network=none".into());
push("--read-only".into());
push("--read-only-tmpfs=false".into());
push("--cap-drop=all".into());
push("--security-opt=no-new-privileges".into());
push(format!("--user={}", cfg.user));
push(format!(
"--cpus={}.{:03}",
l.cpu_millis / 1000,
l.cpu_millis % 1000
));
push(format!("--memory={}m", l.memory_mib));
push(format!("--memory-swap={}m", l.memory_mib));
push(format!("--pids-limit={}", l.pids));
push(format!(
"--tmpfs={SCRATCH_PATH}:rw,size={}m,mode=1777,noexec,nosuid,nodev",
l.scratch_mib
));
push(format!(
"--volume={}:{ARTIFACT_PATH}:ro",
artifact.display()
));
push(format!("--volume={}:{JOB_PATH}:ro", job.display()));
for m in mounts {
push(format!(
"--volume={}:{}:ro",
m.host.display(),
m.container_path()
));
}
push("--env=HOME=/scratch".into());
push("--env=TMPDIR=/scratch".into());
push("--log-driver=none".into());
push("--stop-timeout=1".into());
push("--init=false".into());
push(cfg.image.clone());
a
}
/// [`run_args`] with a job's writable output directory at [`OUT_PATH`].
pub fn run_args_out(
cfg: &SandboxConfig,
name: &str,
artifact: &Path,
job: &Path,
mounts: &[Mount],
out: Option<&Path>,
) -> Vec<String> {
let mut a = run_args(cfg, name, artifact, job, mounts);
if let Some(o) = out {
// Before the image: everything after it is the worker's argv.
let at = a.len() - 1;
a.insert(at, format!("--volume={}:{OUT_PATH}:rw", o.display()));
}
a
}
/// The writable output directory of a `run` argument list (the host path mounted
/// at [`OUT_PATH`]), when the run has one.
pub fn out_of(args: &[String]) -> Option<String> {
args.iter()
.filter_map(|a| a.strip_prefix("--volume="))
.filter_map(|v| v.strip_suffix(&format!(":{OUT_PATH}:rw")))
.map(String::from)
.next()
}
/// The read-only mounts of a `run` argument list, `(host, container)`: what
/// an engine that does not run the podman program decodes from [`run_args`].
pub(crate) fn mounts_of(args: &[String]) -> Vec<(String, String)> {
args.iter()
.filter_map(|a| a.strip_prefix("--volume="))
.filter_map(|v| v.strip_suffix(":ro"))
.filter_map(|v| v.rsplit_once(':'))
.map(|(h, c)| (h.to_string(), c.to_string()))
.collect()
}
/// The worker's own argv of a `run` argument list: everything after the image.
pub(crate) fn command_of(cfg: &SandboxConfig, args: &[String]) -> Vec<String> {
match args.iter().position(|a| a == &cfg.image) {
Some(i) => args[i + 1..].to_vec(),
None => Vec::new(),
}
}
// ── podman ──────────────────────────────────────────────────────────────────
/// The real engine: the podman program.
#[derive(Debug, Clone, Copy, Default)]
pub struct Podman;
impl Podman {
fn cmd(cfg: &SandboxConfig) -> Command {
// A32-14, a process by design: the podman engine, the sandbox's configured container program; the sandbox is the feature.
let mut c = Command::new(&cfg.podman);
c.stdin(Stdio::null());
c
}
fn quiet(cfg: &SandboxConfig, args: &[&str]) -> Result<std::process::Output, Refusal> {
Self::cmd(cfg)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| Refusal::NoEngine(format!("{} {}: {e}", cfg.podman, args.join(" "))))
}
/// Remove our own container (by its name), whatever state it is in.
fn remove(cfg: &SandboxConfig, name: &str) {
let _ = Self::quiet(cfg, &["rm", "--force", "--time=0", "--ignore", name]);
}
fn oom_killed(cfg: &SandboxConfig, name: &str) -> bool {
Self::quiet(
cfg,
&[
"inspect",
"--type=container",
"--format={{.State.OOMKilled}}",
name,
],
)
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
.unwrap_or(false)
}
}
impl Engine for Podman {
fn ready(&self, cfg: &SandboxConfig) -> Result<(), Refusal> {
let info = Self::quiet(cfg, &["info", "--format={{.Host.Security.Rootless}}"])?;
if !info.status.success() {
return Err(Refusal::NoEngine(format!(
"`{} info` failed: {}",
cfg.podman,
String::from_utf8_lossy(&info.stderr).trim()
)));
}
if cfg.require_rootless && String::from_utf8_lossy(&info.stdout).trim() != "true" {
return Err(Refusal::NotRootless);
}
let img = Self::quiet(cfg, &["image", "exists", &cfg.image])?;
if !img.status.success() {
return Err(Refusal::ImageMissing(cfg.image.clone()));
}
Ok(())
}
fn pin(&self, cfg: &SandboxConfig) -> Result<String, Refusal> {
let o = Self::quiet(
cfg,
&["image", "inspect", "--format={{.Digest}}", &cfg.image],
)?;
if !o.status.success() {
return Err(Refusal::ImageMissing(cfg.image.clone()));
}
pinned_ref(&cfg.image, String::from_utf8_lossy(&o.stdout).trim())
}
fn run(&self, cfg: &SandboxConfig, name: &str, args: &[String]) -> Result<Output, Refusal> {
let started = Instant::now();
let child = Self::cmd(cfg)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// A32-14, a process by design: the podman process (see its constructor).
.spawn()
.map_err(|e| Refusal::NoEngine(format!("{}: {e}", cfg.podman)))?;
let _claim = 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 if code == Some(137)
|| code == Some(139)
|| code.is_none()
|| Self::oom_killed(cfg, name)
{
Err(Refusal::Killed {
exit: code,
oom: Self::oom_killed(cfg, name),
stderr,
})
} else {
Err(Refusal::WorkerFailed { exit: code, stderr })
}
}
Drive::TimedOut => Err(Refusal::Timeout {
secs: cfg.limits.timeout_secs,
}),
Drive::TooLarge => Err(Refusal::ReportTooLarge {
cap: cfg.limits.report_max_bytes,
}),
Drive::Io(e) => Err(Refusal::Io(e)),
};
Self::remove(cfg, name);
out
}
}
pub(crate) enum Drive {
Done {
status: std::process::ExitStatus,
stdout: Vec<u8>,
stderr: String,
},
TimedOut,
TooLarge,
Io(String),
}
/// Read stdout (capped) and the stderr tail from one child, without a second
/// thread, until it exits, the deadline passes or stdout passes the cap. On the
/// deadline or the cap the podman client is killed; the caller then removes
/// the container by name, which kills the worker inside it.
pub(crate) fn drive(mut child: Child, deadline: Instant, cap: u64) -> Drive {
let mut out = child.stdout.take();
let mut err = child.stderr.take();
for p in [
out.as_ref().map(|p| p as &dyn std::os::fd::AsFd),
err.as_ref().map(|p| p as &dyn std::os::fd::AsFd),
]
.into_iter()
.flatten()
{
if let Err(e) = nonblocking(p) {
let _ = child.kill();
let _ = child.wait();
return Drive::Io(format!("the worker's pipes: {e}"));
}
}
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut buf = vec![0u8; 64 << 10];
loop {
let mut progressed = false;
if let Some(p) = out.as_mut() {
match p.read(&mut buf) {
Ok(0) => out = None,
Ok(n) => {
progressed = true;
if (stdout.len() + n) as u64 > cap {
let _ = child.kill();
let _ = child.wait();
return Drive::TooLarge;
}
stdout.extend_from_slice(&buf[..n]);
}
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => out = None,
}
}
if let Some(p) = err.as_mut() {
match p.read(&mut buf) {
Ok(0) => err = None,
Ok(n) => {
progressed = true;
stderr.extend_from_slice(&buf[..n]);
if stderr.len() > 2 * STDERR_TAIL {
stderr.drain(..stderr.len() - STDERR_TAIL);
}
}
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => err = None,
}
}
if out.is_none() && err.is_none() {
match child.try_wait() {
Ok(Some(status)) => {
return Drive::Done {
status,
stdout,
stderr: String::from_utf8_lossy(&stderr).into_owned(),
}
}
Ok(None) => {}
Err(e) => return Drive::Io(format!("waiting for the worker: {e}")),
}
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Drive::TimedOut;
}
if !progressed {
std::thread::sleep(Duration::from_millis(5));
}
}
}
fn nonblocking(fd: &dyn std::os::fd::AsFd) -> std::io::Result<()> {
use rustix::fs::{fcntl_getfl, fcntl_setfl, OFlags};
let fl = fcntl_getfl(fd)?;
fcntl_setfl(fd, fl | OFlags::NONBLOCK)?;
Ok(())
}
#[cfg(test)]
mod worker_tag_tests {
/// **The worker image is tagged with the server's version** (lane HOL-FIELD-TEST P1): one
/// fixed tag (`0.1.0`) across every server version let a stale worker answer a newer server.
#[test]
fn the_default_worker_image_names_this_version() {
assert_eq!(
super::DEFAULT_IMAGE,
format!("localhost/holger-scan-worker:{}", super::RELEASE)
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args() -> Vec<String> {
run_args(
&SandboxConfig::default(),
"holger-scan-abc",
Path::new("/w/j/artifact"),
Path::new("/w/j/job.json"),
&[Mount::new("/etc/holger/yara", "d-yara")],
)
}
#[test]
fn the_container_has_every_hardening_flag() {
let a = args();
for want in [
"--network=none",
"--read-only",
"--read-only-tmpfs=false",
"--cap-drop=all",
"--security-opt=no-new-privileges",
"--user=65532:65532",
"--pull=never",
"--cpus=1.000",
"--memory=1024m",
"--memory-swap=1024m",
"--pids-limit=64",
"--tmpfs=/scratch:rw,size=256m,mode=1777,noexec,nosuid,nodev",
"--volume=/w/j/artifact:/in/artifact:ro",
"--volume=/w/j/job.json:/in/job.json:ro",
"--volume=/etc/holger/yara:/in/d-yara:ro",
] {
assert!(a.iter().any(|x| x == want), "missing {want} in {a:?}");
}
assert!(
!a.iter().any(|x| x.contains("unconfined")),
"the default seccomp stays: {a:?}"
);
assert!(
!a.iter()
.any(|x| x.contains("--privileged") || x.contains("--cap-add")),
"{a:?}"
);
assert!(
a.iter()
.filter(|x| x.starts_with("--volume="))
.all(|x| x.ends_with(":ro")),
"every mount is read-only: {a:?}"
);
assert_eq!(
a.last().map(String::as_str),
Some(DEFAULT_IMAGE),
"the image is last; its argv is its own"
);
}
#[test]
fn a_moving_tag_root_or_a_tiny_limit_is_refused_by_name() {
let bad = SandboxConfig {
image: "docker.io/clamav/clamav:latest".into(),
..Default::default()
};
assert_eq!(bad.validate().unwrap_err().code().code, "holger-sandbox-10");
let pinned = SandboxConfig {
image: format!("docker.io/clamav/clamav:1.5.4@sha256:{}", "e1".repeat(32)),
..Default::default()
};
pinned.validate().unwrap();
let root = SandboxConfig {
user: "0:0".into(),
..Default::default()
};
assert!(root.validate().is_err());
let tiny = SandboxConfig {
limits: Limits {
scratch_mib: 2048,
..Default::default()
},
..Default::default()
};
assert!(
tiny.validate().is_err(),
"the tmpfs is charged to the memory limit"
);
}
#[test]
fn every_limit_reads_alone_from_ron() {
let c: SandboxConfig = ron::from_str("(limits: (timeout_secs: 5))").unwrap();
assert_eq!(c.limits.timeout_secs, 5);
assert_eq!(c.limits.memory_mib, Limits::DEFAULT_MEMORY_MIB);
assert_eq!(c.image, DEFAULT_IMAGE);
assert!(
ron::from_str::<SandboxConfig>("(limit: ())").is_err(),
"a misspelt key is refused"
);
}
#[test]
fn the_crun_engine_reads_from_ron_and_needs_an_absolute_rootfs() {
let c: SandboxConfig = ron::from_str("(engine: crun, rootfs: Some(\"/data/holger/sandbox/worker/rootfs\"), crun: \"/data/holger/sandbox/crun\")").unwrap();
assert_eq!(c.engine, EngineKind::Crun);
c.validate().unwrap();
assert_eq!(c.userns_base, crun::DEFAULT_USERNS_BASE);
let none: SandboxConfig = ron::from_str("(engine: crun)").unwrap();
assert_eq!(
none.validate().unwrap_err().code().code,
"holger-sandbox-10"
);
let root: SandboxConfig =
ron::from_str("(engine: crun, rootfs: Some(\"/r\"), userns_base: 0)").unwrap();
assert!(
root.validate()
.unwrap_err()
.to_string()
.contains("userns_base"),
"host root is never mapped in"
);
let s = Sandbox::from_config(c).unwrap();
assert!(s.engine_words().starts_with("crun"));
}
#[test]
fn every_refusal_carries_its_code_and_says_refuses() {
let r = Refusal::NoEngine("podman: not found".into());
assert!(r.to_string().starts_with("[holger-sandbox-1] "), "{r}");
assert!(r.to_string().contains("refuses to scan"), "{r}");
assert_eq!(
Refusal::Timeout { secs: 3 }.to_string(),
"[holger-sandbox-2] the scan worker ran past its 3 s wall clock and was killed"
);
}
#[test]
fn a_local_tag_is_pinned_to_its_digest() {
let d = format!("sha256:{}", "ab".repeat(32));
assert_eq!(
pinned_ref("localhost/holger-scan-worker:0.1.0", &d).unwrap(),
format!("localhost/holger-scan-worker@{d}")
);
assert_eq!(
pinned_ref("localhost:5000/w:1", &d).unwrap(),
format!("localhost:5000/w@{d}"),
"a registry port is not a tag"
);
assert_eq!(
pinned_ref("localhost/w", &d).unwrap(),
format!("localhost/w@{d}")
);
let already = format!("docker.io/x/y:1@{d}");
assert_eq!(
pinned_ref(&already, &format!("sha256:{}", "cd".repeat(32))).unwrap(),
already,
"a pin in the config stands"
);
let zz = format!("sha256:{}", "zz".repeat(32));
for bad in ["", "sha256:", "sha256:abc", "md5:00", zz.as_str()] {
let e = pinned_ref("localhost/w:1", bad).unwrap_err();
assert_eq!(e.code().code, "holger-sandbox-8", "{bad:?}: {e}");
}
}
/// What each run named: (the image, the run arguments).
type Runs = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<String>)>>>;
/// An engine that pins by digest and records what each run named.
struct Pinning(Runs);
impl Engine for Pinning {
fn ready(&self, _: &SandboxConfig) -> Result<(), Refusal> {
Ok(())
}
fn pin(&self, cfg: &SandboxConfig) -> Result<String, Refusal> {
pinned_ref(&cfg.image, &format!("sha256:{}", "ab".repeat(32)))
}
fn run(&self, cfg: &SandboxConfig, _: &str, args: &[String]) -> Result<Output, Refusal> {
self.0
.lock()
.unwrap()
.push((cfg.image.clone(), args.to_vec()));
Ok(Output {
report: b"{}".to_vec(),
elapsed: Duration::ZERO,
})
}
}
/// NCSC P1 (lane HOL-NCSC): the configured tag is resolved once, and every run names the
/// image by that digest, never by the tag.
#[test]
fn every_run_names_the_image_by_its_digest_never_its_tag() {
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let s =
Sandbox::with_engine(SandboxConfig::default(), Box::new(Pinning(log.clone()))).unwrap();
for _ in 0..2 {
s.run(&Request {
artifact: b"x",
job: b"{}",
mounts: vec![],
sha256: "ab",
command: vec![],
})
.unwrap();
}
let want = format!("localhost/holger-scan-worker@sha256:{}", "ab".repeat(32));
let seen = log.lock().unwrap();
assert_eq!(seen.len(), 2);
for (image, args) in seen.iter() {
assert_eq!(image, &want);
assert!(args.iter().any(|a| a == &want), "{args:?}");
assert!(
!args.iter().any(|a| a == DEFAULT_IMAGE),
"never the tag: {args:?}"
);
}
assert_eq!(s.pinned_image().as_deref(), Some(want.as_str()));
}
struct Absent;
impl Engine for Absent {
fn ready(&self, _: &SandboxConfig) -> Result<(), Refusal> {
Err(Refusal::NoEngine("no podman on this host".into()))
}
fn run(&self, _: &SandboxConfig, _: &str, _: &[String]) -> Result<Output, Refusal> {
panic!("an absent engine is never run")
}
}
#[test]
fn no_engine_fails_closed_before_anything_runs() {
let s = Sandbox::with_engine(SandboxConfig::default(), Box::new(Absent)).unwrap();
let e = s
.run(&Request {
artifact: b"x",
job: b"{}",
mounts: vec![],
sha256: "ab",
command: vec![],
})
.unwrap_err();
assert_eq!(e.code().code, "holger-sandbox-1");
}
#[test]
fn the_real_podman_program_missing_is_no_engine() {
let cfg = SandboxConfig {
podman: "/nonexistent/holger-test/podman".into(),
..Default::default()
};
assert!(matches!(Podman.ready(&cfg), Err(Refusal::NoEngine(_))));
}
/// An engine that plays the worker: it writes `files` (name, bytes, or a link
/// when the bytes are `LINK`) into the run's output directory and records the
/// limits it was run under.
struct Writes {
files: Vec<(&'static str, &'static [u8])>,
seen: std::sync::Arc<std::sync::Mutex<Option<Limits>>>,
}
const LINK: &[u8] = b"<link>";
impl Engine for Writes {
fn ready(&self, _: &SandboxConfig) -> Result<(), Refusal> {
Ok(())
}
fn run(&self, cfg: &SandboxConfig, _: &str, args: &[String]) -> Result<Output, Refusal> {
*self.seen.lock().unwrap() = Some(cfg.limits.clone());
let out = PathBuf::from(out_of(args).expect("a job run has an output directory"));
for (n, b) in &self.files {
if *b == LINK {
std::os::unix::fs::symlink("/etc/passwd", out.join(n)).unwrap();
} else {
std::fs::write(out.join(n), b).unwrap();
}
}
Ok(Output {
report: b"{}".to_vec(),
elapsed: Duration::ZERO,
})
}
}
type Seen = std::sync::Arc<std::sync::Mutex<Option<Limits>>>;
fn writes(files: Vec<(&'static str, &'static [u8])>) -> (Sandbox, Seen) {
let seen = Seen::default();
let s = Sandbox::with_engine(
SandboxConfig::default(),
Box::new(Writes {
files,
seen: seen.clone(),
}),
)
.unwrap();
(s, seen)
}
fn job_req() -> Request<'static> {
Request {
artifact: b"",
job: b"{}",
mounts: vec![],
sha256: "ab",
command: vec![],
}
}
#[test]
fn a_job_has_one_writable_out_and_every_other_mount_read_only() {
let a = run_args_out(
&SandboxConfig::default(),
"holger-scan-abc",
Path::new("/w/j/artifact"),
Path::new("/w/j/job.json"),
&[Mount::new("/w/in", "i")],
Some(Path::new("/w/j/out")),
);
assert!(a.iter().any(|x| x == "--volume=/w/j/out:/out:rw"), "{a:?}");
assert_eq!(
a.iter().filter(|x| x.ends_with(":rw")).count(),
1,
"one writable mount: {a:?}"
);
assert_eq!(out_of(&a).as_deref(), Some("/w/j/out"));
assert!(
!mounts_of(&a).iter().any(|(_, c)| c == OUT_PATH),
"the output is no read-only input"
);
assert!(
a.iter().any(|x| x == "--network=none") && a.iter().any(|x| x == "--read-only"),
"{a:?}"
);
assert_eq!(a.last().map(String::as_str), Some(DEFAULT_IMAGE));
assert_eq!(
run_args_out(
&SandboxConfig::default(),
"n",
Path::new("/a"),
Path::new("/j"),
&[],
None
),
run_args(
&SandboxConfig::default(),
"n",
Path::new("/a"),
Path::new("/j"),
&[]
)
);
assert_eq!(out_of(&args()), None);
}
#[test]
fn a_jobs_files_are_handed_over_under_its_own_limits() {
let (s, seen) = writes(vec![("a", b"alpha"), ("members.json", b"{}")]);
let dest = tempfile::tempdir().unwrap();
let limits = Limits {
timeout_secs: 7,
memory_mib: 300,
..Limits::default()
};
s.run_job(&job_req(), dest.path(), &limits).unwrap();
assert_eq!(std::fs::read(dest.path().join("a")).unwrap(), b"alpha");
assert_eq!(
std::fs::read(dest.path().join("members.json")).unwrap(),
b"{}"
);
let seen = seen.lock().unwrap().clone().expect("the engine ran");
assert_eq!(seen.timeout_secs, 7);
assert_eq!(seen.memory_mib, 300);
}
#[test]
fn a_linked_output_refuses_the_run_and_hands_over_nothing() {
let (s, _) = writes(vec![("a", b"alpha"), ("passwd", LINK)]);
let dest = tempfile::tempdir().unwrap();
let e = s
.run_job(&job_req(), dest.path(), &Limits::default())
.unwrap_err();
assert!(
matches!(&e, Refusal::ReportInvalid(w) if w.contains("passwd")),
"{e}"
);
assert_eq!(
std::fs::read_dir(dest.path()).unwrap().count(),
0,
"nothing is handed over"
);
}
#[test]
fn a_job_needs_an_empty_output_directory_and_limits_above_the_floor() {
let (s, _) = writes(vec![]);
let dest = tempfile::tempdir().unwrap();
std::fs::write(dest.path().join("old"), b"x").unwrap();
assert!(matches!(
s.run_job(&job_req(), dest.path(), &Limits::default())
.unwrap_err(),
Refusal::Config(_)
));
let empty = tempfile::tempdir().unwrap();
let tiny = Limits {
memory_mib: 1,
..Limits::default()
};
assert!(matches!(
s.run_job(&job_req(), empty.path(), &tiny).unwrap_err(),
Refusal::Config(_)
));
}
#[test]
fn a_child_past_its_deadline_is_killed_and_timed_out() {
let child = Command::new("sleep")
.arg("30")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let t = Instant::now();
assert!(matches!(
drive(child, Instant::now() + Duration::from_millis(200), 1024),
Drive::TimedOut
));
assert!(t.elapsed() < Duration::from_secs(5));
}
#[test]
fn a_child_past_the_cap_is_killed() {
let child = Command::new("yes")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
assert!(matches!(
drive(child, Instant::now() + Duration::from_secs(10), 4096),
Drive::TooLarge
));
}
#[test]
fn a_finished_child_hands_back_all_of_its_stdout() {
let child = Command::new("sh")
.args(["-c", "head -c 300000 /dev/zero; echo oops >&2"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
match drive(child, Instant::now() + Duration::from_secs(10), 1 << 20) {
Drive::Done {
status,
stdout,
stderr,
} => {
assert!(status.success());
assert_eq!(stdout.len(), 300_000);
assert_eq!(stderr.trim(), "oops");
}
_ => panic!("not done"),
}
}
}