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

nordisk/holger

server/lib/tests/scan_sandbox_live.rs

//! **Every scan runs in the scan sandbox** (lane SANDBOX-SCAN, owner 2026-10-23).
//!
//! Each test runs through `ScanWorker`, the server's only road to the scanners:
//!
//! * in-process (`sandbox: off`, this test build) by default, which proves the
//!   worker's code reaches the server's own verdicts;
//! * in the REAL container when `HOLGER_TEST_SANDBOX=required` (`cargo xtask
//!   scan-worker tests`): the minted worker image under rootless podman, over
//!   its API socket (`HOLGER_TEST_SANDBOX_URL`), with a job directory both sides
//!   see (`HOLGER_TEST_SANDBOX_WORKDIR`).
//!
//! The tests that only mean something in a container (the wall clock killing a
//! bomb the inspector was told to let through, EICAR found by clamd in its own
//! sandbox) are `#[ignore]`d and say so when the container is not there.

use std::path::PathBuf;
use std::sync::Arc;

use holger_sandbox::{Limits, SandboxConfig};
use holger_server_lib::sandbox::{
    JobTemplate, SandboxRule, ScanWorker, VulnFromWorker, WorkerScanner,
};
use holger_server_lib::scanner::{FindingSeverity, ScanSubject, ScanVerdict, Scanner, ScannerKind};

fn contained() -> bool {
    std::env::var("HOLGER_TEST_SANDBOX").as_deref() == Ok("required")
}

fn sandbox_cfg(limits: Limits) -> SandboxConfig {
    SandboxConfig {
        url: std::env::var("HOLGER_TEST_SANDBOX_URL").ok(),
        work_dir: std::env::var("HOLGER_TEST_SANDBOX_WORKDIR").ok(),
        image: std::env::var("HOLGER_SCAN_WORKER_IMAGE")
            .unwrap_or_else(|_| holger_sandbox::DEFAULT_IMAGE.into()),
        limits,
        ..Default::default()
    }
}

fn template(format_name: &str, inspect: holger_inspect::InspectConfig) -> JobTemplate {
    JobTemplate {
        format_name: format_name.into(),
        inspect,
        kinds: vec![
            ScannerKind::Inspection,
            ScannerKind::Model,
            ScannerKind::Malware,
            ScannerKind::License,
            ScannerKind::Sbom,
            ScannerKind::Secrets,
        ],
        license: Default::default(),
        allow_pickle: Vec::new(),
        secrets: None,
    }
}

/// The worker this run uses: the container when asked for, else in-process.
fn worker_with(
    format_name: &str,
    inspect: holger_inspect::InspectConfig,
    limits: Limits,
) -> Arc<ScanWorker> {
    let (rule, cfg) = if contained() {
        (SandboxRule::Required, Some(sandbox_cfg(limits)))
    } else {
        (SandboxRule::Off, None)
    };
    Arc::new(ScanWorker::new(rule, cfg, template(format_name, inspect)).expect("the worker"))
}

fn worker(format_name: &str) -> Arc<ScanWorker> {
    worker_with(format_name, Default::default(), Limits::default())
}

fn fixture(rel: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(rel)
}

fn sha(b: &[u8]) -> String {
    use sha2::Digest as _;
    hex::encode(sha2::Sha256::digest(b))
}

fn tar_of(files: &[(String, Vec<u8>)]) -> Vec<u8> {
    let mut b = tar::Builder::new(Vec::new());
    for (p, d) in files {
        let mut h = tar::Header::new_gnu();
        h.set_size(d.len() as u64);
        h.set_mode(0o644);
        h.set_entry_type(tar::EntryType::Regular);
        h.set_path(p).unwrap_or_else(|_| {
            // A traversal path is what the test plants: write the raw name.
            let n = p.as_bytes();
            h.as_old_mut().name[..n.len()].copy_from_slice(n);
        });
        h.set_cksum();
        b.append(&h, d.as_slice()).unwrap();
    }
    b.into_inner().unwrap()
}

fn gz(b: &[u8]) -> Vec<u8> {
    use std::io::Write as _;
    let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
    e.write_all(b).unwrap();
    e.finish().unwrap()
}

/// `cgr.dev/chainguard/static` as the pull-through keeps it (`.oci.tar`): the
/// index, its amd64 manifest, that manifest's config and layer, from the real
/// fixture. `extra` is one more gzip layer on top.
fn chainguard_static(extra: Option<Vec<(String, Vec<u8>)>>) -> Vec<u8> {
    let dir = fixture("pull_through/oci/chainguard/static");
    let read = |p: &str| std::fs::read(dir.join(p)).unwrap();
    let index =
        read("manifests/sha256_17367e4d3ae2b46c3290858e483fac09be085c92b372360fe3bf6a6150848fde");
    let amd64 = "483300cd4680af8b3388dd6883a9fb09e9d9c1f700f59e8eac8d0870eb3b04fd";
    let mut manifest: serde_json::Value =
        serde_json::from_slice(&read(&format!("manifests/sha256_{amd64}"))).unwrap();
    let mut files: Vec<(String, Vec<u8>)> = Vec::new();
    for l in manifest["layers"]
        .as_array()
        .unwrap()
        .iter()
        .chain([&manifest["config"]])
    {
        let d = l["digest"]
            .as_str()
            .unwrap()
            .strip_prefix("sha256:")
            .unwrap()
            .to_string();
        files.push((
            format!("blobs/sha256/{d}"),
            read(&format!("blobs/sha256_{d}")),
        ));
    }
    let mut index_doc: serde_json::Value = serde_json::from_slice(&index).unwrap();
    let top;
    if let Some(extra) = extra {
        // A derived image: the static base plus a layer, under its own manifest.
        let layer = gz(&tar_of(&extra));
        let d = sha(&layer);
        manifest["layers"].as_array_mut().unwrap().push(serde_json::json!({
            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": format!("sha256:{d}"), "size": layer.len()}));
        files.push((format!("blobs/sha256/{d}"), layer));
        let m = serde_json::to_vec(&manifest).unwrap();
        top = sha(&m);
        files.push((format!("blobs/sha256/{top}"), m));
        index_doc = serde_json::json!({"manifests": [{"digest": format!("sha256:{top}")}]});
    } else {
        top = "17367e4d3ae2b46c3290858e483fac09be085c92b372360fe3bf6a6150848fde".to_string();
        files.push((format!("blobs/sha256/{top}"), index));
        files.push((
            format!("blobs/sha256/{amd64}"),
            read(&format!("manifests/sha256_{amd64}")),
        ));
    }
    let layout_index =
        serde_json::json!({"schemaVersion": 2, "manifests": [{"digest": format!("sha256:{top}")}]});
    let _ = index_doc;
    files.push((
        "index.json".into(),
        serde_json::to_vec(&layout_index).unwrap(),
    ));
    files.push((
        "oci-layout".into(),
        br#"{"imageLayoutVersion":"1.0.0"}"#.to_vec(),
    ));
    tar_of(&files)
}

/// The real Wolfi advisories for openssl (OSV, fetched 2026-10-23).
fn wolfi_advisories() -> Arc<dyn Scanner> {
    let p = fixture("sandbox/osv-wolfi-openssl.json");
    let db = holger_server_lib::security::advisory_db_from_osv_file(p.to_str().unwrap()).unwrap();
    Arc::new(holger_server_lib::scanner::osv::OsvVulnScanner::new(db))
}

#[test]
fn chainguard_static_gets_its_package_list_and_a_clean_cve_list() {
    let img = chainguard_static(None);
    let w = worker("docker");
    let v = VulnFromWorker::new(wolfi_advisories(), Arc::clone(&w));
    let o = v
        .scan(&ScanSubject::new(
            "chainguard/static@sha256_17367e4d.oci.tar",
            "docker",
            &img,
        ))
        .unwrap();
    assert_eq!(o.verdict, ScanVerdict::Pass, "{o:?}");
    assert!(o.findings.is_empty(), "{o:?}");
    // Three apk packages; ca-certificates-bundle's origin is matched too.
    assert!(
        o.detail
            .starts_with("image: 4 package(s) from layers (os wolfi)"),
        "{}",
        o.detail
    );
    let pk = &o.document.as_ref().unwrap()["packages"];
    let names: Vec<&str> = pk
        .as_array()
        .unwrap()
        .iter()
        .map(|p| p["name"].as_str().unwrap())
        .collect();
    for want in [
        "ca-certificates-bundle",
        "ca-certificates",
        "tzdata",
        "wolfi-baselayout",
    ] {
        assert!(names.contains(&want), "{want} in {names:?}");
    }
}

#[test]
fn an_image_with_known_cves_gets_each_with_its_severity() {
    // The static base, plus a layer that installs an old openssl (the apk database rewritten).
    let base = chainguard_static(None);
    let db = {
        let dir = fixture("pull_through/oci/chainguard/static/blobs");
        let layer = std::fs::read(
            dir.join("sha256_81372b5898bbb29bafead3628e232f0381ce6099b2e4173df12304711ab4136a"),
        )
        .unwrap();
        let mut ar = tar::Archive::new(flate2::read::GzDecoder::new(layer.as_slice()));
        let mut text = String::new();
        for e in ar.entries().unwrap() {
            let mut e = e.unwrap();
            if e.path().unwrap().to_string_lossy() == "usr/lib/apk/db/installed" {
                std::io::Read::read_to_string(&mut e, &mut text).unwrap();
            }
        }
        format!(
            "{}\n\nP:openssl\nV:3.0.8-r0\nA:x86_64\no:openssl\n",
            text.trim_end()
        )
    };
    let _ = base;
    let img = chainguard_static(Some(vec![(
        "usr/lib/apk/db/installed".into(),
        db.into_bytes(),
    )]));
    let v = VulnFromWorker::new(wolfi_advisories(), worker("docker"));
    let o = v
        .scan(&ScanSubject::new(
            "demo/openssl@sha256_x.oci.tar",
            "docker",
            &img,
        ))
        .unwrap();
    eprintln!("{}\n{:#?}", o.detail, o.findings);
    let ids: Vec<&str> = o.findings.iter().map(|f| f.id.as_str()).collect();
    for want in [
        "CGA-22p8-hhxj-9pcc",
        "CGA-2c5c-rx22-cxxf",
        "CGA-2m6v-xf35-w7r6",
    ] {
        assert!(ids.contains(&want), "{want} in {ids:?}");
    }
    assert!(
        o.findings
            .iter()
            .all(|f| f.summary.contains("wolfi openssl@3.0.8-r0")),
        "{:?}",
        o.findings
    );
    assert!(
        o.findings
            .iter()
            .all(|f| f.severity >= FindingSeverity::Low),
        "each CVE carries a severity: {:?}",
        o.findings
    );
    assert!(o.detail.contains("5 package(s)"), "{}", o.detail);
    assert!(
        o.findings
            .iter()
            .any(|f| f.severity >= FindingSeverity::High),
        "the real CVSS vectors score high: {:?}",
        o.findings
    );
    assert_ne!(
        o.verdict,
        ScanVerdict::Error,
        "an image's vulnerability scan is a verdict, so cve_max can hold"
    );
}

fn gzip_bomb(mib: usize) -> Vec<u8> {
    use std::io::Write as _;
    let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
    let zeros = vec![0u8; 1 << 20];
    for _ in 0..mib {
        e.write_all(&zeros).unwrap();
    }
    e.finish().unwrap()
}

#[test]
fn a_planted_zip_bomb_is_refused_by_name() {
    let w = worker("pip");
    let f = w.inspect(&ScanSubject::new("demo-1.0.tar.gz", "pip", &gzip_bomb(64)));
    assert!(
        f.has(
            holger_inspect::FindingKind::ZipBomb,
            holger_inspect::Severity::Block
        ),
        "{:?}",
        f.findings
    );
    assert_eq!(f.verdict(), holger_inspect::Verdict::Fail);
}

#[test]
#[ignore = "the container arm: HOLGER_TEST_SANDBOX=required (cargo xtask scan-worker tests)"]
fn a_bomb_the_inspector_lets_through_is_killed_by_the_sandbox_and_refused_by_name() {
    if !contained() {
        panic!("needs HOLGER_TEST_SANDBOX=required: the wall clock is the container's");
    }
    // The inspector told to let everything through; the container's limits stay.
    let loose = holger_inspect::InspectConfig {
        limits: holger_inspect::Limits {
            max_ratio: u64::MAX / 2,
            ratio_floor_bytes: u64::MAX / 2,
            max_total_expanded: u64::MAX / 2,
            max_entry_bytes: u64::MAX / 2,
            ..Default::default()
        },
        ..Default::default()
    };
    // 32 GiB of zeros as 512 concatenated gzip members of 64 MiB each (one member
    // compressed once, about 64 KiB, repeated): about 32 MiB on the wire.
    let member = gzip_bomb(64);
    let outer: Vec<u8> = std::iter::repeat_n(member.as_slice(), 512)
        .flatten()
        .copied()
        .collect();
    let w = worker_with(
        "pip",
        loose,
        Limits {
            timeout_secs: 5,
            ..Default::default()
        },
    );
    let t = std::time::Instant::now();
    let f = w.inspect(&ScanSubject::new("demo-1.0.tar.gz", "pip", &outer));
    eprintln!("{:?} in {:?}", f.findings, t.elapsed());
    assert_eq!(f.verdict(), holger_inspect::Verdict::Fail);
    assert!(
        f.findings[0]
            .detail
            .starts_with("sandbox-timeout: [holger-sandbox-2]"),
        "{:?}",
        f.findings
    );
}

#[test]
fn a_traversal_archive_is_refused_and_writes_nothing_outside_the_scratch() {
    let evil = gz(&tar_of(&[
        (
            "demo-1.0/PKG-INFO".into(),
            b"Metadata-Version: 2.1\nName: demo\nVersion: 1.0\n".to_vec(),
        ),
        (
            "../../../../tmp/holger-sandbox-escape".into(),
            b"pwned".to_vec(),
        ),
        ("/etc/holger-sandbox-escape".into(), b"pwned".to_vec()),
    ]));
    // A job directory of this test's own: the other tests scan beside it.
    let work = std::env::var("HOLGER_TEST_SANDBOX_WORKDIR")
        .ok()
        .map(|w| PathBuf::from(w).join(format!("traversal-{}", std::process::id())));
    let before = work.as_ref().map(|w| {
        std::fs::create_dir_all(w).unwrap();
        std::fs::read_dir(w).map(|d| d.count()).unwrap_or(0)
    });
    let w = match &work {
        Some(dir) if contained() => Arc::new(
            ScanWorker::new(
                SandboxRule::Required,
                Some(SandboxConfig {
                    work_dir: Some(dir.display().to_string()),
                    ..sandbox_cfg(Limits::default())
                }),
                template("pip", Default::default()),
            )
            .unwrap(),
        ),
        _ => worker("pip"),
    };
    let f = w.inspect(&ScanSubject::new("demo-1.0.tar.gz", "pip", &evil));
    assert!(
        f.has(
            holger_inspect::FindingKind::PathTraversal,
            holger_inspect::Severity::Block
        ),
        "{:?}",
        f.findings
    );
    assert!(
        f.has(
            holger_inspect::FindingKind::AbsolutePath,
            holger_inspect::Severity::Block
        ),
        "{:?}",
        f.findings
    );
    assert!(!std::path::Path::new("/tmp/holger-sandbox-escape").exists());
    assert!(!std::path::Path::new("/etc/holger-sandbox-escape").exists());
    if let (Some(w), Some(n)) = (work, before) {
        assert_eq!(
            std::fs::read_dir(&w).map(|d| d.count()).unwrap_or(0),
            n,
            "the job directory was removed and nothing was left"
        );
    }
}

#[test]
#[ignore = "the container arm: a clamd in its own sandbox (HOLGER_TEST_CLAMD_SOCKET, cargo xtask scan-worker tests)"]
fn eicar_is_found_by_clamd_in_its_sandbox() {
    let sock = std::env::var("HOLGER_TEST_CLAMD_SOCKET")
        .expect("HOLGER_TEST_CLAMD_SOCKET: the sandboxed clamd's socket");
    let inspect = holger_inspect::InspectConfig {
        clamav: Some(holger_inspect::malware::ClamAvConfig {
            socket: Some(sock),
            required: true,
            ..Default::default()
        }),
        ..Default::default()
    };
    let w = worker_with("raw", inspect, Limits::default());
    let eicar = format!(
        "{}{}",
        "X5O!P%@AP[4\\PZX54(P^)7CC)7}$", "EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
    )
    .into_bytes();
    let f = w.inspect(&ScanSubject::new("eicar.com", "raw", &eicar));
    assert!(
        f.has(
            holger_inspect::FindingKind::Malware,
            holger_inspect::Severity::Block
        ),
        "{:?}",
        f.findings
    );
    assert!(
        f.findings
            .iter()
            .any(|x| x.detail.contains("clamav") && x.detail.contains("Eicar")),
        "{:?}",
        f.findings
    );
    let clean = w.inspect(&ScanSubject::new("notes.txt", "raw", b"hello"));
    assert_eq!(
        clean.verdict(),
        holger_inspect::Verdict::Pass,
        "{:?}",
        clean.findings
    );
}

#[test]
fn with_no_engine_every_scan_is_refused_by_name_and_nothing_is_scanned_here() {
    // `sandbox: required` and no podman: the server does not fall back to
    // scanning in its own process. A zip bomb is refused for the missing
    // engine, never as a zip bomb: the walk did not run here.
    let cfg = SandboxConfig {
        podman: "/nonexistent/holger-test/podman".into(),
        ..Default::default()
    };
    let w = Arc::new(
        ScanWorker::new(
            SandboxRule::Required,
            Some(cfg),
            template("pip", Default::default()),
        )
        .unwrap(),
    );
    let bomb = gzip_bomb(64);
    let s = ScanSubject::new("demo-1.0.tar.gz", "pip", &bomb);
    let f = w.inspect(&s);
    assert_eq!(f.verdict(), holger_inspect::Verdict::Fail);
    assert!(
        !f.has(
            holger_inspect::FindingKind::ZipBomb,
            holger_inspect::Severity::Info
        ),
        "the walk ran in-process: {:?}",
        f.findings
    );
    assert!(
        f.findings[0].detail.contains("[holger-sandbox-1]"),
        "{:?}",
        f.findings
    );
    for p in WorkerScanner::all(&w) {
        let o = p.scan(&s).unwrap();
        assert_eq!(
            (o.verdict, o.findings[0].id.as_str()),
            (ScanVerdict::Block, "sandbox-no-engine"),
            "{}",
            p.id()
        );
    }
    let v = VulnFromWorker::new(wolfi_advisories(), Arc::clone(&w))
        .scan(&s)
        .unwrap();
    assert_eq!(v.verdict, ScanVerdict::Block, "{v:?}");
}

/// The per-package overhead of the sandbox: one small wheel scanned 10 times
/// (each a fresh subject, so nothing is reused), in the container and in this
/// process. Prints `SANDBOX-OVERHEAD` with both medians, for the bench lane.
#[test]
#[ignore = "the container arm: HOLGER_TEST_SANDBOX=required (cargo xtask scan-worker tests)"]
fn the_per_package_overhead_of_the_sandbox() {
    if !contained() {
        panic!("needs HOLGER_TEST_SANDBOX=required");
    }
    let wheel = |i: usize| {
        let mut files = vec![(
            format!("demo{i}-1.0.dist-info/METADATA"),
            format!(
                "Metadata-Version: 2.1\nName: demo{i}\nVersion: 1.0\nLicense-Expression: MIT\n"
            )
            .into_bytes(),
        )];
        files.push((format!("demo{i}/__init__.py"), vec![b'#'; 256 << 10]));
        // A zip, as a wheel is: build it with the server's own fixture writer.
        let refs: Vec<(&str, &[u8])> = files
            .iter()
            .map(|(p, b)| (p.as_str(), b.as_slice()))
            .collect();
        zip_entry::fixture::zip(&refs)
    };
    let median = |w: &Arc<ScanWorker>| {
        let mut ms: Vec<u128> = (0..10)
            .map(|i| {
                let b = wheel(i);
                let t = std::time::Instant::now();
                let name = format!("demo{i}-1.0-py3-none-any.whl");
                let f = w.inspect(&ScanSubject::new(&name, "pip", &b));
                assert_eq!(
                    f.verdict(),
                    holger_inspect::Verdict::Pass,
                    "{:?}",
                    f.findings
                );
                t.elapsed().as_millis()
            })
            .collect();
        ms.sort();
        ms[ms.len() / 2]
    };
    let boxed = median(&worker("raw"));
    let here = median(&Arc::new(
        ScanWorker::new(SandboxRule::Off, None, template("raw", Default::default())).unwrap(),
    ));
    println!("SANDBOX-OVERHEAD per package (256 KiB wheel, all five in-worker scanners): container {boxed} ms, in-process {here} ms, overhead {} ms", boxed.saturating_sub(here));
}