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

nordisk/heimdall

crates/heimdall-cli/src/worker.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! `heimdall run --job wasm` under the GENERATED seccomp profile of the wasm
//! host (lane genprof; docs/SECCOMP.md "Enforcing the wasm host profile").
//!
//! **Heimdall is a defensive sandbox.** In a wasm job wasmtime is the only
//! native code (spec §2), so the generated `heimdall-wasm-host` profile is the
//! kernel filter of every wasm job. It is applied to a WORKER process, the
//! smallest place that runs the guest:
//!
//! 1. the parent `heimdall run` (no filter) picks the checked-in profile for
//!    this build's arch and libc (crate heimdall-profiles), checks
//!    `--seccomp-sha256`, makes the per-boot key and writes `--pin-out`;
//! 2. it starts itself again as the worker (`--heimdall-worker`) through
//!    heimdall-seccomp's `Launch`: the filter (prelude + profile, unknown
//!    call = `KILL_PROCESS`) is installed before the worker's first
//!    instruction, exactly as the probe and `profiles/tests/enforce.rs` ran
//!    the binary;
//! 3. the worker verifies and loads the guest, runs it, writes `out/files/`
//!    and prints the UNSIGNED result on stdout; it never holds the key;
//! 4. the parent re-checks every output file against that result, states
//!    `Protection::Generated { profile_digest }` (the sha256 of the profile's
//!    OCI JSON), signs and writes `out/result.signed`. A worker killed by the
//!    filter (`SIGSYS`) gives a signed Refused `heimdall-seccomp-1` with no
//!    outputs; another signal `heimdall-exit-2`.
//!
//! `--seccomp none` (or `HEIMDALL_SECCOMP=none`) runs the guest in the one
//! process without a filter, as before, and the result says
//! `Protection::None`. The probe corpus (`profiles/heimdall-wasm-host`) runs
//! that way, so the profile is the whole host's.

use std::io::Read;
use std::os::fd::OwnedFd;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use clap::ValueEnum;
use heimdall_core::{
    codes, BootKey, ExitInfo, Features, JobId, JobResult, Nonce, Protection, Sha256Digest, Verdict,
};
use heimdall_profiles::Entry;
use heimdall_run::job::wasm_features;
use heimdall_run::outdir::{FILES_DIR, RESULT_FILE};
use heimdall_seccomp::bpf::Mode;
use heimdall_seccomp::launch::{Exit, Launch, Stdio};
use heimdall_seccomp::{Arch, Libc, Profile};

/// The workload whose profile the wasm host runs under.
pub const WORKLOAD: &str = "heimdall-wasm-host";

/// The environment variable that sets the default `--seccomp`.
pub const ENV: &str = "HEIMDALL_SECCOMP";

/// The hidden flag that makes `heimdall run` the worker.
pub const WORKER_FLAG: &str = "--heimdall-worker";

/// The largest unsigned result a worker may print.
const MAX_RESULT_JSON: u64 = 16 << 20;

/// `--seccomp`.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum SeccompArg {
    /// The checked-in generated profile of the wasm host, in a worker process.
    Generated,
    /// No filter: the guest runs in this process (Protection::None).
    None,
}

/// The mode: the flag, else `HEIMDALL_SECCOMP`, else `generated` in a
/// release build and `none` in a debug build (the checked-in profiles are
/// generated from the release build; a debug build makes other calls, e.g.
/// std's debug-assertion `fcntl(F_GETFD)` checks).
pub fn mode(flag: Option<SeccompArg>) -> Result<SeccompArg, String> {
    if let Some(m) = flag {
        return Ok(m);
    }
    match std::env::var(ENV) {
        Err(std::env::VarError::NotPresent) if cfg!(debug_assertions) => Ok(SeccompArg::None),
        Err(std::env::VarError::NotPresent) => Ok(SeccompArg::Generated),
        Ok(v) => SeccompArg::from_str(&v, false)
            .map_err(|_| format!("{ENV}={v:?} is not `generated` or `none`")),
        Err(e) => Err(format!("{ENV}: {e}")),
    }
}

/// This build's libc (the profiles differ per libc).
pub fn build_libc() -> Libc {
    if cfg!(target_env = "musl") {
        Libc::Musl
    } else {
        Libc::Glibc
    }
}

/// The checked-in profile for this build's arch and libc.
pub fn builtin() -> Result<&'static Entry, String> {
    if cfg!(debug_assertions) {
        return Err(format!(
            "this is a debug build: the generated {WORKLOAD} profiles are for the release build"
        ));
    }
    let arch = Arch::native().ok_or("this arch has no seccomp profiles")?;
    let libc = build_libc();
    heimdall_profiles::ALL
        .iter()
        .find(|e| {
            e.profile.workload == WORKLOAD && e.profile.arch == arch && e.profile.libc == libc
        })
        .ok_or_else(|| format!("no generated {WORKLOAD} profile for {arch}-{libc} in this build"))
}

/// `sha256:<hex>` of a checked-in profile's OCI JSON.
pub fn digest(e: &Entry) -> String {
    Sha256Digest::of(e.oci_json.as_bytes()).to_prefixed()
}

/// The features of a run under the generated profile.
pub fn features(profile_digest: &str) -> Features {
    Features {
        seccomp: Protection::Generated {
            profile_digest: profile_digest.to_string(),
        },
        ..wasm_features()
    }
}

/// The test hook's variable (feature `test-hooks` only).
#[cfg_attr(not(feature = "test-hooks"), allow(dead_code))]
const TEST_HOOK_ENV: &str = "HEIMDALL_TEST_WORKER_CALL";

/// Test hook (feature `test-hooks`, never in a shipped binary):
/// `HEIMDALL_TEST_WORKER_CALL=socket` makes the worker open a TCP socket, a
/// call the wasm host profile does not allow, so the tests can prove the
/// filter kills it.
pub fn test_hook() {
    #[cfg(feature = "test-hooks")]
    if std::env::var(TEST_HOOK_ENV).as_deref() == Ok("socket") {
        let _ = std::net::TcpListener::bind("127.0.0.1:0");
    }
}

/// What the worker ended with.
pub enum WorkerEnd {
    /// The worker's unsigned result.
    Result(Box<JobResult>),
    /// The worker could not start the job (its message is on stderr): pass
    /// this exit code on, nothing is signed (as without a worker).
    Host(u8),
    /// The worker died: sign a refusal.
    Died(Verdict, ExitInfo),
}

/// Start the worker under `profile` with this process's arguments plus
/// [`WORKER_FLAG`]; wait for it and read its result.
pub fn spawn(profile: &Profile, profile_digest: &str) -> Result<WorkerEnd, String> {
    let exe = std::env::current_exe().map_err(|e| format!("current exe: {e}"))?;
    let (mine, theirs) = UnixStream::pair().map_err(|e| format!("worker socket: {e}"))?;
    // The worker starts from the environment the profile was probed with
    // (profiles/heimdall-wasm-host/workload.toml): nothing of the caller's
    // environment reaches it, so no LD_LIBRARY_PATH / LD_PRELOAD makes the
    // dynamic loader do what the probe never saw.
    #[allow(unused_mut)]
    let mut l = Launch::new(&exe)
        .arg0("heimdall")
        .args(std::env::args_os().skip(1))
        .arg(WORKER_FLAG)
        .env_clear()
        .env("PATH", "/usr/bin:/bin")
        .env("RUST_BACKTRACE", "0");
    #[cfg(feature = "test-hooks")]
    if let Some(v) = std::env::var_os(TEST_HOOK_ENV) {
        l = l.env(TEST_HOOK_ENV, v);
    }
    let l = l
        .stdin(Stdio::Null)
        .stdout(Stdio::Fd(OwnedFd::from(theirs)))
        .stderr(Stdio::Inherit)
        .profile(profile, &Mode::Enforce);
    let mut child = l.spawn().map_err(|e| format!("worker: {e}"))?;
    let reader = std::thread::spawn(move || {
        let mut out = Vec::new();
        let _ = (&mine).take(MAX_RESULT_JSON).read_to_end(&mut out);
        out
    });
    let exit = child.wait().map_err(|e| format!("worker: {e}"))?;
    let out = reader.join().unwrap_or_default();
    Ok(match exit {
        Exit::Code(0) => match serde_json::from_slice::<JobResult>(&out) {
            Ok(r) => WorkerEnd::Result(Box::new(r)),
            Err(e) => WorkerEnd::Died(
                Verdict::refused(
                    codes::EXIT_NONZERO,
                    format!("the wasm host worker printed no result ({e})"),
                ),
                ExitInfo::exited(0),
            ),
        },
        Exit::Code(c) if c == i32::from(crate::EXIT_HOST) || c == i32::from(crate::EXIT_USAGE) => {
            WorkerEnd::Host(c as u8)
        }
        Exit::Code(c) => WorkerEnd::Died(
            Verdict::refused(
                codes::EXIT_NONZERO,
                format!("the wasm host worker exited with code {c}"),
            ),
            ExitInfo::exited(c),
        ),
        Exit::Signal { signal, .. } if signal == libc::SIGSYS => WorkerEnd::Died(
            Verdict::refused(
                codes::SECCOMP_KILL,
                format!(
                    "the wasm host worker died from signal {signal} (SIGSYS): a system call its generated seccomp profile ({profile_digest}) kills"
                ),
            ),
            ExitInfo {
                signal: Some(signal),
                ..ExitInfo::default()
            },
        ),
        Exit::Signal { signal, .. } => WorkerEnd::Died(
            Verdict::refused(
                codes::EXIT_SIGNAL,
                format!("the wasm host worker died from signal {signal}"),
            ),
            ExitInfo {
                signal: Some(signal),
                ..ExitInfo::default()
            },
        ),
    })
}

fn now_unix() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

/// Remove every file the worker left in `out/files/` (a refused job hands on
/// no partial output); create `out/` and `out/files/` when the worker never
/// got that far.
fn discard_outputs(out: &Path) -> std::io::Result<()> {
    use std::os::unix::fs::DirBuilderExt;
    if std::fs::symlink_metadata(out).is_err() {
        std::fs::DirBuilder::new().mode(0o700).create(out)?;
    }
    let files = out.join(FILES_DIR);
    if std::fs::symlink_metadata(&files).is_err() {
        std::fs::DirBuilder::new().mode(0o700).create(&files)?;
        return Ok(());
    }
    for e in std::fs::read_dir(&files)? {
        let p = e?.path();
        if std::fs::symlink_metadata(&p)?.is_dir() {
            std::fs::remove_dir_all(&p)?;
        } else {
            std::fs::remove_file(&p)?;
        }
    }
    Ok(())
}

/// The result the parent signs for what the worker ended with: the worker's
/// own result with its outputs re-checked on disk, or a refusal. `job_id`,
/// `nonce` and the features are the parent's, never the worker's.
pub fn finish(
    end: WorkerEnd,
    out: &Path,
    job_id: JobId,
    nonce: Nonce,
    profile_digest: &str,
    started_unix: i64,
) -> Result<JobResult, String> {
    let refused = |verdict: Verdict, exit: ExitInfo| -> Result<JobResult, String> {
        discard_outputs(out).map_err(|e| format!("out dir {}: {e}", out.display()))?;
        Ok(JobResult {
            job_id,
            nonce,
            verdict,
            outputs: vec![],
            exit,
            started_unix,
            finished_unix: now_unix().max(started_unix),
            runner: features(profile_digest),
            events: vec![],
            kernel: None,
        })
    };
    match end {
        WorkerEnd::Host(c) => Err(format!("the worker could not start the job (exit {c})")),
        WorkerEnd::Died(v, e) => refused(v, e),
        WorkerEnd::Result(r) => {
            let mut r = *r;
            r.job_id = job_id;
            r.nonce = nonce;
            r.runner = features(profile_digest);
            r.kernel = None;
            if let Err(e) = heimdall_core::check_output_files(&r, &out.join(FILES_DIR)) {
                return refused(
                    Verdict::refused(
                        codes::OUT_AUDIT,
                        format!("the worker's outputs do not match its result ({e})"),
                    ),
                    r.exit,
                );
            }
            Ok(r)
        }
    }
}

/// Sign `result` and write `out/result.signed` (exclusive, no symlink follow).
pub fn sign_and_write(
    key: &BootKey,
    result: JobResult,
    out: &Path,
) -> Result<(heimdall_core::SignedResult, std::path::PathBuf), String> {
    let signed = key.sign(result).map_err(|e| e.to_string())?;
    let path = out.join(RESULT_FILE);
    let write = || -> std::io::Result<()> {
        use std::io::Write;
        use std::os::unix::fs::OpenOptionsExt;
        let mut f = std::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .mode(0o600)
            .custom_flags(libc::O_NOFOLLOW)
            .open(&path)?;
        f.write_all(&signed.to_bytes())?;
        f.sync_all()
    };
    write().map_err(|e| format!("{}: {e}", path.display()))?;
    Ok((signed, path))
}