holger-scan-worker 3.1.1

Holger's scan worker: the one program the scan sandbox runs, in a container with no network, over one read-only artifact. It holds holger-inspect (the walk and YARA), the model, licence and SBOM scanners, the image cataloger and holger-provenance, and answers one JSON report on stdout.
//! **holger-scan-worker**: the program the scan sandbox runs.
//!
//! In: `/in/job.json` (the job, read-only), `/in/artifact` (the bytes,
//! read-only), `/in/m/` (the job's own files, read-only), `/in/d-yara` (the
//! YARA rules, read-only). Out: ONE `holger.scan-worker-report/v2` document on
//! stdout. Exit 0 with a report, 2 when the job cannot be read, 3 when the
//! artifact is not the bytes the job names. Nothing else is written anywhere
//! but the capped `/scratch`.
//!
//! A `holger.job/v1` document at `/in/job.json` (lane HOL-SANDBOX-TRAIT) is a JOB
//! instead: the program it names (`verify-bundle`, …) runs over `/in/i` and writes into
//! `/out`, and the worker answers one `holger.job-answer/v1` document on stdout, or, when
//! the job names an answer file (Heimdall's runner, lane HOL-HEIMDALL-02), as that file in
//! `/out`.
//!
//! `serve [--socket <path>] [--share <dir>] [--scratch <dir>] [--max-jobs <n>]
//! [--memory-mib <n>] [--timeout-secs <n>] [--pod-pids-limit <n>]`: **the scan worker as a SIDECAR**
//! (lane B4, A31 §7.3), the Kubernetes pod's engine. It listens on a unix
//! socket in the directory it shares with the server and runs THIS program
//! over each job directory the server writes there, in a fresh subprocess with
//! rlimits and a wall clock (`holger_sandbox::remote`). The job's `/in/…`
//! paths then name that job directory.

use std::io::Write as _;
use std::path::Path;

const MAX_JOB: u64 = 4 << 20;

fn fail(code: i32, why: &str) -> ! {
    eprintln!("holger-scan-worker: {why}");
    std::process::exit(code)
}

/// What the box looks like from inside (no network, a read-only root, the scratch cap, no
/// capabilities, no new privileges, a seccomp filter, a non-root user), `fill` bytes the write
/// it tries past the scratch cap. The `probe` mode prints it; the `probe` job writes it.
fn inside(fill: usize) -> serde_json::Value {
    use std::time::Duration;
    let connect = |addr: &str| -> String {
        match addr.parse::<std::net::SocketAddr>() {
            Ok(a) => match std::net::TcpStream::connect_timeout(&a, Duration::from_secs(2)) {
                Ok(_) => "connected".into(),
                Err(e) => format!("refused: {e}"),
            },
            Err(e) => format!("bad address: {e}"),
        }
    };
    // A write past `RLIMIT_FSIZE` (the sidecar's scratch cap) is a SIGXFSZ, which ends
    // the process; ignored, it is the EFBIG the probe reports as the cap holding.
    // SAFETY: setting one signal's disposition to SIG_IGN, before any thread.
    unsafe {
        libc::signal(libc::SIGXFSZ, libc::SIG_IGN);
    }
    // In 1 MiB pieces: one 1 GiB buffer would itself be refused by a job's address
    // space limit (the sidecar's `RLIMIT_AS`), so the probe would die before it wrote.
    let write = |p: &str, n: usize| -> String {
        let piece = vec![0u8; n.min(1 << 20)];
        let r = std::fs::File::create(p).and_then(|mut f| {
            let mut left = n;
            while left > 0 {
                let k = left.min(piece.len());
                f.write_all(&piece[..k])?;
                left -= k;
            }
            Ok(())
        });
        match r {
            Ok(()) => "written".into(),
            Err(e) => format!("refused: {e}"),
        }
    };
    // The job's scratch: `/scratch` in a container engine (the image's TMPDIR), the
    // job's own directory under the sidecar (its TMPDIR).
    let scratch = std::env::var("TMPDIR")
        .ok()
        .filter(|t| !t.is_empty())
        .unwrap_or_else(|| "/scratch".into());
    let write_scratch = write(&format!("{scratch}/holger-probe"), 1024);
    let write_past_scratch_cap = write(&format!("{scratch}/holger-fill"), fill);
    let _ = std::fs::remove_file(format!("{scratch}/holger-fill"));
    let status = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
    let field = |k: &str| {
        status
            .lines()
            .find_map(|l| l.strip_prefix(k))
            .map(|v| v.trim().to_string())
            .unwrap_or_default()
    };
    let v = serde_json::json!({
        "connect_public": connect("1.1.1.1:443"),
        "connect_gateway": connect("10.0.2.2:80"),
        "resolve": format!("{:?}", std::net::ToSocketAddrs::to_socket_addrs(&("pypi.org", 443)).map(|a| a.count())),
        "write_root": write("/holger-probe", 1),
        "write_etc": write("/etc/holger-probe", 1),
        "write_in": write("/in/holger-probe", 1),
        "write_traversal": write("/scratch/../etc/holger-probe", 1),
        "write_scratch": write_scratch,
        "write_past_scratch_cap": write_past_scratch_cap,
        "uid": field("Uid:"),
        "cap_eff": field("CapEff:"),
        "cap_bnd": field("CapBnd:"),
        "no_new_privs": field("NoNewPrivs:"),
        "seccomp": field("Seccomp:"),
    });
    v
}

/// `probe [--sleep <s>] [--alloc <MiB>] [--spam <MiB>]`: what the box looks like from inside, as JSON, for the
/// live tests (no network, a read-only root, the scratch cap, no capabilities,
/// no new privileges, a seccomp filter, a non-root user). It reads no artifact.
fn probe(args: &[String]) -> ! {
    use std::time::Duration;
    let num = |f: &str| {
        args.iter()
            .position(|a| a == f)
            .and_then(|i| args.get(i + 1))
            .and_then(|s| s.parse::<u64>().ok())
    };
    if let Some(s) = num("--sleep") {
        std::thread::sleep(Duration::from_secs(s));
    }
    if let Some(mib) = num("--alloc") {
        // Touch every page, so the memory limit sees it.
        let mut held: Vec<Vec<u8>> = Vec::new();
        for _ in 0..mib {
            held.push(vec![1u8; 1 << 20]);
        }
        eprintln!("allocated {} MiB", held.len());
    }
    if let Some(mib) = num("--spam") {
        let line = vec![b'x'; 1 << 20];
        let mut so = std::io::stdout().lock();
        for _ in 0..mib {
            let _ = so.write_all(&line);
        }
        std::process::exit(0)
    }
    println!("{}", inside(1 << 30));
    std::process::exit(0)
}

/// The `probe` program of a job: [`inside`] into the job's output, `params.fill_mib` the write
/// past the scratch cap it tries (1 GiB when the job names none).
fn probe_job(
    w: &server_lib::job_runner::holger_job_runner::wire::WireJob,
    dirs: &server_lib::job_runner::holger_job_runner::wire::Dirs,
) -> server_lib::job_runner::holger_job_runner::wire::Answer {
    use server_lib::job_runner::holger_job_runner::wire::Answer;
    let fill = w.params["fill_mib"]
        .as_u64()
        .map_or(1 << 30, |m| (m.min(4096) << 20) as usize);
    let v = inside(fill);
    match std::fs::write(
        dirs.out.join(server_lib::sandbox::PROBE_FILE),
        v.to_string(),
    ) {
        Ok(()) => Answer::accepted(vec![server_lib::sandbox::PROBE_FILE.into()]),
        Err(e) => Answer::refused(
            server_lib::errcode::SANDBOX_REPORT_INVALID.code,
            server_lib::errcode::SANDBOX_REPORT_INVALID.tag(format!("the probe's answer: {e}")),
        ),
    }
}

/// `serve …`: the sidecar. Runs until its socket fails.
fn serve(args: &[String]) -> ! {
    let me = std::env::current_exe()
        .unwrap_or_else(|e| fail(2, &format!("this program's own path: {e}")));
    let mut cfg = holger_sandbox::remote::ServeConfig::defaults(me);
    let mut it = args.iter();
    while let Some(flag) = it.next() {
        let mut val = || {
            it.next()
                .cloned()
                .unwrap_or_else(|| fail(2, &format!("{flag} needs a value")))
        };
        let num = |v: String| {
            v.parse::<u64>()
                .unwrap_or_else(|_| fail(2, &format!("{flag} {v:?} is not a number")))
        };
        match flag.as_str() {
            "--socket" => cfg.socket = val().into(),
            "--share" => cfg.share = val().into(),
            "--scratch" => cfg.scratch = val().into(),
            "--max-jobs" => cfg.max_jobs = num(val()).max(1) as usize,
            "--memory-mib" => cfg.ceiling.memory_mib = num(val()),
            "--timeout-secs" => cfg.ceiling.timeout_secs = num(val()),
            // RA5-27: the kubelet's podPidsLimit, out of the sidecar's own sight.
            "--pod-pids-limit" => cfg.pod_pids_limit = Some(num(val())).filter(|n| *n > 0),
            other => fail(2, &format!("serve: unknown flag {other:?}")),
        }
    }
    let listener = holger_sandbox::remote::bind(&cfg)
        .unwrap_or_else(|e| fail(2, &format!("binding {}: {e}", cfg.socket.display())));
    // PID 1 in its container ignores a signal it has no handler for: SIGTERM
    // and SIGINT end the sidecar (a running job's child dies with it).
    let socket = cfg.socket.clone();
    if let Err(e) = ctrlc::set_handler(move || {
        let _ = std::fs::remove_file(&socket);
        std::process::exit(0)
    }) {
        fail(2, &format!("the signal handler: {e}"));
    }
    eprintln!(
        "holger-scan-worker: the scan sidecar listens on {} over {} ({} job slots, at most {} MiB and {} s a job)",
        cfg.socket.display(),
        cfg.share.display(),
        cfg.max_jobs,
        cfg.ceiling.memory_mib,
        cfg.ceiling.timeout_secs
    );
    match holger_sandbox::remote::serve(listener, cfg) {
        Ok(()) => std::process::exit(0),
        Err(e) => fail(2, &format!("the socket: {e}")),
    }
}

/// The job's `/in/…` paths (the YARA rules at `/in/d-yara`) name `dir` when the
/// worker runs over a job directory that is not `/in` (the sidecar's).
fn rebase(job: &mut server_lib::sandbox::wire::Job, dir: &Path) {
    if dir == Path::new("/in") {
        return;
    }
    if let Some(r) = job.inspect.yara.as_mut().and_then(|y| y.rules.as_mut()) {
        if let Some(rest) = r.strip_prefix("/in/") {
            *r = dir.join(rest).display().to_string();
        }
    }
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    if args.get(1).map(String::as_str) == Some("probe") {
        probe(&args[2..]);
    }
    if args.get(1).map(String::as_str) == Some("serve") {
        serve(&args[2..]);
    }
    let dir = args.get(1).cloned().unwrap_or_else(|| "/in".to_string());
    let dir = Path::new(&dir);
    let job_path = dir.join("job.json");
    let meta = std::fs::metadata(&job_path)
        .unwrap_or_else(|e| fail(2, &format!("{}: {e}", job_path.display())));
    if meta.len() > MAX_JOB {
        fail(2, "the job is larger than its cap");
    }
    let raw = std::fs::read(&job_path).unwrap_or_else(|e| fail(2, &format!("the job: {e}")));
    // Lane HOL-SANDBOX-TRAIT: a `holger.job/v1` document names a program (verify-bundle,
    // …) over `<dir>/i`, writing into `/out` (`<dir>/out` under the sidecar), and the
    // worker answers ONE `holger.job-answer/v1` document. A refusal is an answer: exit 0.
    if let Ok(w) =
        serde_json::from_slice::<server_lib::job_runner::holger_job_runner::wire::WireJob>(&raw)
    {
        let dirs = server_lib::job_runner::holger_job_runner::wire::Dirs::under(dir, w.reads);
        // HOL-HEIMDALL-06: the probe as a job of the server's runner (Heimdall's, where no
        // sandbox of holger's own stands between): what the box looks like from inside, into
        // `/out/probe.json`.
        let answer = if w.program() == server_lib::sandbox::PROBE_PROGRAM {
            probe_job(&w, &dirs)
        } else {
            server_lib::job_runner::programs::run(&w, &dirs)
        };
        // Under Heimdall (lane HOL-HEIMDALL-02) the job names an answer FILE in `/out`
        // (its stdout is /dev/null there); elsewhere the answer is stdout.
        let out = server_lib::job_runner::holger_job_runner::wire::deliver(&w, &dirs.out, &answer)
            .unwrap_or_else(|e| fail(2, &format!("the answer: {e}")));
        if let Some(out) = out {
            let mut so = std::io::stdout().lock();
            if so.write_all(&out).and_then(|_| so.flush()).is_err() {
                fail(2, "stdout closed");
            }
        }
        std::process::exit(0);
    }
    let mut job: server_lib::sandbox::wire::Job =
        serde_json::from_slice(&raw).unwrap_or_else(|e| fail(2, &format!("the job: {e}")));
    if job.schema != server_lib::sandbox::wire::JOB_SCHEMA {
        fail(2, &format!("job schema {:?}", job.schema));
    }
    rebase(&mut job, dir);
    let f = std::fs::File::open(dir.join("artifact"))
        .unwrap_or_else(|e| fail(2, &format!("the artifact: {e}")));
    let len = f.metadata().map(|m| m.len()).unwrap_or(0);
    // An empty file cannot be mapped; it is still an artifact.
    let map;
    let bytes: &[u8] = if len == 0 {
        &[]
    } else {
        // SAFETY: the artifact is a read-only bind mount nobody writes while the
        // worker runs; a change under the map could only alter this worker's own
        // answer, which the server then checks against the sha256 it hashed.
        map = unsafe { memmap2::Mmap::map(&f) }
            .unwrap_or_else(|e| fail(2, &format!("mapping the artifact: {e}")));
        &map[..]
    };
    let report = server_lib::sandbox::worker::run(&job, bytes, &dir.join("m"));
    if report.sha256 != job.sha256 {
        fail(3, "the artifact is not the bytes the job names");
    }
    let out = serde_json::to_vec(&report).unwrap_or_else(|e| fail(2, &format!("the report: {e}")));
    let mut so = std::io::stdout().lock();
    if so.write_all(&out).and_then(|_| so.flush()).is_err() {
        fail(2, "stdout closed");
    }
}

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

    fn job_with_rules(rules: &str) -> server_lib::sandbox::wire::Job {
        let mut v = serde_json::json!({
            "schema": server_lib::sandbox::wire::JOB_SCHEMA,
            "sha256": "00", "file_name": "f", "ecosystem": "raw", "format_name": "raw",
            "inspect": {}, "kinds": [],
        });
        let mut job: server_lib::sandbox::wire::Job = serde_json::from_value(v.take()).unwrap();
        job.inspect.yara = Some(Default::default());
        job.inspect.yara.as_mut().unwrap().rules = Some(rules.into());
        job
    }

    #[test]
    fn a_sidecar_job_reads_its_rules_from_its_own_directory() {
        let mut j = job_with_rules("/in/d-yara");
        rebase(&mut j, Path::new("/run/holger-scan/holger-scan-abc"));
        assert_eq!(
            j.inspect.yara.unwrap().rules.as_deref(),
            Some("/run/holger-scan/holger-scan-abc/d-yara")
        );
        let mut k = job_with_rules("/in/d-yara");
        rebase(&mut k, Path::new("/in"));
        assert_eq!(
            k.inspect.yara.unwrap().rules.as_deref(),
            Some("/in/d-yara"),
            "in a container the paths stay"
        );
        let mut o = job_with_rules("/etc/rules");
        rebase(&mut o, Path::new("/x"));
        assert_eq!(
            o.inspect.yara.unwrap().rules.as_deref(),
            Some("/etc/rules"),
            "a path outside /in is not touched"
        );
    }
}