Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/cli/src/sandbox_boot.rs

//! **The scan sandbox laid at boot** (lane HOLGER-MEDIA, 2026-10-23): the
//! appliance's half of `server_lib::sandbox::media`.
//!
//! On a box korp-start booted, the sandbox entries ride the running slot's app
//! archive (the same signed `.znippy` the server was `fexecve`'d out of). This
//! module reads that archive the way korp-start does (the slot from the ESP's
//! setting, `korp/app.len`, the raw partition staged into a memfd), hands each
//! entry to the lay (sha256-checked against the manifest), mounts cgroup v2 for
//! the limits, and starts clamd in its sandbox, supervised. All of it before the
//! intake chain opens, so `rules: (sandbox: required)` holds from the first scan.
//!
//! Elsewhere (a container, a developer's box, a test), `HOLGER_SANDBOX_MEDIA_DIR`
//! names a directory of entries (`cargo xtask scan-worker stage`); on a box korp
//! booted the variable is ignored, with a warning (NCSC P1, lane HOL-NCSC). With neither,
//! nothing is laid and the intake refuses every scan by name, as before.

use std::path::{Path, PathBuf};
use std::time::Duration;

use holger_sandbox::crun::{Kernel, Runtime, State};
use holger_sandbox::media::Layout;
use holger_sandbox::Refusal;
use server_lib::sandbox::{clamd, media};

/// cgroup v2's mount point (the kernel's own; `holger_sandbox::crun::CGROUP_ROOT`).
const CGROUP_ROOT: &str = holger_sandbox::crun::CGROUP_ROOT;

/// Where the entries come from this boot.
enum Source {
    /// A directory of entries (`HOLGER_SANDBOX_MEDIA_DIR`).
    Dir(PathBuf),
    /// The running slot's archive, staged into a memfd (the file keeps it alive).
    Archive(PathBuf, std::fs::File),
    /// The live medium's archive at a path (`korp.znippy=/app.znippy`).
    File(PathBuf),
}

/// **`HOLGER_SANDBOX_MEDIA_DIR`, unless korp booted this box** (NCSC P1, lane HOL-NCSC): on
/// a box korp-start booted the sandbox comes from the running slot's signed app archive only,
/// and the variable is ignored, with a warning that names it. Elsewhere it names the entries.
fn media_dir(env: Option<std::ffi::OsString>, korp_booted: bool) -> Option<PathBuf> {
    let d = env?;
    if korp_booted {
        log::warn!(
            "scan sandbox: {}={} is IGNORED: korp-start booted this box, so the sandbox is laid from the running slot's signed app archive only",
            media::MEDIA_DIR_ENV,
            d.to_string_lossy()
        );
        return None;
    }
    Some(PathBuf::from(d))
}

fn source() -> Result<Option<Source>, Refusal> {
    let korp = appliance_pid1::net::booted_by_korp_start();
    if let Some(d) = media_dir(std::env::var_os(media::MEDIA_DIR_ENV), korp) {
        return Ok(Some(Source::Dir(d)));
    }
    if !korp {
        return Ok(None);
    }
    let selector = korp_installer::cmdline::effective()
        .znippy
        .unwrap_or_else(|| "/system/app.znippy".into());
    match korp_installer::datahome::partition_selector(&selector) {
        Some(gpt_name) => {
            let esp = korp_installer::live::Esp::boot();
            let setting = esp
                .read_setting()
                .map_err(|e| Refusal::Io(format!("the running slot: {e}")))?;
            let slot = setting.active;
            let gpt_name = if gpt_name == korp_installer::live::Slot::A.partition() {
                slot.partition()
            } else {
                gpt_name
            };
            let len_at = esp.records(slot).len;
            let app_len: u64 = std::fs::read_to_string(&len_at)
                .map_err(|e| Refusal::Io(format!("{}: {e}", len_at.display())))?
                .trim()
                .parse()
                .map_err(|e| Refusal::Io(format!("{}: not a length ({e})", len_at.display())))?;
            let mut serial = korp_installer::serial::Serial::in_memory();
            let (path, keep) =
                korp_installer::datahome::stage_partition_archive(gpt_name, app_len, &mut serial)
                    .map_err(|()| {
                    Refusal::Io(format!(
                        "the app partition {gpt_name:?} could not be staged into memory"
                    ))
                })?;
            Ok(Some(Source::Archive(path, keep)))
        }
        None => {
            let p = PathBuf::from(selector);
            if p.is_file() {
                Ok(Some(Source::File(p)))
            } else {
                Ok(None)
            }
        }
    }
}

/// Mount cgroup v2 at its root when the kernel has not (an appliance boot).
fn mount_cgroup2() -> Result<(), Refusal> {
    if Path::new(CGROUP_ROOT).join("cgroup.controllers").exists() {
        return Ok(());
    }
    let fs = appliance_pid1::mounts::PseudoFs {
        source: "cgroup2",
        target: CGROUP_ROOT,
        fstype: "cgroup2",
        dir_mode: 0o755,
        flags: libc::MS_NOSUID | libc::MS_NOEXEC | libc::MS_NODEV,
    };
    appliance_pid1::mounts::mount_one(&fs).map_err(|errno| Refusal::NoEngine(format!("cgroup2 would not mount at {CGROUP_ROOT} (errno {errno}); the limits need CONFIG_CGROUPS")))
}

/// **An exec-capable view of the sandbox at its own path.** The data volume is
/// mounted `nosuid,nodev,noexec` (korp lays it so, rightly: a site's data is
/// never code), and the runtime, the worker's and ClamAV's rootfs live under it;
/// MEASURED on the laid box: `crun --version: Permission denied`. So the sandbox
/// directory — verified content only: the runtime and the rootfs the manifest's
/// sha256 vouched for — is bind-mounted onto itself and remounted `exec`, still
/// `nosuid,nodev`; every other byte of /data stays noexec, and every path in the
/// declaration stays as it is. Idempotent across a live re-exec (the mount is
/// found, not stacked).
fn exec_bind(dir: &Path) -> Result<(), Refusal> {
    let mountinfo = std::fs::read_to_string("/proc/self/mountinfo")
        .map_err(|e| Refusal::Io(format!("/proc/self/mountinfo: {e}")))?;
    match media::mount_noexec_for(&mountinfo, dir) {
        Some(false) | None => return Ok(()),
        Some(true) => {}
    }
    let c = std::ffi::CString::new(dir.as_os_str().as_encoded_bytes())
        .map_err(|_| Refusal::Io(format!("{}: not a C path", dir.display())))?;
    // SAFETY: both pointers are NUL-terminated C strings owned by `c`, which
    // outlives both calls; `mount(2)` only reads through them; the fstype and
    // data pointers are null (a bind and a remount take neither). The flags are
    // libc's own MS_* constants.
    let bound = unsafe {
        libc::mount(
            c.as_ptr(),
            c.as_ptr(),
            std::ptr::null(),
            libc::MS_BIND,
            std::ptr::null(),
        )
    };
    if bound != 0 {
        return Err(Refusal::Io(format!(
            "bind {} onto itself: {}",
            dir.display(),
            std::io::Error::last_os_error()
        )));
    }
    let flags = libc::MS_REMOUNT | libc::MS_BIND | libc::MS_NOSUID | libc::MS_NODEV;
    // SAFETY: as above; a remount of the bind just made, exec allowed.
    let remounted = unsafe {
        libc::mount(
            std::ptr::null(),
            c.as_ptr(),
            std::ptr::null(),
            flags,
            std::ptr::null(),
        )
    };
    if remounted != 0 {
        return Err(Refusal::Io(format!(
            "remount {} exec,nosuid,nodev: {}",
            dir.display(),
            std::io::Error::last_os_error()
        )));
    }
    log::info!("scan sandbox: {} remounted exec,nosuid,nodev over the noexec data volume (verified content only)", dir.display());
    Ok(())
}

/// The containers' user namespace base: the engine's default as root, none as
/// an ordinary user (a developer's box cannot shift owners).
pub fn userns_base() -> u32 {
    // SAFETY: `geteuid(2)` takes nothing, touches no memory and cannot fail.
    if unsafe { libc::geteuid() } == 0 {
        holger_sandbox::crun::DEFAULT_USERNS_BASE
    } else {
        0
    }
}

/// **The boot step.** `Ok(None)`: nothing to lay here. `Ok(Some)`: laid (or kept)
/// and clamd answering in its sandbox. `Err`: refused by name; the server serves
/// and the intake refuses every scan.
pub fn boot(data_root: &Path) -> Result<Option<media::Laid>, Refusal> {
    let Some(src) = source()? else {
        return Ok(None);
    };
    // This process is PID 1 on the appliance: its reaper leaves claimed children to
    // the engines (appliance_pid1::claim), or every scan's status could be lost.
    holger_sandbox::set_child_claim(|pid| Box::new(appliance_pid1::claim(pid)));
    let layout = Layout::under(data_root);
    let base = userns_base();
    let laid = match &src {
        Source::Dir(d) => media::lay(&layout, &media::fetch_from_dir(d.clone()), base)?,
        Source::Archive(p, _keep) => lay_from_archive(&layout, p, base)?,
        Source::File(p) => lay_from_archive(&layout, p, base)?,
    };
    drop(src);
    if appliance_pid1::net::booted_by_korp_start() {
        mount_cgroup2()?;
        exec_bind(&layout.dir)?;
    }
    let kernel = Kernel::probe()?;
    holger_sandbox::crun::cgroup_ready(Path::new(CGROUP_ROOT))?;
    let rt = Runtime {
        program: layout.crun().display().to_string(),
        state: State {
            dir: layout.state(),
        },
    };
    rt.version()?;
    let spec = clamd::spec(&layout, base, kernel.ipc);
    let c = clamd::Clamd::start(rt, spec, layout.clamd_socket(), clamd::START_BUDGET)?;
    log::info!(
        "clamd answers in its sandbox at {} (crun, uid {}, no network)",
        c.socket.display(),
        clamd::CLAMD_UID
    );
    // A28 M18, stays a task on the server's runtime: clamd's supervisor, which says and restarts clamd itself.
    tokio::spawn(clamd::supervise(c, Duration::from_secs(2)));
    Ok(Some(laid))
}

#[cfg(feature = "sign")]
fn lay_from_archive(layout: &Layout, archive: &Path, base: u32) -> Result<media::Laid, Refusal> {
    media::lay(
        layout,
        &media::fetch_from_archive(archive.to_path_buf()),
        base,
    )
}

#[cfg(not(feature = "sign"))]
fn lay_from_archive(_layout: &Layout, archive: &Path, _base: u32) -> Result<media::Laid, Refusal> {
    Err(Refusal::NoEngine(format!(
        "this build reads no archive ({}): it was built without `sign`",
        archive.display()
    )))
}

/// **The release's Sigstore trust root, seeded from the medium** (lane HOL-OCI-EVIDENCE): the
/// signed bundle and the policy that pins its signer ride beside the sandbox entries (the same
/// signed app archive, or `HOLGER_SANDBOX_MEDIA_DIR`); they are laid under
/// `<data_root>/holger-trust` before the intake chain opens (`server_lib::sigstore_trust::seed`:
/// a same-or-later root an import installed is kept, the floor rises). `Ok(None)`: the medium
/// carries none, and the box verifies no keyless signature until a signed bundle is imported.
pub fn seed_trust(data_root: &Path) -> Result<Option<server_lib::sigstore_trust::Seeded>, String> {
    match source().map_err(|e| e.to_string())? {
        Some(src) => seed_trust_from(&src, data_root),
        None => Ok(None),
    }
}

fn seed_trust_from(
    src: &Source,
    data_root: &Path,
) -> Result<Option<server_lib::sigstore_trust::Seeded>, String> {
    let fetch: Box<dyn Fn(&str) -> Option<Vec<u8>>> = match src {
        Source::Dir(d) => {
            let f = media::fetch_from_dir(d.clone());
            Box::new(move |n: &str| f(n).ok())
        }
        Source::Archive(p, _) | Source::File(p) => archive_fetch(p),
    };
    server_lib::sigstore_trust::seed(data_root, &*fetch)
}

#[cfg(feature = "sign")]
fn archive_fetch(p: &Path) -> Box<dyn Fn(&str) -> Option<Vec<u8>>> {
    let f = media::fetch_from_archive(p.to_path_buf());
    Box::new(move |n: &str| f(n).ok())
}

#[cfg(not(feature = "sign"))]
fn archive_fetch(_p: &Path) -> Box<dyn Fn(&str) -> Option<Vec<u8>>> {
    Box::new(|_: &str| None)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The medium's two trust entries (a directory of entries here) are seeded under the data root.
    #[test]
    fn the_medium_seeds_the_trust_root_under_the_data_root() {
        let medium = tempfile::tempdir().unwrap();
        let signer = tempfile::tempdir().unwrap();
        let root = std::fs::read(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../provenance/tests/fixtures/sigstore/trusted_root.json"
        ))
        .unwrap();
        let policy = server_lib::sigstore_trust::make(
            &root,
            signer.path(),
            &medium.path().join(server_lib::sigstore_trust::BUNDLE_FILE),
            7,
            30,
            None,
        )
        .unwrap();
        std::fs::write(
            medium.path().join(server_lib::sigstore_trust::POLICY_FILE),
            policy.to_ron().unwrap(),
        )
        .unwrap();
        let data = tempfile::tempdir().unwrap();
        let s = seed_trust_from(&Source::Dir(medium.path().to_path_buf()), data.path())
            .unwrap()
            .expect("seeded");
        assert_eq!((s.sequence, s.kept), (7, false));
        assert!(server_lib::sigstore_trust::bundle_path(data.path()).is_file());
        assert!(server_lib::sigstore_trust::policy_path(data.path()).is_file());
        // What the seed wrote is the medium's, not the box's (lane HOL-REBUILT-SYNC-KEY): a box
        // laid again after its main died stays fresh, so its boot hold holds it for its recharge.
        assert_eq!(
            server_lib::twin::held_state(data.path()),
            None,
            "the seeded trust root is no history"
        );
        // A medium without them seeds nothing.
        let empty = tempfile::tempdir().unwrap();
        assert!(
            seed_trust_from(&Source::Dir(empty.path().to_path_buf()), data.path())
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn the_media_dir_variable_is_ignored_on_a_box_korp_booted() {
        assert_eq!(
            media_dir(Some("/tmp/entries".into()), false),
            Some(PathBuf::from("/tmp/entries")),
            "a developer's box, a container, a test"
        );
        assert_eq!(
            media_dir(Some("/tmp/entries".into()), true),
            None,
            "the appliance lays from its signed archive only"
        );
        assert_eq!(media_dir(None, true), None);
        assert_eq!(media_dir(None, false), None);
    }
}