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()),
// H32-FAILCLOSED: nothing judged the bytes: held and scanned again.
(ScanVerdict::Unknown, "sandbox-no-engine"),
"{}",
p.id()
);
}
let v = VulnFromWorker::new(wolfi_advisories(), Arc::clone(&w))
.scan(&s)
.unwrap();
assert_eq!(v.verdict, ScanVerdict::Unknown, "{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));
}